diff --git a/packaging/README.md b/packaging/README.md index 26c2b98..fdc805a 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -125,6 +125,7 @@ Both sides should publish the same release assets (`NetX-*-win64.zip`). Code mir ## Windows Service (admin) Uses [WinSW](https://github.com/winsw/winsw) (downloaded on first install into `packaging/cache`). +`service_run.ps1` probes `/health` and restarts children after consecutive failures. ```powershell # Elevated PowerShell diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 index 4a1ef66..4bba789 100644 --- a/packaging/_common.ps1 +++ b/packaging/_common.ps1 @@ -135,3 +135,72 @@ function Import-NetxEnvFile { } return $map } + +function ConvertTo-NetxFsPath([string]$Path) { + # Forward slashes work in .env and most Windows APIs via Python pathlib. + return ($Path -replace '\\', '/') +} + +function Get-NetxDataEnvMap { + param([string]$DataRoot) + $d = $DataRoot + return @{ + "NETX_AUTH_MCP_TOKEN_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\mcp_token")) + "NETX_AUTH_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\jwt_secret")) + "NETX_SCHEDULER_HEARTBEAT_PATH" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime\scheduler_heartbeat.json")) + "NETX_BIZ_STATE_SPOOL_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\biz_state_spool")) + "NETX_NE_COLLECTION_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_collections")) + "NETX_NE_EXEC_JOB_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_exec_jobs")) + "NETX_WEBCRT_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\webcrt")) + } +} + +function Ensure-NetxDataDirectories { + param([string]$DataRoot) + $subs = @( + "data", "data\auth", "data\runtime", "data\biz_state_spool", + "data\ne_collections", "data\ne_exec_jobs", "data\webcrt", + "backups", "pgdata" + ) + foreach ($sub in $subs) { + $p = Join-Path $DataRoot $sub + if (-not (Test-Path $p)) { + New-Item -ItemType Directory -Path $p -Force | Out-Null + } + } +} + +function Test-NetxTcpPortFree { + param([string]$HostName = "127.0.0.1", [int]$Port) + try { + $client = New-Object System.Net.Sockets.TcpClient + $iar = $client.BeginConnect($HostName, $Port, $null, $null) + $ok = $iar.AsyncWaitHandle.WaitOne(400) + if ($ok -and $client.Connected) { + $client.Close() + return $false + } + $client.Close() + return $true + } catch { + return $true + } +} + +function Test-NetxApiHealthy { + param([string]$HostName = "127.0.0.1", [int]$Port = 8890, [int]$TimeoutSec = 2) + try { + $url = "http://${HostName}:${Port}/health" + $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $TimeoutSec -MaximumRedirection 0 + if ($r.StatusCode -ne 200) { return $false } + $body = $r.Content | ConvertFrom-Json -ErrorAction Stop + return ($body.status -eq "ok") + } catch { + return $false + } +} + +function ConvertTo-NetxSqlLiteral([string]$Value) { + # Single-quote escaping for PostgreSQL string literals. + return ($Value -replace "'", "''") +} diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index 6563203..75f552f 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -316,7 +316,14 @@ try { $zipName = "NetX-$latest-win64.zip" $zipPath = Join-Path $dlDir $zipName Write-Host "==> Downloading $zipName from $($result.source) ..." - $dlHeaders = Get-AuthHeaders -Style "token" + # Only attach token for non-GitHub hosts (Forgejo/private). Public GitHub assets need no auth; + # a Forgejo token would break anonymous GitHub downloads. + $dlHeaders = @{ "User-Agent" = "NetX-UpdateCheck" } + $dlUri = [uri]$result.download_url + $isGithub = ($dlUri.Host -match '(^|\.)github\.com$' -or $dlUri.Host -match '(^|\.)githubusercontent\.com$') + if ($UpdateToken -and -not $isGithub) { + $dlHeaders["Authorization"] = "token $UpdateToken" + } Invoke-WebRequest -Uri $result.download_url -OutFile $zipPath -Headers $dlHeaders -UseBasicParsing if ($result.sha256 -and $result.sha256 -notmatch 'REPLACE' -and $result.sha256.Trim()) { $hash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index 19838d5..63a1a5c 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -4,7 +4,7 @@ #define MyAppName "NetX" #ifndef MyAppVersion - #define MyAppVersion "0.3.0" + #define MyAppVersion "0.4.0" #endif #define MyAppPublisher "NetX" #define MyAppURL "https://github.com/hansjone/netx" diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json index 6d4b4fd..a6c18bd 100644 --- a/packaging/manifest.example.json +++ b/packaging/manifest.example.json @@ -1,11 +1,11 @@ { "channel": "stable", - "latest": "0.3.0", + "latest": "0.4.0", "min_compatible": "0.3.0", - "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.3.0", + "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.0", "windows": { - "url": "https://github.com/hansjone/netx/releases/download/v0.3.0/NetX-0.3.0-win64.zip", - "setup_url": "https://github.com/hansjone/netx/releases/download/v0.3.0/NetX-Setup-0.3.0.exe", + "url": "https://github.com/hansjone/netx/releases/download/v0.4.0/NetX-0.4.0-win64.zip", + "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.0/NetX-Setup-0.4.0.exe", "sha256": "", "size": 0 } diff --git a/packaging/service_run.ps1 b/packaging/service_run.ps1 index f12ee38..40cc61f 100644 --- a/packaging/service_run.ps1 +++ b/packaging/service_run.ps1 @@ -1,10 +1,12 @@ param( [string]$ProgramRoot = "", - [string]$DataRoot = "" + [string]$DataRoot = "", + [int]$HealthFailLimit = 6, + [int]$HealthIntervalSec = 10 ) # Long-running supervisor for Windows Service / Task Scheduler. -# Starts NetX (and bundled PG), waits until stopped, then tears down. +# Starts NetX, probes /health; repeated failures trigger restart (or exit for WinSW). $ErrorActionPreference = "Stop" . "$PSScriptRoot\_common.ps1" @@ -16,6 +18,7 @@ if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $logFile = Join-Path $logDir "service_run.log" +$stopFlag = Join-Path $logDir "service.stop" function Write-SvcLog([string]$Msg) { $line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Msg @@ -23,40 +26,75 @@ function Write-SvcLog([string]$Msg) { Write-Host $line } -$stopFlag = Join-Path $logDir "service.stop" -Remove-Item -Force $stopFlag -ErrorAction SilentlyContinue +function Get-BindInfo { + $envPath = Join-Path $data ".env" + $hostBind = "127.0.0.1" + $port = 8890 + if (Test-Path $envPath) { + $map = Read-DotEnv -Path $envPath + if ($map["NETX_HOST"]) { $hostBind = $map["NETX_HOST"] } + if ($map["NETX_PORT"]) { try { $port = [int]$map["NETX_PORT"] } catch {} } + } + return @{ Host = $hostBind; Port = $port } +} +Remove-Item -Force $stopFlag -ErrorAction SilentlyContinue Write-SvcLog "service_run starting program=$prog data=$data" $startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1" $stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1" +$bind = Get-BindInfo +$failStreak = 0 +$restartCount = 0 -try { +function Start-NetxChildren { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startPs1 ` - -ProgramRoot $prog -DataRoot $data -SkipBrowser + -ProgramRoot $prog -DataRoot $data -SkipBrowser -Force if ($LASTEXITCODE -ne 0) { throw "start_netx_app_failed exit=$LASTEXITCODE" } - Write-SvcLog "NetX started; entering watch loop" +} + +function Stop-NetxChildren { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stopPs1 ` + -ProgramRoot $prog -DataRoot $data +} + +try { + Start-NetxChildren + Write-SvcLog "NetX started; health watch host=$($bind.Host) port=$($bind.Port)" while ($true) { if (Test-Path $stopFlag) { Write-SvcLog "stop flag detected" break } - Start-Sleep -Seconds 5 + + if (Test-NetxApiHealthy -HostName $bind.Host -Port $bind.Port -TimeoutSec 3) { + $failStreak = 0 + } else { + $failStreak++ + Write-SvcLog "health fail streak=$failStreak/$HealthFailLimit" + if ($failStreak -ge $HealthFailLimit) { + $restartCount++ + Write-SvcLog "restarting NetX (attempt=$restartCount)" + try { Stop-NetxChildren } catch { Write-SvcLog "stop warning: $($_.Exception.Message)" } + Start-Sleep -Seconds 3 + Start-NetxChildren + $failStreak = 0 + # Give API time to come up before counting failures again. + Start-Sleep -Seconds ([Math]::Max(15, $HealthIntervalSec)) + continue + } + } + Start-Sleep -Seconds $HealthIntervalSec } } catch { Write-SvcLog "ERROR: $($_.Exception.Message)" throw } finally { Write-SvcLog "stopping NetX" - try { - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stopPs1 ` - -ProgramRoot $prog -DataRoot $data - } catch { - Write-SvcLog "stop warning: $($_.Exception.Message)" - } + try { Stop-NetxChildren } catch { Write-SvcLog "stop warning: $($_.Exception.Message)" } Remove-Item -Force $stopFlag -ErrorAction SilentlyContinue Write-SvcLog "service_run exited" } diff --git a/packaging/setup_first_run.ps1 b/packaging/setup_first_run.ps1 index fa8e0f5..6a9e9c3 100644 --- a/packaging/setup_first_run.ps1 +++ b/packaging/setup_first_run.ps1 @@ -20,15 +20,7 @@ Write-Host "==> Program root: $prog" Write-Host "==> Data root: $data" Write-Host "==> Env file: $envPath" -if (-not (Test-Path $data)) { - New-Item -ItemType Directory -Path $data -Force | Out-Null -} -foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) { - $p = Join-Path $data $sub - if (-not (Test-Path $p)) { - New-Item -ItemType Directory -Path $p -Force | Out-Null - } -} +Ensure-NetxDataDirectories -DataRoot $data $existing = Read-DotEnv -Path $envPath if (-not $DbMode) { @@ -58,16 +50,21 @@ $values = @{} $values["NETX_DB_MODE"] = $DbMode $values["NETX_HOST"] = "127.0.0.1" $values["NETX_PORT"] = "8890" -$values["NETX_UI_DIST_DIR"] = "web/dist" +# Absolute UI path when present; else relative for source trees. +$distAbs = Join-Path $prog "web\dist" +if (Test-Path (Join-Path $distAbs "index.html")) { + $values["NETX_UI_DIST_DIR"] = (ConvertTo-NetxFsPath $distAbs) +} else { + $values["NETX_UI_DIST_DIR"] = "web/dist" +} -# Point runtime data dirs into the data root (absolute). -$values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/') -$values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/') +# All runtime data under data root (never under Program Files). +$dataEnv = Get-NetxDataEnvMap -DataRoot $data +foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] } if ($DbMode -eq "bundled") { $pgsql = Join-Path $prog "postgres\pgsql" if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) { - # In-repo layout $alt = Join-Path $PSScriptRoot "postgres\pgsql" if (Test-Path (Join-Path $alt "bin\initdb.exe")) { $pgsql = $alt @@ -94,18 +91,16 @@ if ($DbMode -eq "bundled") { } $pgData = Join-Path $data "pgdata" $values["NETX_BUNDLED_PG_PORT"] = "$BundledPort" - $values["NETX_BUNDLED_PG_DATA_DIR"] = ($pgData -replace '\\', '/') + $values["NETX_BUNDLED_PG_DATA_DIR"] = (ConvertTo-NetxFsPath $pgData) $pwFile = Join-Path $data "pg_netx.pw" Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline $encPw = [uri]::EscapeDataString($BundledPassword) $values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx" - # Initialize cluster if needed $initdb = Join-Path $pgsql "bin\initdb.exe" $pgCtl = Join-Path $pgsql "bin\pg_ctl.exe" $psql = Join-Path $pgsql "bin\psql.exe" - $createdb = Join-Path $pgsql "bin\createdb.exe" - $createuser = Join-Path $pgsql "bin\createuser.exe" + $sqlPw = ConvertTo-NetxSqlLiteral $BundledPassword if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) { Write-Host "==> initdb $pgData" @@ -115,7 +110,6 @@ if ($DbMode -eq "bundled") { if ($LASTEXITCODE -ne 0) { throw "initdb_failed" } } - # Ensure listen / port in postgresql.conf $conf = Join-Path $pgData "postgresql.conf" $hba = Join-Path $pgData "pg_hba.conf" if (Test-Path $conf) { @@ -126,6 +120,8 @@ if ($DbMode -eq "bundled") { $confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort" if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") { $confText += "`nlisten_addresses = '127.0.0.1'`n" + } else { + $confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'" } Set-Content -Path $conf -Value $confText -Encoding utf8 } @@ -137,19 +133,26 @@ if ($DbMode -eq "bundled") { } } - Write-Host "==> Starting bundled PostgreSQL for bootstrap" - & $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start - Start-Sleep -Seconds 2 + $status = & $pgCtl -D $pgData status 2>&1 | Out-String + if ($status -notmatch "server is running") { + if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) { + throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)" + } + Write-Host "==> Starting bundled PostgreSQL for bootstrap" + & $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start + if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" } + Start-Sleep -Seconds 2 + } $env:PGPASSWORD = $BundledPassword try { $role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'" if ($role -notmatch "1") { & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 ` - -c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';" + -c "CREATE ROLE netx LOGIN PASSWORD '$sqlPw';" } else { & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 ` - -c "ALTER ROLE netx WITH LOGIN PASSWORD '$BundledPassword';" + -c "ALTER ROLE netx WITH LOGIN PASSWORD '$sqlPw';" } $db = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='netx'" if ($db -notmatch "1") { diff --git a/packaging/start_netx_app.ps1 b/packaging/start_netx_app.ps1 index 3fe2313..d57781c 100644 --- a/packaging/start_netx_app.ps1 +++ b/packaging/start_netx_app.ps1 @@ -2,7 +2,8 @@ param( [string]$ProgramRoot = "", [string]$DataRoot = "", [switch]$SkipBrowser = $false, - [switch]$InlineSchedulers = $false + [switch]$InlineSchedulers = $false, + [switch]$Force = $false ) $ErrorActionPreference = "Stop" @@ -21,25 +22,39 @@ if (-not (Test-Path (Join-Path $prog "netx_api"))) { throw "netx_api not found under program root: $prog" } +Ensure-NetxDataDirectories -DataRoot $data + $map = Import-NetxEnvFile -Path $envPath +# Force data-root paths even if an older .env missed them. +$dataEnv = Get-NetxDataEnvMap -DataRoot $data +foreach ($k in $dataEnv.Keys) { + Set-Item -Path "Env:$k" -Value $dataEnv[$k] +} + Set-Location $prog $env:PYTHONPATH = $prog -# Keep runtime artifacts in the data root (not under Program Files). -$env:NETX_AUTH_MCP_TOKEN_FILE = Join-Path $data "data\auth\mcp_token" -$env:NETX_SCHEDULER_HEARTBEAT_PATH = Join-Path $data "data\runtime\scheduler_heartbeat.json" -$env:NETX_AUTH_SECRET_FILE = Join-Path $data "data\auth\jwt_secret" - $dist = Join-Path $prog "web\dist" if (Test-Path (Join-Path $dist "index.html")) { $env:NETX_UI_DIST_DIR = $dist } $mode = Get-DbMode -EnvMap $map +$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" } +$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 } + Write-Host "==> Program: $prog" Write-Host "==> Data: $data" Write-Host "==> DB mode: $mode" +if (-not $Force -and (Test-NetxApiHealthy -HostName $hostBind -Port $port)) { + Write-Host "==> NetX already healthy at http://${hostBind}:${port}/ — skip start (use -Force to restart)" -ForegroundColor Green + if (-not $SkipBrowser) { + try { Start-Process "http://${hostBind}:${port}/" } catch {} + } + exit 0 +} + function Get-PgsqlBin { foreach ($b in @( (Join-Path $prog "postgres\pgsql\bin"), @@ -54,13 +69,20 @@ if ($mode -eq "bundled") { $pgBin = Get-PgsqlBin if (-not $pgBin) { throw "bundled_postgres_missing" } $pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) { - $map["NETX_BUNDLED_PG_DATA_DIR"] + $map["NETX_BUNDLED_PG_DATA_DIR"] -replace '/', '\' } else { Join-Path $data "pgdata" } + $pgPort = 15432 + if ($map["NETX_BUNDLED_PG_PORT"]) { + try { $pgPort = [int]$map["NETX_BUNDLED_PG_PORT"] } catch {} + } $pgCtl = Join-Path $pgBin "pg_ctl.exe" $status = & $pgCtl -D $pgData status 2>&1 | Out-String if ($status -notmatch "server is running") { + if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $pgPort)) { + throw "port_in_use: 127.0.0.1:$pgPort (bundled Postgres)" + } Write-Host "==> Starting bundled PostgreSQL" if (-not (Test-Path $pgData)) { New-Item -ItemType Directory -Path $pgData -Force | Out-Null @@ -74,7 +96,6 @@ if ($mode -eq "bundled") { } } -# Ensure venv exists for start_netx.ps1 $venvPy = Join-Path $prog ".venv\Scripts\python.exe" if (-not (Test-Path $venvPy)) { Write-Host "==> Creating .venv (one-time)" @@ -86,8 +107,13 @@ if (-not (Test-Path $venvPy)) { if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" } } -$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" } -$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 } +if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) { + if (Test-NetxApiHealthy -HostName $hostBind -Port $port) { + Write-Host "==> Port $port already serves NetX — skip start" -ForegroundColor Green + exit 0 + } + throw "port_in_use: ${hostBind}:${port} occupied by non-NetX process" +} $startScript = Join-Path $prog "scripts\start_netx.ps1" if (-not (Test-Path $startScript)) { diff --git a/packaging/update_netx.ps1 b/packaging/update_netx.ps1 index 1ddd0ec..c959f66 100644 --- a/packaging/update_netx.ps1 +++ b/packaging/update_netx.ps1 @@ -54,7 +54,35 @@ try { if (Test-Path $envFile) { Copy-Item $envFile (Join-Path $bak ".env") } - Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)" + # Best-effort logical backup when psql is available (bundled or PATH). + $map = Read-DotEnv -Path $envFile + $pgDump = $null + foreach ($c in @( + (Join-Path $prog "postgres\pgsql\bin\pg_dump.exe"), + (Join-Path $PSScriptRoot "postgres\pgsql\bin\pg_dump.exe") + )) { + if (Test-Path $c) { $pgDump = $c; break } + } + if (-not $pgDump) { + $cmd = Get-Command pg_dump -ErrorAction SilentlyContinue + if ($cmd) { $pgDump = $cmd.Source } + } + if ($pgDump -and $map["NETX_DATABASE_URL"] -match 'postgresql\+?[^:]*://([^:]+):([^@]+)@([^:/]+):?(\d+)?/([^?\s]+)') { + $u = $Matches[1]; $p = [uri]::UnescapeDataString($Matches[2]); $h = $Matches[3] + $pt = if ($Matches[4]) { $Matches[4] } else { "5432" } + $dbn = $Matches[5] + $dumpOut = Join-Path $bak "netx.dump" + Write-Host "==> pg_dump -> $dumpOut" + $env:PGPASSWORD = $p + try { + & $pgDump -h $h -p $pt -U $u -d $dbn -Fc -f $dumpOut + } catch { + Write-Host "[WARN] pg_dump failed: $($_.Exception.Message)" -ForegroundColor Yellow + } finally { + Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue + } + } + Write-Host "==> Backup: $bak" } Write-Host "==> Stopping services" diff --git a/pyproject.toml b/pyproject.toml index a2836f0..b891192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" -version = "0.3.0" +version = "0.4.0" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" readme = "README.md" requires-python = ">=3.11"