You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

557 lines
17 KiB

# Ensure MySQL + Redis are ready. Returns: docker | native
# Dot-sourced from start.ps1
function Get-DevInfraModeFromEnv {
param([string]$Root)
$envFile = Join-Path $Root ".env"
if (-not (Test-Path $envFile)) {
return "auto"
}
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*DEV_INFRA_MODE\s*=\s*(\S+)\s*$') {
return $Matches[1].Trim().ToLower()
}
}
return "auto"
}
function Get-EnvDbEndpoints {
param([string]$Root)
$envFile = Join-Path $Root ".env"
$mysqlHost = "localhost"
$mysqlPort = 3307
$redisHost = "localhost"
$redisPort = 6379
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*DATABASE_URL\s*=\s*mysql://[^@]+@([^:/]+):(\d+)/') {
$mysqlHost = $Matches[1]
$mysqlPort = [int]$Matches[2]
}
if ($line -match '^\s*REDIS_URL\s*=\s*redis://([^:/]+):(\d+)') {
$redisHost = $Matches[1]
$redisPort = [int]$Matches[2]
}
if ($line -match '^\s*MYSQL_HOST_PORT\s*=\s*(\d+)') {
$mysqlPort = [int]$Matches[1]
}
}
}
return @{
MysqlHost = $mysqlHost
MysqlPort = $mysqlPort
RedisHost = $redisHost
RedisPort = $redisPort
}
}
function Test-TcpPortOpen {
param(
[string]$HostName,
[int]$Port,
[int]$TimeoutMs = 2000
)
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$connect = $client.BeginConnect($HostName, $Port, $null, $null)
$ok = $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
if (-not $ok) {
return $false
}
$client.EndConnect($connect)
return $true
}
catch {
return $false
}
finally {
if ($client) {
$client.Close()
}
}
}
function Get-WslHostIp {
$distro = Get-DevWslDistro
if (-not $distro) { return $null }
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
$raw = & wsl.exe -d $distro -e hostname -I 2>&1 | Out-String
$ErrorActionPreference = $prev
$text = ($raw -replace "`0", "").Trim()
$ip = ($text -split '\s+')[0]
if ($ip -match '^\d+\.\d+\.\d+\.\d+$') { return $ip }
return $null
}
function Resolve-InfraHost {
param(
[string]$Root,
[int]$MysqlPort,
[int]$RedisPort
)
$localRedisUrl = "redis://127.0.0.1:${RedisPort}"
if ((Test-TcpPortOpen -HostName "127.0.0.1" -Port $MysqlPort) -and
(Test-TcpPortOpen -HostName "127.0.0.1" -Port $RedisPort) -and
(Test-RedisPingFromNode -RedisUrl $localRedisUrl)) {
return "127.0.0.1"
}
$wslIp = Get-WslHostIp
if ($wslIp) {
$wslRedisUrl = "redis://${wslIp}:${RedisPort}"
if ((Test-TcpPortOpen -HostName $wslIp -Port $MysqlPort) -and
(Test-TcpPortOpen -HostName $wslIp -Port $RedisPort) -and
(Test-RedisPingFromNode -RedisUrl $wslRedisUrl)) {
return $wslIp
}
}
if ((Test-TcpPortOpen -HostName "127.0.0.1" -Port $MysqlPort) -and
(Test-TcpPortOpen -HostName "127.0.0.1" -Port $RedisPort)) {
return "127.0.0.1"
}
if ($wslIp -and
(Test-TcpPortOpen -HostName $wslIp -Port $MysqlPort) -and
(Test-TcpPortOpen -HostName $wslIp -Port $RedisPort)) {
return $wslIp
}
return "127.0.0.1"
}
function Apply-DevInfraEnv {
param([string]$Root)
$ep = Get-EnvDbEndpoints -Root $Root
$infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort
if ($infraHost -ne "127.0.0.1" -and $infraHost -ne "localhost") {
Write-DevLog "WSL localhost forward unavailable, using $infraHost" "Warn"
}
$envFile = Join-Path $Root ".env"
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*DATABASE_URL\s*=\s*(.+)$') {
$env:DATABASE_URL = ($Matches[1] -replace '@([^:/]+):', "@${infraHost}:")
}
if ($line -match '^\s*REDIS_URL\s*=\s*(.+)$') {
$env:REDIS_URL = ($Matches[1] -replace 'redis://([^:/]+):', "redis://${infraHost}:")
}
}
}
Ensure-DevDirs
$infraEnv = Join-Path $script:DevConfig.DevDir "infra.env"
$wslHostIp = Get-WslHostIp
$lines = @(
"DATABASE_URL=$($env:DATABASE_URL)"
"REDIS_URL=$($env:REDIS_URL)"
)
if ($wslHostIp) {
$lines += "WSL_HOST_IP=$wslHostIp"
}
$lines | Set-Content -Path $infraEnv -Encoding ascii
}
function Test-NativeInfraPorts {
param([string]$Root)
$ep = Get-EnvDbEndpoints -Root $Root
$infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort
$mysqlOk = Test-TcpPortOpen -HostName $infraHost -Port $ep.MysqlPort
$redisOk = Test-TcpPortOpen -HostName $infraHost -Port $ep.RedisPort
return @{
Ok = ($mysqlOk -and $redisOk)
Mysql = $mysqlOk
Redis = $redisOk
Host = $infraHost
Endpoints = $ep
}
}
function Test-NativeInfraReady {
param([string]$Root)
$ports = Test-NativeInfraPorts -Root $Root
if (-not $ports.Ok) {
return $false
}
Apply-DevInfraEnv -Root $Root
if (-not $env:REDIS_URL) {
return $false
}
return Test-RedisPingFromNode -RedisUrl $env:REDIS_URL
}
function Get-DockerComposeArgs {
$files = $script:DevConfig.ComposeFiles
$args = @()
foreach ($f in $files) {
$args += "-f"
$args += (Join-Path $script:DevConfig.ProjectRoot $f)
}
return $args
}
function Invoke-DockerComposeProject {
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$ComposeArgs)
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
$base = Get-DockerComposeArgs
docker compose @base @ComposeArgs 2>&1 | Out-Null
$code = $LASTEXITCODE
$ErrorActionPreference = $prev
return $code
}
function Invoke-DockerInfoSafe {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "docker"
$psi.Arguments = "info"
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
$finished = $proc.WaitForExit(12000)
if (-not $finished) {
try { $proc.Kill() } catch {}
return @{ Code = -1; Output = "docker info timeout (engine likely stuck)" }
}
$output = $proc.StandardOutput.ReadToEnd() + $proc.StandardError.ReadToEnd()
return @{ Code = $proc.ExitCode; Output = $output }
}
function Get-DockerEngineStatus {
$info = Invoke-DockerInfoSafe
$output = $info.Output
$code = $info.Code
if ($code -eq 0 -and $output -match "(?m)^Server:") {
return "ok"
}
if ($output -match "500|Internal Server Error|request returned 500|engine likely stuck") {
return "engine_500"
}
if ($output -match "pipe|cannot find the file|connection refused|not running") {
return "not_running"
}
return "error"
}
function Get-WslInfraScriptPath {
param([string]$Root)
$drive = $Root.Substring(0, 1).ToLower()
$rest = $Root.Substring(2) -replace '\\', '/'
return "/mnt/$drive$rest/scripts/dev/wsl-infra.sh"
}
function Start-WslInfra {
param([string]$Root)
$scriptPath = Get-WslInfraScriptPath -Root $Root
$code = Invoke-DevWsl -BashCommand "bash '$scriptPath' status"
if ($code -ne 0) {
Write-DevLog "starting mysql+redis in WSL (no Docker Desktop)..." "Warn"
$code = Invoke-DevWsl -BashCommand "export MYSQL_HOST_PORT=3307 MYSQL_ROOT_PASSWORD=changeme; bash '$scriptPath' start"
}
if ($code -eq 0) {
$ep = Get-EnvDbEndpoints -Root $Root
$infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort
$redisUrl = "redis://${infraHost}:$($ep.RedisPort)"
if (Wait-InfraReadyFromWindows -HostName $infraHost -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort -RedisUrl $redisUrl) {
Write-DevLog "wsl mysql:3307 + redis:6379 ready" "Ok"
}
else {
Write-DevLog "wsl infra started; Windows probe still waiting (will retry)" "Warn"
}
$distro = $env:WSL_DISTRO
if (-not $distro) { $distro = "Ubuntu-22.04" }
Start-WslKeepAlive -Distro $distro
}
return ($code -eq 0)
}
function Repair-DockerEngine {
param([int]$MaxAttempts = 1)
Write-DevLog "docker repair: quit desktop + wsl shutdown + restart (NO SwitchDaemon)" "Warn"
$desktopExe = Join-Path ${env:ProgramFiles} "Docker\Docker\Docker Desktop.exe"
if (Test-Path $desktopExe) {
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
Start-Process -FilePath $desktopExe -ArgumentList "--quit" -ErrorAction SilentlyContinue | Out-Null
$ErrorActionPreference = $prev
Start-Sleep -Seconds 8
}
@(
"Docker Desktop",
"com.docker.backend",
"com.docker.proxy",
"vpnkit",
"wslrelay"
) | ForEach-Object {
Get-Process -Name $_ -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
}
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
wsl --shutdown 2>&1 | Out-Null
$ErrorActionPreference = $prev
Start-Sleep -Seconds 8
if (Test-Path $desktopExe) {
Start-Process -FilePath $desktopExe | Out-Null
}
$deadline = (Get-Date).AddSeconds(120)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
if ((Get-DockerEngineStatus) -eq "ok") {
Write-DevLog "docker engine recovered" "Ok"
return $true
}
}
Write-DevLog "docker repair failed - use WSL infra instead: npm run dev:infra:setup" "Err"
return $false
}
function Wait-DockerDaemonQuick {
param([int]$TimeoutSec = 25)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
if ((Get-DockerEngineStatus) -eq "ok") {
Write-DevLog "docker daemon ready" "Ok"
return $true
}
Start-Sleep -Seconds 3
}
Write-DevLog "docker not ready in ${TimeoutSec}s (skip repair on start)" "Warn"
return $false
}
function Wait-DockerHealthy {
param(
[string[]]$Services,
[int]$TimeoutSec = 90
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
Push-Location $script:DevConfig.ProjectRoot
try {
$composeBase = Get-DockerComposeArgs
while ((Get-Date) -lt $deadline) {
$allOk = $true
foreach ($svc in $Services) {
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
$json = docker compose @composeBase ps --format json $svc 2>$null
$ErrorActionPreference = $prev
if (-not $json) {
$allOk = $false
break
}
$status = $json | ConvertFrom-Json
if (-not $status -or $status.Health -ne "healthy") {
$allOk = $false
break
}
}
if ($allOk) {
Write-DevLog "docker healthy: $($Services -join ', ')" "Ok"
return $true
}
Start-Sleep -Seconds 2
}
throw "docker not healthy within ${TimeoutSec}s"
}
finally {
Pop-Location
}
}
function Start-DockerInfra {
param([string]$Root)
Push-Location $Root
try {
if (-not (Wait-DockerDaemonQuick -TimeoutSec $script:DevConfig.DockerDaemonWaitSec)) {
return $false
}
Write-DevLog "docker compose up mysql redis (dev profile)..."
$code = Invoke-DockerComposeProject up -d mysql redis
if ($code -ne 0) {
return $false
}
Wait-DockerHealthy -Services $script:DevConfig.DockerServices -TimeoutSec $script:DevConfig.DockerWaitSec
return $true
}
finally {
Pop-Location
}
}
function Test-RedisPingFromNode {
param([string]$RedisUrl)
if (-not $RedisUrl) { return $false }
$escaped = $RedisUrl.Replace('"', '\"')
$nodeScript = @"
const Redis=require('ioredis');
(async()=>{
const r=new Redis('$escaped',{lazyConnect:true,connectTimeout:1500,maxRetriesPerRequest:1,retryStrategy:()=>null});
try{
await r.connect();
const pong=await r.ping();
await r.quit();
process.exit(pong==='PONG'?0:1);
}catch{
await r.quit().catch(()=>{});
process.exit(1);
}
})();
"@
$prev = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
$null = node -e $nodeScript 2>&1
$ok = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = $prev
return $ok
}
function Wait-InfraReadyFromWindows {
param(
[string]$HostName,
[int]$MysqlPort,
[int]$RedisPort,
[string]$RedisUrl,
[int]$TimeoutSec = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$attempt = 0
Wake-WslRedisForward
while ((Get-Date) -lt $deadline) {
$attempt += 1
$mysqlOk = Test-TcpPortOpen -HostName $HostName -Port $MysqlPort -TimeoutMs 800
$redisOk = Test-RedisPingFromNode -RedisUrl $RedisUrl
if ($mysqlOk -and $redisOk) {
return $true
}
if ($attempt -eq 1 -or ($attempt % 2) -eq 0) {
Wake-WslRedisForward
}
$sleepMs = if ($attempt -le 6) { 350 } elseif ($attempt -le 12) { 700 } else { 1200 }
Start-Sleep -Milliseconds $sleepMs
}
return $false
}
function Ensure-DevInfra {
param(
[string]$Root,
[string]$Mode = "auto"
)
$mode = $Mode.ToLower()
if ($mode -eq "auto") {
$mode = Get-DevInfraModeFromEnv -Root $Root
}
Write-DevLog "infra mode: $mode"
if ($mode -eq "native") {
if (Test-NativeInfraReady -Root $Root) {
Apply-DevInfraEnv -Root $Root
$distro = $env:WSL_DISTRO
if (-not $distro) { $distro = "Ubuntu-22.04" }
Start-WslKeepAlive -Distro $distro
Write-DevLog "using native mysql/redis" "Ok"
return "native"
}
Write-DevLog "native ports not ready, trying WSL mysql/redis..." "Warn"
if (Start-WslInfra -Root $Root) {
if (Test-NativeInfraReady -Root $Root) {
Apply-DevInfraEnv -Root $Root
Write-DevLog "using wsl mysql/redis" "Ok"
return "wsl"
}
}
$ports = Test-NativeInfraPorts -Root $Root
throw @"
native mode: mysql/redis not ready.
mysql $($ports.Endpoints.MysqlPort): $(if ($ports.Mysql) { 'open' } else { 'closed' })
redis $($ports.Endpoints.RedisPort): $(if ($ports.Redis) { 'open' } else { 'closed' })
Run once: npm run dev:infra:setup
Then: npm run dev:start
"@
}
if ($mode -eq "docker") {
if (-not (Start-DockerInfra -Root $Root)) {
throw "docker mode: failed to start mysql/redis. Run: npm run dev:repair-docker"
}
Write-DevLog "using docker mysql/redis" "Ok"
return "docker"
}
# auto: docker quick try -> wsl infra -> native ports
if (Start-DockerInfra -Root $Root) {
Write-DevLog "using docker mysql/redis" "Ok"
return "docker"
}
Write-DevLog "docker unavailable, trying WSL mysql/redis..." "Warn"
if (Start-WslInfra -Root $Root) {
if (Test-NativeInfraReady -Root $Root) {
Apply-DevInfraEnv -Root $Root
Write-DevLog "using wsl mysql/redis" "Ok"
return "wsl"
}
}
Write-DevLog "wsl infra failed, trying native ports..." "Warn"
if (Test-NativeInfraReady -Root $Root) {
Apply-DevInfraEnv -Root $Root
Write-DevLog "using native mysql/redis (fallback)" "Ok"
return "native"
}
$ports = Test-NativeInfraPorts -Root $Root
throw @"
Cannot start dev infrastructure.
Root cause: Docker Desktop WSL engine IPC deadlock (engine 500 / context deadline exceeded).
Do NOT use DockerCli -SwitchDaemon - it makes this worse.
Recommended permanent fix:
1. npm run dev:infra:setup # install mysql+redis in WSL (no Docker Desktop)
2. Set DEV_INFRA_MODE=native in .env
3. npm run dev:start
Or diagnose Docker: npm run dev:diagnose-docker
Current ports:
mysql $($ports.Endpoints.MysqlPort): $(if ($ports.Mysql) { 'open' } else { 'closed' })
redis $($ports.Endpoints.RedisPort): $(if ($ports.Redis) { 'open' } else { 'closed' })
"@
}