From 6f6413e8b26e40334860b072d8b77d8a2b846482 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:20:49 +0300 Subject: [PATCH 01/35] ci(installer): probe the EDB 17 installer on a runner --- .github/workflows/ci.yml | 197 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22f3f9c..cbd1f8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,203 @@ jobs: # the runner exits with the last native exit code, which the refusal left at 1 exit 0 + # Throwaway probe for task 1 of docs/plans/20261019-windows-installer.md. + edb-probe: + runs-on: windows-latest + timeout-minutes: 75 + steps: + - name: Probe the EDB PostgreSQL 17 installer + run: | + $ErrorActionPreference = 'Continue' + $url = 'https://get.enterprisedb.com/postgresql/postgresql-17.11-5-windows-x64.exe' + $wantHash = '612C7F5400A003AAFFA0372CB273EED52278F93C680AB911EB9CB2229E329072' + $work = "$env:RUNNER_TEMP\edb" + New-Item -ItemType Directory -Path $work | Out-Null + $exe = "$work\postgresql-17.11-5-windows-x64.exe" + $root = 'C:\Program Files\PostgreSQL\17' + $data = "$root\data" + + function Section([string] $title) { Write-Host "`n===== $title" } + function Dump-Registry { + foreach ($key in 'HKLM:\SOFTWARE\PostgreSQL', 'HKLM:\SOFTWARE\WOW6432Node\PostgreSQL') { + if (-not (Test-Path $key)) { Write-Host "$key absent"; continue } + Get-ChildItem $key -Recurse | ForEach-Object { + $k = $_ + Write-Host $k.Name + foreach ($n in $k.GetValueNames()) { + Write-Host (" {0} = {1} ({2})" -f $n, $k.GetValue($n), $k.GetValueKind($n)) + } + } + } + Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | + Where-Object { $_.PSChildName -like '*PostgreSQL*' } | + ForEach-Object { Write-Host "uninstall entry: $($_.Name)" } + } + function Dump-Services { + Get-CimInstance Win32_Service -Filter "Name LIKE 'postgresql%'" | + Select-Object Name, State, StartMode, StartName, PathName | Format-List | Out-String | Write-Host + } + function Run-Exe([string] $file, [string] $arguments, [int] $timeoutSeconds, [string] $stdout) { + $start = Get-Date + $p = if ($stdout) { + Start-Process -FilePath $file -ArgumentList $arguments -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdout -RedirectStandardError "$stdout.err" + } else { + Start-Process -FilePath $file -ArgumentList $arguments -PassThru + } + $null = $p.Handle + if (-not $p.WaitForExit($timeoutSeconds * 1000)) { + Stop-Process -Id $p.Id -Force + Write-Host "TIMEOUT after $timeoutSeconds s: $file $arguments" + return 'timeout' + } + Write-Host ("exit {0} after {1:n0} s: {2}" -f $p.ExitCode, ((Get-Date) - $start).TotalSeconds, $arguments) + return $p.ExitCode + } + function Find-Token([string] $token) { + $roots = @($env:TEMP, 'C:\Windows\Temp', $work, "$root\installer", "$data\log") + $files = @("$data\postgresql.conf", "$data\postgresql.auto.conf", "$data\pg_hba.conf") + foreach ($r in $roots) { + if (Test-Path $r) { + $files += (Get-ChildItem $r -Recurse -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -ne '.exe' }).FullName + } + } + $existing = @($files | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) + $hits = Select-String -LiteralPath $existing -SimpleMatch -Pattern $token -ErrorAction SilentlyContinue + $control = Select-String -LiteralPath $existing -SimpleMatch -Pattern 'initcluster' -List -ErrorAction SilentlyContinue + Write-Host "token hits: $(@($hits).Count) over $($existing.Count) files; files naming initcluster (control): $(@($control).Count)" + $control | ForEach-Object { Write-Host " control file: $($_.Path)" } + $hits | ForEach-Object { Write-Host "TOKEN HIT $($_.Path):$($_.LineNumber)" } + } + function Start-CommandLineWatch([string] $token) { + Start-Job -ArgumentList $token -ScriptBlock { + param($t) + $seen = @{} + while ($true) { + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and $_.CommandLine.Contains($t) } | + ForEach-Object { + $line = "$($_.Name): $($_.CommandLine.Replace($t, ''))" + if (-not $seen[$line]) { $seen[$line] = 1; $line } + } + Start-Sleep -Milliseconds 200 + } + } + } + function Install-Edb([string] $token, [string] $label) { + Section "install $label" + $optionFile = "$work\options-$label.txt" + @( + "superpassword=$token", + 'serverport=5432', + 'servicename=postgresql-x64-17', + 'locale=C', + "datadir=$data" + ) | Set-Content -Path $optionFile -Encoding ascii + $watch = Start-CommandLineWatch $token + Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$optionFile`" --disable-components pgAdmin,stackbuilder" 1500 | Out-Null + if (-not (Get-Service postgresql* -ErrorAction SilentlyContinue)) { + Write-Host 'no service after the install: retrying without --disable-components' + Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$optionFile`"" 1500 | Out-Null + } + Stop-Job $watch + $pending = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager').PendingFileRenameOperations + Write-Host "PendingFileRenameOperations entries: $(@($pending).Count)" + Write-Host "command lines carrying the password during the install:" + Receive-Job $watch | ForEach-Object { Write-Host " $_" } + Write-Host "option file still exists: $(Test-Path $optionFile)" + Dump-Services + Dump-Registry + Write-Host "components: $((Get-ChildItem $root -Name) -join ', ')" + Get-ChildItem $env:TEMP, 'C:\Windows\Temp' -Recurse -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '*postgres*' -or $_.Name -like 'bitrock*' -or $_.Name -like '*.log' } | + ForEach-Object { Write-Host "temp file: $($_.FullName) $($_.Length)" } + Find-Token $token + $env:PGPASSWORD = $token + $psql = "$root\bin\psql.exe" + & $psql -h 127.0.0.1 -U postgres -d postgres -c 'SELECT version()' -c 'SHOW server_encoding' ` + -c 'SHOW listen_addresses' -c 'SHOW port' -c 'SHOW data_directory' -c 'SHOW lc_messages' ` + -c 'SELECT datname, datcollate, datctype, datlocprovider, pg_encoding_to_char(encoding) FROM pg_database' ` + -c 'SELECT rolname, rolsuper FROM pg_roles WHERE rolname !~ ''^pg_''' 2>&1 | + ForEach-Object { Write-Host " $_" } + Write-Host "psql exit: $LASTEXITCODE" + Write-Host "pg_hba.conf rules:" + Get-Content "$data\pg_hba.conf" | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { Write-Host " $_" } + } + function Remove-Edb([string] $label) { + Section "remove $label" + Write-Host "files in ${root}: $((Get-ChildItem $root -Name -ErrorAction SilentlyContinue) -join ', ')" + Run-Exe "$root\uninstall-postgresql.exe" '--mode unattended --unattendedmodeui none' 900 | Out-Null + Write-Host 'after the uninstaller:' + Dump-Services + Dump-Registry + foreach ($p in $data, $root, 'C:\PostgreSQL\17\data') { + if (Test-Path $p) { Write-Host "left: $p ($((Get-ChildItem $p -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object).Count) entries)" } + } + Remove-Item -Recurse -Force 'C:\PostgreSQL', 'C:\Program Files\PostgreSQL', 'HKLM:\SOFTWARE\PostgreSQL' -ErrorAction SilentlyContinue + Write-Host "after cleanup: services $(@(Get-Service postgresql* -ErrorAction SilentlyContinue).Count), registry $(Test-Path HKLM:\SOFTWARE\PostgreSQL), program files $(Test-Path 'C:\Program Files\PostgreSQL')" + } + + Section 'runner state' + [System.Environment]::OSVersion.VersionString + Write-Host "machine PGUSER=$([Environment]::GetEnvironmentVariable('PGUSER', 'Machine')) PGPASSWORD set: $([bool][Environment]::GetEnvironmentVariable('PGPASSWORD', 'Machine'))" + Write-Host "ANSI code page: $((Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage).ACP)" + Dump-Services + Dump-Registry + + Section 'download' + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing + $hash = (Get-FileHash $exe -Algorithm SHA256).Hash + Write-Host "sha256 $hash, matches the pin: $($hash -eq $wantHash), size $((Get-Item $exe).Length)" + $sig = Get-AuthenticodeSignature $exe + Write-Host "signature $($sig.Status): $($sig.SignerCertificate.Subject)" + + Section '--help' + Run-Exe $exe '--help' 120 "$work\help.txt" | Out-Null + Get-Content "$work\help.txt", "$work\help.txt.err" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } + Section '--version' + Run-Exe $exe '--version' 120 "$work\version.txt" | Out-Null + Get-Content "$work\version.txt", "$work\version.txt.err" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } + + Remove-Edb 'the runner install' + + $plain = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) + Install-Edb $plain 'plain' + + Section 'initcluster.ps1' + $n = 0 + Get-Content "$root\installer\server\initcluster.ps1" | ForEach-Object { $n++; Write-Host ("{0,4}: {1}" -f $n, $_) } + Section 'install-postgresql.log, initcluster lines' + Get-ChildItem $env:TEMP -Recurse -Filter 'install-postgresql.log' -ErrorAction SilentlyContinue | + Select-Object -First 1 | Get-Content | + Select-String -Pattern 'initcluster|Executing|exit code|vcredist|reboot|runtime|Password' | + ForEach-Object { Write-Host " $($_.Line.Replace($plain, ''))" } + Section 'bitrock_installer log, head' + Get-ChildItem $env:TEMP -Recurse -Filter 'bitrock_installer*.log' -ErrorAction SilentlyContinue | + ForEach-Object { + Write-Host " file: $($_.FullName)" + Get-Content $_.FullName -TotalCount 80 | ForEach-Object { Write-Host " $($_.Replace($plain, ''))" } + } + + Remove-Edb 'the plain install' + + $special = 'Q"w e%PATH%^&|<>''`$x!;z' + Install-Edb $special 'special' + Remove-Edb 'the special install' + + Section 'unknown option' + Run-Exe $exe '--mode unattended --unattendedmodeui none --no-such-option 1' 300 | Out-Null + Dump-Services + Section 'missing option file' + Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$work\absent.txt`"" 300 | Out-Null + Dump-Services + Section 'no superpassword' + Run-Exe $exe '--mode unattended --unattendedmodeui none --disable-components pgAdmin,stackbuilder' 1500 | Out-Null + Dump-Services + exit 0 + linux: runs-on: ubuntu-latest env: From 0b7eebdb5c2fed7a3cc7626eb9af197842fef4fd Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:31:28 +0300 Subject: [PATCH 02/35] ci(installer): wait for the EDB uninstaller --- .github/workflows/ci.yml | 86 +++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbd1f8e..d6fe63c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,14 +192,10 @@ jobs: function Install-Edb([string] $token, [string] $label) { Section "install $label" $optionFile = "$work\options-$label.txt" - @( - "superpassword=$token", - 'serverport=5432', - 'servicename=postgresql-x64-17', - 'locale=C', - "datadir=$data" - ) | Set-Content -Path $optionFile -Encoding ascii - $watch = Start-CommandLineWatch $token + $options = @('serverport=5432', 'servicename=postgresql-x64-17', 'locale=C', "datadir=$data") + if ($token) { $options = @("superpassword=$token") + $options } + $options | Set-Content -Path $optionFile -Encoding ascii + $watch = Start-CommandLineWatch $(if ($token) { $token } else { 'no-token-to-watch' }) Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$optionFile`" --disable-components pgAdmin,stackbuilder" 1500 | Out-Null if (-not (Get-Service postgresql* -ErrorAction SilentlyContinue)) { Write-Host 'no service after the install: retrying without --disable-components' @@ -217,10 +213,14 @@ jobs: Get-ChildItem $env:TEMP, 'C:\Windows\Temp' -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '*postgres*' -or $_.Name -like 'bitrock*' -or $_.Name -like '*.log' } | ForEach-Object { Write-Host "temp file: $($_.FullName) $($_.Length)" } - Find-Token $token + if ($token) { Find-Token $token } + Get-ChildItem $env:TEMP -Filter 'install-postgresql.log' -ErrorAction SilentlyContinue | Get-Content | + Select-String -Pattern 'initcluster|initdb|error|password|exit code|Die\(' | + Select-Object -Last 30 | + ForEach-Object { Write-Host " install log: $(if ($token) { $_.Line.Replace($token, '') } else { $_.Line })" } $env:PGPASSWORD = $token $psql = "$root\bin\psql.exe" - & $psql -h 127.0.0.1 -U postgres -d postgres -c 'SELECT version()' -c 'SHOW server_encoding' ` + & $psql -w -h 127.0.0.1 -U postgres -d postgres -c 'SELECT version()' -c 'SHOW server_encoding' ` -c 'SHOW listen_addresses' -c 'SHOW port' -c 'SHOW data_directory' -c 'SHOW lc_messages' ` -c 'SELECT datname, datcollate, datctype, datlocprovider, pg_encoding_to_char(encoding) FROM pg_database' ` -c 'SELECT rolname, rolsuper FROM pg_roles WHERE rolname !~ ''^pg_''' 2>&1 | @@ -229,16 +229,47 @@ jobs: Write-Host "pg_hba.conf rules:" Get-Content "$data\pg_hba.conf" | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { Write-Host " $_" } } + function Wait-Uninstaller([int[]] $before) { + $start = Get-Date + $deadline = $start.AddMinutes(10) + $seen = @{} + $quiet = 0 + while ((Get-Date) -lt $deadline -and $quiet -lt 5) { + $busy = @(Get-CimInstance Win32_Process | Where-Object { + $before -notcontains $_.ProcessId -and ( + $_.Name -like '*uninstall*' -or $_.Name -like '*.tmp' -or + ($_.ExecutablePath -and ($_.ExecutablePath -like '*\Temp\*' -or $_.ExecutablePath -like '*\PostgreSQL\*'))) + }) + foreach ($p in $busy) { + $k = "$($p.ProcessId) $($p.Name) $($p.ExecutablePath)" + if (-not $seen[$k]) { $seen[$k] = 1; Write-Host " uninstaller process: $k" } + } + if ($busy.Count -eq 0) { $quiet++ } else { $quiet = 0 } + Start-Sleep -Seconds 1 + } + Write-Host ("uninstaller processes gone after {0:n0} s" -f ((Get-Date) - $start).TotalSeconds) + } function Remove-Edb([string] $label) { Section "remove $label" Write-Host "files in ${root}: $((Get-ChildItem $root -Name -ErrorAction SilentlyContinue) -join ', ')" + $before = @(Get-Process | ForEach-Object Id) Run-Exe "$root\uninstall-postgresql.exe" '--mode unattended --unattendedmodeui none' 900 | Out-Null - Write-Host 'after the uninstaller:' + Write-Host 'right after the uninstaller exited:' + Dump-Services + Wait-Uninstaller $before + Write-Host 'after its processes ended:' Dump-Services Dump-Registry foreach ($p in $data, $root, 'C:\PostgreSQL\17\data') { if (Test-Path $p) { Write-Host "left: $p ($((Get-ChildItem $p -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object).Count) entries)" } } + Get-ChildItem $env:TEMP -Filter 'uninstall-postgresql.log' -ErrorAction SilentlyContinue | + Get-Content -Tail 15 | ForEach-Object { Write-Host " uninstall log: $_" } + foreach ($s in @(Get-Service postgresql* -ErrorAction SilentlyContinue)) { + Write-Host "deleting leftover service $($s.Name)" + Stop-Service $s.Name -Force -ErrorAction SilentlyContinue + sc.exe delete $s.Name | Out-Null + } Remove-Item -Recurse -Force 'C:\PostgreSQL', 'C:\Program Files\PostgreSQL', 'HKLM:\SOFTWARE\PostgreSQL' -ErrorAction SilentlyContinue Write-Host "after cleanup: services $(@(Get-Service postgresql* -ErrorAction SilentlyContinue).Count), registry $(Test-Path HKLM:\SOFTWARE\PostgreSQL), program files $(Test-Path 'C:\Program Files\PostgreSQL')" } @@ -258,48 +289,21 @@ jobs: $sig = Get-AuthenticodeSignature $exe Write-Host "signature $($sig.Status): $($sig.SignerCertificate.Subject)" - Section '--help' - Run-Exe $exe '--help' 120 "$work\help.txt" | Out-Null - Get-Content "$work\help.txt", "$work\help.txt.err" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } - Section '--version' - Run-Exe $exe '--version' 120 "$work\version.txt" | Out-Null - Get-Content "$work\version.txt", "$work\version.txt.err" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } - Remove-Edb 'the runner install' - $plain = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) - Install-Edb $plain 'plain' - - Section 'initcluster.ps1' - $n = 0 - Get-Content "$root\installer\server\initcluster.ps1" | ForEach-Object { $n++; Write-Host ("{0,4}: {1}" -f $n, $_) } - Section 'install-postgresql.log, initcluster lines' - Get-ChildItem $env:TEMP -Recurse -Filter 'install-postgresql.log' -ErrorAction SilentlyContinue | - Select-Object -First 1 | Get-Content | - Select-String -Pattern 'initcluster|Executing|exit code|vcredist|reboot|runtime|Password' | - ForEach-Object { Write-Host " $($_.Line.Replace($plain, ''))" } - Section 'bitrock_installer log, head' - Get-ChildItem $env:TEMP -Recurse -Filter 'bitrock_installer*.log' -ErrorAction SilentlyContinue | - ForEach-Object { - Write-Host " file: $($_.FullName)" - Get-Content $_.FullName -TotalCount 80 | ForEach-Object { Write-Host " $($_.Replace($plain, ''))" } - } - - Remove-Edb 'the plain install' - $special = 'Q"w e%PATH%^&|<>''`$x!;z' Install-Edb $special 'special' Remove-Edb 'the special install' + Install-Edb '' 'without superpassword' + Remove-Edb 'the install without superpassword' + Section 'unknown option' Run-Exe $exe '--mode unattended --unattendedmodeui none --no-such-option 1' 300 | Out-Null Dump-Services Section 'missing option file' Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$work\absent.txt`"" 300 | Out-Null Dump-Services - Section 'no superpassword' - Run-Exe $exe '--mode unattended --unattendedmodeui none --disable-components pgAdmin,stackbuilder' 1500 | Out-Null - Dump-Services exit 0 linux: From e837805c6180aa65e4187c6db74c7976272166ac Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:39:26 +0300 Subject: [PATCH 03/35] ci(installer): probe EDB password characters --- .github/workflows/ci.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6fe63c..06bf438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,19 +291,18 @@ jobs: Remove-Edb 'the runner install' - $special = 'Q"w e%PATH%^&|<>''`$x!;z' - Install-Edb $special 'special' - Remove-Edb 'the special install' - - Install-Edb '' 'without superpassword' - Remove-Edb 'the install without superpassword' - - Section 'unknown option' - Run-Exe $exe '--mode unattended --unattendedmodeui none --no-such-option 1' 300 | Out-Null - Dump-Services - Section 'missing option file' - Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$work\absent.txt`"" 300 | Out-Null - Dump-Services + $groups = [ordered]@{ + 'shell' = 'Aa1 ''`$!;^&|<>()*?z' + 'percent' = 'Aa1%PATH%z' + 'quote' = 'Aa1"z' + 'punctuation' = 'Aa1-_.,:@#+=~/[]{}\z' + 'trailing-backslash' = 'Aa1z\' + } + foreach ($name in $groups.Keys) { + Install-Edb $groups[$name] $name + Write-Host "RESULT ${name}: service $(@(Get-Service postgresql* -ErrorAction SilentlyContinue | Where-Object Status -eq 'Running').Count) running, psql exit $LASTEXITCODE" + Remove-Edb $name + } exit 0 linux: From 46494396e010050b1e647a7957b709b437a6fdbf Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:45 +0300 Subject: [PATCH 04/35] ci(installer): probe the allowed password set --- .github/workflows/ci.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06bf438..077c595 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,11 +292,8 @@ jobs: Remove-Edb 'the runner install' $groups = [ordered]@{ - 'shell' = 'Aa1 ''`$!;^&|<>()*?z' - 'percent' = 'Aa1%PATH%z' - 'quote' = 'Aa1"z' - 'punctuation' = 'Aa1-_.,:@#+=~/[]{}\z' - 'trailing-backslash' = 'Aa1z\' + 'all-but-quote' = 'Aa1 !#$%&''()*+,-./:;<=>?@[\]^_`{|}~z' + 'quote-ampersand' = 'Aa1"z&y' } foreach ($name in $groups.Keys) { Install-Edb $groups[$name] $name From 8110d42ae759bceea6499a7c9695ae8e09d4493f Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:01:15 +0300 Subject: [PATCH 05/35] ci(installer): remove the EDB installer probe --- .github/workflows/ci.yml | 197 --------------------------------------- 1 file changed, 197 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 077c595..22f3f9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,203 +105,6 @@ jobs: # the runner exits with the last native exit code, which the refusal left at 1 exit 0 - # Throwaway probe for task 1 of docs/plans/20261019-windows-installer.md. - edb-probe: - runs-on: windows-latest - timeout-minutes: 75 - steps: - - name: Probe the EDB PostgreSQL 17 installer - run: | - $ErrorActionPreference = 'Continue' - $url = 'https://get.enterprisedb.com/postgresql/postgresql-17.11-5-windows-x64.exe' - $wantHash = '612C7F5400A003AAFFA0372CB273EED52278F93C680AB911EB9CB2229E329072' - $work = "$env:RUNNER_TEMP\edb" - New-Item -ItemType Directory -Path $work | Out-Null - $exe = "$work\postgresql-17.11-5-windows-x64.exe" - $root = 'C:\Program Files\PostgreSQL\17' - $data = "$root\data" - - function Section([string] $title) { Write-Host "`n===== $title" } - function Dump-Registry { - foreach ($key in 'HKLM:\SOFTWARE\PostgreSQL', 'HKLM:\SOFTWARE\WOW6432Node\PostgreSQL') { - if (-not (Test-Path $key)) { Write-Host "$key absent"; continue } - Get-ChildItem $key -Recurse | ForEach-Object { - $k = $_ - Write-Host $k.Name - foreach ($n in $k.GetValueNames()) { - Write-Host (" {0} = {1} ({2})" -f $n, $k.GetValue($n), $k.GetValueKind($n)) - } - } - } - Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | - Where-Object { $_.PSChildName -like '*PostgreSQL*' } | - ForEach-Object { Write-Host "uninstall entry: $($_.Name)" } - } - function Dump-Services { - Get-CimInstance Win32_Service -Filter "Name LIKE 'postgresql%'" | - Select-Object Name, State, StartMode, StartName, PathName | Format-List | Out-String | Write-Host - } - function Run-Exe([string] $file, [string] $arguments, [int] $timeoutSeconds, [string] $stdout) { - $start = Get-Date - $p = if ($stdout) { - Start-Process -FilePath $file -ArgumentList $arguments -PassThru -NoNewWindow ` - -RedirectStandardOutput $stdout -RedirectStandardError "$stdout.err" - } else { - Start-Process -FilePath $file -ArgumentList $arguments -PassThru - } - $null = $p.Handle - if (-not $p.WaitForExit($timeoutSeconds * 1000)) { - Stop-Process -Id $p.Id -Force - Write-Host "TIMEOUT after $timeoutSeconds s: $file $arguments" - return 'timeout' - } - Write-Host ("exit {0} after {1:n0} s: {2}" -f $p.ExitCode, ((Get-Date) - $start).TotalSeconds, $arguments) - return $p.ExitCode - } - function Find-Token([string] $token) { - $roots = @($env:TEMP, 'C:\Windows\Temp', $work, "$root\installer", "$data\log") - $files = @("$data\postgresql.conf", "$data\postgresql.auto.conf", "$data\pg_hba.conf") - foreach ($r in $roots) { - if (Test-Path $r) { - $files += (Get-ChildItem $r -Recurse -File -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -ne '.exe' }).FullName - } - } - $existing = @($files | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) - $hits = Select-String -LiteralPath $existing -SimpleMatch -Pattern $token -ErrorAction SilentlyContinue - $control = Select-String -LiteralPath $existing -SimpleMatch -Pattern 'initcluster' -List -ErrorAction SilentlyContinue - Write-Host "token hits: $(@($hits).Count) over $($existing.Count) files; files naming initcluster (control): $(@($control).Count)" - $control | ForEach-Object { Write-Host " control file: $($_.Path)" } - $hits | ForEach-Object { Write-Host "TOKEN HIT $($_.Path):$($_.LineNumber)" } - } - function Start-CommandLineWatch([string] $token) { - Start-Job -ArgumentList $token -ScriptBlock { - param($t) - $seen = @{} - while ($true) { - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { $_.CommandLine -and $_.CommandLine.Contains($t) } | - ForEach-Object { - $line = "$($_.Name): $($_.CommandLine.Replace($t, ''))" - if (-not $seen[$line]) { $seen[$line] = 1; $line } - } - Start-Sleep -Milliseconds 200 - } - } - } - function Install-Edb([string] $token, [string] $label) { - Section "install $label" - $optionFile = "$work\options-$label.txt" - $options = @('serverport=5432', 'servicename=postgresql-x64-17', 'locale=C', "datadir=$data") - if ($token) { $options = @("superpassword=$token") + $options } - $options | Set-Content -Path $optionFile -Encoding ascii - $watch = Start-CommandLineWatch $(if ($token) { $token } else { 'no-token-to-watch' }) - Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$optionFile`" --disable-components pgAdmin,stackbuilder" 1500 | Out-Null - if (-not (Get-Service postgresql* -ErrorAction SilentlyContinue)) { - Write-Host 'no service after the install: retrying without --disable-components' - Run-Exe $exe "--mode unattended --unattendedmodeui none --optionfile `"$optionFile`"" 1500 | Out-Null - } - Stop-Job $watch - $pending = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager').PendingFileRenameOperations - Write-Host "PendingFileRenameOperations entries: $(@($pending).Count)" - Write-Host "command lines carrying the password during the install:" - Receive-Job $watch | ForEach-Object { Write-Host " $_" } - Write-Host "option file still exists: $(Test-Path $optionFile)" - Dump-Services - Dump-Registry - Write-Host "components: $((Get-ChildItem $root -Name) -join ', ')" - Get-ChildItem $env:TEMP, 'C:\Windows\Temp' -Recurse -File -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like '*postgres*' -or $_.Name -like 'bitrock*' -or $_.Name -like '*.log' } | - ForEach-Object { Write-Host "temp file: $($_.FullName) $($_.Length)" } - if ($token) { Find-Token $token } - Get-ChildItem $env:TEMP -Filter 'install-postgresql.log' -ErrorAction SilentlyContinue | Get-Content | - Select-String -Pattern 'initcluster|initdb|error|password|exit code|Die\(' | - Select-Object -Last 30 | - ForEach-Object { Write-Host " install log: $(if ($token) { $_.Line.Replace($token, '') } else { $_.Line })" } - $env:PGPASSWORD = $token - $psql = "$root\bin\psql.exe" - & $psql -w -h 127.0.0.1 -U postgres -d postgres -c 'SELECT version()' -c 'SHOW server_encoding' ` - -c 'SHOW listen_addresses' -c 'SHOW port' -c 'SHOW data_directory' -c 'SHOW lc_messages' ` - -c 'SELECT datname, datcollate, datctype, datlocprovider, pg_encoding_to_char(encoding) FROM pg_database' ` - -c 'SELECT rolname, rolsuper FROM pg_roles WHERE rolname !~ ''^pg_''' 2>&1 | - ForEach-Object { Write-Host " $_" } - Write-Host "psql exit: $LASTEXITCODE" - Write-Host "pg_hba.conf rules:" - Get-Content "$data\pg_hba.conf" | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { Write-Host " $_" } - } - function Wait-Uninstaller([int[]] $before) { - $start = Get-Date - $deadline = $start.AddMinutes(10) - $seen = @{} - $quiet = 0 - while ((Get-Date) -lt $deadline -and $quiet -lt 5) { - $busy = @(Get-CimInstance Win32_Process | Where-Object { - $before -notcontains $_.ProcessId -and ( - $_.Name -like '*uninstall*' -or $_.Name -like '*.tmp' -or - ($_.ExecutablePath -and ($_.ExecutablePath -like '*\Temp\*' -or $_.ExecutablePath -like '*\PostgreSQL\*'))) - }) - foreach ($p in $busy) { - $k = "$($p.ProcessId) $($p.Name) $($p.ExecutablePath)" - if (-not $seen[$k]) { $seen[$k] = 1; Write-Host " uninstaller process: $k" } - } - if ($busy.Count -eq 0) { $quiet++ } else { $quiet = 0 } - Start-Sleep -Seconds 1 - } - Write-Host ("uninstaller processes gone after {0:n0} s" -f ((Get-Date) - $start).TotalSeconds) - } - function Remove-Edb([string] $label) { - Section "remove $label" - Write-Host "files in ${root}: $((Get-ChildItem $root -Name -ErrorAction SilentlyContinue) -join ', ')" - $before = @(Get-Process | ForEach-Object Id) - Run-Exe "$root\uninstall-postgresql.exe" '--mode unattended --unattendedmodeui none' 900 | Out-Null - Write-Host 'right after the uninstaller exited:' - Dump-Services - Wait-Uninstaller $before - Write-Host 'after its processes ended:' - Dump-Services - Dump-Registry - foreach ($p in $data, $root, 'C:\PostgreSQL\17\data') { - if (Test-Path $p) { Write-Host "left: $p ($((Get-ChildItem $p -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object).Count) entries)" } - } - Get-ChildItem $env:TEMP -Filter 'uninstall-postgresql.log' -ErrorAction SilentlyContinue | - Get-Content -Tail 15 | ForEach-Object { Write-Host " uninstall log: $_" } - foreach ($s in @(Get-Service postgresql* -ErrorAction SilentlyContinue)) { - Write-Host "deleting leftover service $($s.Name)" - Stop-Service $s.Name -Force -ErrorAction SilentlyContinue - sc.exe delete $s.Name | Out-Null - } - Remove-Item -Recurse -Force 'C:\PostgreSQL', 'C:\Program Files\PostgreSQL', 'HKLM:\SOFTWARE\PostgreSQL' -ErrorAction SilentlyContinue - Write-Host "after cleanup: services $(@(Get-Service postgresql* -ErrorAction SilentlyContinue).Count), registry $(Test-Path HKLM:\SOFTWARE\PostgreSQL), program files $(Test-Path 'C:\Program Files\PostgreSQL')" - } - - Section 'runner state' - [System.Environment]::OSVersion.VersionString - Write-Host "machine PGUSER=$([Environment]::GetEnvironmentVariable('PGUSER', 'Machine')) PGPASSWORD set: $([bool][Environment]::GetEnvironmentVariable('PGPASSWORD', 'Machine'))" - Write-Host "ANSI code page: $((Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage).ACP)" - Dump-Services - Dump-Registry - - Section 'download' - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing - $hash = (Get-FileHash $exe -Algorithm SHA256).Hash - Write-Host "sha256 $hash, matches the pin: $($hash -eq $wantHash), size $((Get-Item $exe).Length)" - $sig = Get-AuthenticodeSignature $exe - Write-Host "signature $($sig.Status): $($sig.SignerCertificate.Subject)" - - Remove-Edb 'the runner install' - - $groups = [ordered]@{ - 'all-but-quote' = 'Aa1 !#$%&''()*+,-./:;<=>?@[\]^_`{|}~z' - 'quote-ampersand' = 'Aa1"z&y' - } - foreach ($name in $groups.Keys) { - Install-Edb $groups[$name] $name - Write-Host "RESULT ${name}: service $(@(Get-Service postgresql* -ErrorAction SilentlyContinue | Where-Object Status -eq 'Running').Count) running, psql exit $LASTEXITCODE" - Remove-Edb $name - } - exit 0 - linux: runs-on: ubuntu-latest env: From 085cf8f3d585224b0d072d3fabb3ba1d83c1a084 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:03:41 +0300 Subject: [PATCH 06/35] docs(installer): record the EDB 17 installer facts --- docs/architecture/installer.md | 180 +++++++++++ docs/plans/20261019-windows-installer.md | 389 +++++++++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 docs/architecture/installer.md create mode 100644 docs/plans/20261019-windows-installer.md diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md new file mode 100644 index 0000000..22e7c12 --- /dev/null +++ b/docs/architecture/installer.md @@ -0,0 +1,180 @@ +# Installer + +The facts about the EDB PostgreSQL 17 installer and Inno Setup that the Windows setup rests on. +Each one is a dated measurement. The EDB runs happened on GitHub's `windows-latest` runner +(Windows Server 2025, build 26100, ANSI code page 1252) on 2026-10-19, in a throwaway CI job: the +probe script is in commits `6f6413e`, `0b7eebd`, `e837805` and `4649439`, its logs in CI runs +39268177188, 39269744425, 39270947396 and 39273096839. Every install ran on a machine whose +earlier PostgreSQL had been uninstalled, its uninstaller process waited for, and its service, +`C:\PostgreSQL`, `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL` deleted. + +## The pinned payload + +| Field | Value | +| --- | --- | +| Version | 17.11-5 (PostgreSQL 17.11, EDB build 5) | +| URL | `https://get.enterprisedb.com/postgresql/postgresql-17.11-5-windows-x64.exe` | +| SHA256 | `612c7f5400a003aaffa0372cb273eed52278f93c680ab911eb9cb2229e329072` | +| Size | 413001200 bytes | +| Signature | Authenticode, Valid, `CN=EnterpriseDB Corporation, O=EnterpriseDB Corporation, L=Wilmington, S=Delaware, C=US` | + +On 2026-10-19 the EDB downloads page linked this build for Windows x86-64 (`fileid=1260614`). +`get.enterprisedb.com` also serves builds 17.11-1 to 17.11-4. The installer is built with +InstallBuilder: it writes `installbuilder_installer_.log` next to `install-postgresql.log` +in `%TEMP%`. + +## Command line + +`--help` prints the options to stdout and exits 0, after 62 s. `--version` prints nothing to +stdout and exits 0, after 61 s. The options the setup uses, with their printed defaults: + +| Option | Default | Note | +| --- | --- | --- | +| `--mode` | `qt` | `qt`, `win32`, `unattended` | +| `--unattendedmodeui` | `minimal` | `none`, `minimal`, `minimalWithDialogs` | +| `--optionfile` | empty | installation option file | +| `--disable-components` | empty | from `server`, `pgAdmin`, `stackbuilder`, `commandlinetools` | +| `--superpassword` | empty | | +| `--serverport` | `5432` | | +| `--servicename` | empty | | +| `--locale` | `DEFAULT` | a long name or a BCP 47 code; `DEFAULT` passes no `--locale` to `initdb` | +| `--datadir` | `C:\Program Files\PostgreSQL\17\data` | | +| `--prefix` | `C:\Program Files\PostgreSQL\17` | | +| `--superaccount` | `postgres` | | +| `--install_runtimes` | `1` | installs the bundled Visual C++ runtimes first | + +The option file holds `key=value` lines, keys are option names without `--`. A file with +`superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and +`datadir=C:\Program Files\PostgreSQL\17\data`, passed as `--mode unattended --unattendedmodeui +none --optionfile --disable-components pgAdmin,stackbuilder`, produced exactly that port, +service, locale and data directory. The installer leaves the option file in place. + +## Exit codes + +| Run | Exit | Duration | What it leaves | +| --- | --- | --- | --- | +| Success | 0 | 38 to 51 s | the install below | +| Unknown option (`--no-such-option 1`) | 1 | 11 to 12 s | nothing | +| `--optionfile` naming a missing file | 1 | 12 to 16 s | nothing | +| No `superpassword` at all | 0 | 49 s | a running cluster whose superuser password the caller does not know | +| `initcluster.ps1` fails (see [password characters](#password-characters)) | 1 | 28 to 61 s | a partial install, see [a failed install](#a-failed-install) | +| Reboot required | not reproduced | | | + +The reboot case did not occur. The runner already has Visual C++ runtime 14.51.36247, newer than +the bundled 14.44.35211; the log records both versions and no `vcredist` run. One +`PendingFileRenameOperations` entry existed after every install; it was not counted before, so +nothing attributes it to EDB. + +## What a successful install leaves + +- `HKLM\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17`: `Base Directory` + (`C:\Program Files\PostgreSQL\17`), `Version` (`17.11-5`), `Shortcuts`, `Data Directory`, + `Service ID` (`postgresql-x64-17`), `Service Account` (`NT AUTHORITY\NetworkService`), + `Super User` (`postgres`), `Branding`, `CLT_Version`. All `REG_SZ`. With pgAdmin and Stack + Builder installed it also has `pgAdmin_Version` and `SB_Version`. +- `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-17`: `Data Directory`, `Port` (`REG_DWORD` + 5432), `Locale` (`C`, the value passed), `Database Superuser`, `Service Account`, + `Product Code`, `Display Name`. +- `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\PostgreSQL 17`. Nothing under + `WOW6432Node`. +- Service `postgresql-x64-17`: Running, start type Automatic, account + `NT AUTHORITY\NetworkService`, `PathName` `"C:\Program Files\PostgreSQL\17\bin\pg_ctl.exe" + runservice -N "postgresql-x64-17" -D "C:\Program Files\PostgreSQL\17\data" -w`. That `-D` is + the argument `site --restart-service` compares with `SHOW data_directory` + ([provisioning.md](./provisioning.md#the-service-restart)). +- The cluster: `PostgreSQL 17.11 on x86_64-windows, compiled by msvc-19.44.35229, 64-bit`; + `server_encoding` `UTF8`; `postgres`, `template0` and `template1` with `datcollate` `C`, + `datctype` `C`, `datlocprovider` `c`, encoding `UTF8`; one role, `postgres`, superuser; + `listen_addresses` `*`, which `initcluster.ps1:294` writes into `postgresql.conf`. +- `pg_hba.conf`: `scram-sha-256` for `local`, `127.0.0.1/32` and `::1/128`, for `all` and for + `replication`. `initdb` ran with `--auth=scram-sha-256`. +- `--disable-components pgAdmin,stackbuilder` leaves no `pgAdmin 4` directory; the command-line + tools, `psql` among them, are installed. + +The runner image's own install (17.11-5 as well) differs: `Data Directory` +`C:\PostgreSQL\17\data`, `Locale` `English_United` in the `Services` key, pgAdmin and Stack +Builder present, and machine-level `PGUSER=postgres` and `PGPASSWORD` set. + +## Where the superuser password goes + +- `initcluster.ps1` receives it as its fourth positional argument: + `powershell.exe -ExecutionPolicy Bypass -File "\installer\server\initcluster.ps1" + "NT AUTHORITY\NetworkService" "postgres" "" "" "" + "" "" 5432 "C" 0`. That is Windows PowerShell 5.1, and the command line is + readable in the process list while the script runs (seen by polling `Win32_Process` every + 200 ms). +- `install-postgresql.log` writes that argument as `****`. A search for the password over + `%TEMP%`, `C:\Windows\Temp`, `\installer`, `\log`, `postgresql.conf`, + `postgresql.auto.conf` and `pg_hba.conf` found it only in the caller's option file. The same + search found `initcluster` in 5 to 14 of those files, so it read the logs. +- `initcluster.ps1:241-244` writes the password into + `%TEMP%\postgresql_installer_<10 hex>\<8 hex>.tmp` with `Set-Content` and no `-Encoding`, + which in Windows PowerShell 5.1 is the ANSI code page. `initdb` reads it through `--pwfile`, + and `:282-285` deletes it. A non-ASCII password therefore reaches the cluster in the ANSI code + page. +- The `initdb` call (`initcluster.ps1:257-266`): `--pgdata="" --username="postgres" + --encoding=UTF8 --pwfile="" --auth=scram-sha-256 --locale="C"`. +- 17.11-5's `initcluster.ps1` equals the EDB 14.24 file on the development machine except for the + Authenticode signature block. + +## Password characters + +One install per password; "passes" means exit 0 and a `psql` login with that password. + +| Password | Result | +| --- | --- | +| `` Aa1 '`$!;^&\|<>()*?z `` | passes | +| `Aa1%PATH%z` | passes | +| `Aa1"z` | passes | +| `Aa1-_.,:@#+=~/[]{}\z` | passes | +| `` Aa1 !#$%&'()*+,-./:;<=>?@[\]^_`{\|}~z ``, every printable ASCII punctuation character but `"` | passes | +| `Aa1z\` | exit 1: `initcluster.ps1` prints its usage line | +| `Aa1"z&y` | exit 1: `initcluster.ps1` prints its usage line | +| `` Q"w e%PATH%^&\|<>'`$x!;z `` | exit 1: `initcluster.ps1` exits 255 | + +A trailing `\` escapes the closing quote of the password argument, so the arguments after it +shift. The `"` failures look the same, a quote alone passing and a quote with `&` failing, which +points at a `cmd.exe` layer between the installer and PowerShell; that mechanism is inferred, not +read in EDB's source. A printable ASCII password without `"` that does not end in `\` passed in +every combination tried. + +## A failed install + +A failed `initcluster.ps1` leaves the installed files, the uninstaller and the `Uninstall` entry, +and no data directory. `Installations\postgresql-x64-17` exists in every case, and `Super User` +is missing in every case: + +| Failure | `Installations\postgresql-x64-17` | Service | +| --- | --- | --- | +| exit 255 | `Base Directory`, `Version`, `Shortcuts`, `Data Directory`, `Service ID`, `Branding`, `CLT_Version` | `postgresql-x64-17` registered, Stopped, Automatic | +| usage line, then a retry without `--disable-components` | `Base Directory`, `Version`, `Shortcuts`, `Branding`, `CLT_Version`, `SB_Version`, `pgAdmin_Version` | none | + +So `Version` and `Service ID` with an existing service do not prove a usable install; `Super +User` and `\PG_VERSION` do. The uninstaller removes a partial install like a +complete one. + +## The uninstaller + +`\uninstall-postgresql.exe --mode unattended --unattendedmodeui none` exits 0 after 6 to +9 s, before it has done anything. It starts a copy of itself, +`%TEMP%\_uninstall<8 hex>\_uninstall.000`, which runs another 14 to 70 s and ends with +`Exiting with code 0` in `%TEMP%\uninstall-postgresql.log`. A caller waits for that process. +Afterwards: + +- the service is gone, a running one included; +- `` holds nothing but the data directory, when the data directory is inside it; +- the `postgresql-x64-17` subkeys and the `Uninstall\PostgreSQL 17` entry are gone, while the + empty `HKLM\SOFTWARE\PostgreSQL\Installations` and `Services` keys stay; +- the data directory stays; the log says `The data directory () has not been removed.` + +## Inno Setup + +Measured 2026-10-19 on the development machine (Windows 11, build 26300) with a scratch setup +built by ISCC 6.7.3: + +- A variable set in `[Code]` through `SetEnvironmentVariableW` (kernel32) reaches a child started + by `Exec`, directly and through `cmd.exe /c "... > log 2>&1"`, byte for byte, Cyrillic and + `"q" %x% ^&|` included. +- `PrepareToInstall` returning a non-empty string under `/VERYSILENT` makes Setup exit with 7. +- The fourth line of a `/LOG` file is `Setup command line: ...` with every parameter. `Exec` + command lines from `[Code]` are not logged. diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md new file mode 100644 index 0000000..899acf9 --- /dev/null +++ b/docs/plans/20261019-windows-installer.md @@ -0,0 +1,389 @@ +# Windows installer for SemiBase + +## Overview +- `SemiBase-Setup.exe` (Inno Setup) brings a Windows machine to the commissioned state in one + run: PostgreSQL 17 present, `semibase site` passed, the service restarted when tuning needs it. +- The headless form is the main channel: `SemiBase-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES + /NORESTART /SUPERPASSWORD=... [/WRITERPASSWORD=...]`, exit code 0 means provisioned, anything + else means not. An absent parameter falls back to `SEMIBASE_SUPER_PASSWORD` or + `SEMIBASE_WRITER_PASSWORD` in Setup's environment, so a caller can keep passwords off Setup's + command line. A future suite installer chains it the same way it chains SemiPlot's. +- The `semiplot` password is the convention `semiplot`, and Setup takes no parameter for it. +- The setup carries the EDB PostgreSQL 17 installer and downloads nothing. +- The setup replaces every other commissioning path: the release no longer attaches a bare + Windows binary, and `docs/deployment.md` describes the setup plus one recovery section for the + states the setup refuses. +- Depends on `20261019-site-loopback-and-restart.md` (`--restart-service`) and + `20261019-password-hygiene.md` (passwords from flags and environment only, none in the server + log). Both are satisfied: this branch is stacked on `site-loopback-and-restart` (PR #19), which + is stacked on `password-hygiene` (PR #17). + +## Context (from discovery) +- SemiPlot's installer is the convention source: `SemiPlot/Installer/SemiPlot.iss` (admin + privileges, ru and en languages, `C:\DISTR\...` site folders with `uninsneveruninstall`, + `/VERYSILENT` contract; `SemiPlot.iss:30` sets `VersionInfoVersion`, which accepts digits and + dots only) and its Windows release job `SemiPlot/.github/workflows/release.yml` (Inno Setup via + `winget`, `iscc.exe /DAppVersion=...`). +- `cmd/semibase/main.go:42-46,191-201` — passwords come from flags, then the `SEMIBASE_*` + environment variables, read with no parsing; `main.go:161-173` — the connection flags, + `--superuser` and `--expected-major` among them. +- Verified 2026-10-19 with a scratch setup built by ISCC 6.7.3: a variable set in `[Code]` through + `SetEnvironmentVariableW` (kernel32) reaches a child started by `Exec`, directly and through + `cmd.exe /c "... > log 2>&1"`, byte for byte, including Cyrillic and `"q" %x% ^&|`; + `PrepareToInstall` returning a string under `/VERYSILENT` exits 7; the `/LOG` file's fourth line + is `Setup command line: ...` with every parameter, and `[Code]` `Exec` command lines are not + logged. +- EDB 14.24 `installer\server\initcluster.ps1` on the development machine: writes the superuser + password with `Set-Content` and no `-Encoding` (`:244`), so in the ANSI code page, for + `initdb --pwfile`, and passes `--encoding=UTF8` (`:260`). The 17.11-5 file is the same apart + from its signature block (task 1, `docs/architecture/installer.md`). +- `internal/provision/create.go:216-236` — an existing role keeps its password unless one is + given; a missing role without a password is an error (`create.go:222-223`). +- `internal/provision/create.go:18-20,257-272` — `CREATE DATABASE` with no options, so a new + database inherits `template1`'s encoding and locale; an existing database is used as is. +- `internal/provision/create.go:138-147,166-189` — an existing `public.trends` is never altered, + only inspected. +- `internal/provision/create.go:191-205` — `--expected-major` mismatch is an error. +- `.github/workflows/release.yml:17-238` — one `ubuntu-latest` job: test, read the tag + (`release.yml:48-122`, outputs `tag`, `version`, `image`, `prerelease`, `newest`, + `make_latest`, `body`), cross-compile both targets (`release.yml:126-153`), build and smoke the + image, create the release with `dist/*`, push the image; the concurrency group is the workflow + (`release.yml:13-15`). `release.yml:198-200` justifies the release-before-push order with + consumers downloading release assets. +- Nothing outside this repository downloads a release asset: the only matches for + `releases/download`, `gh release download` or `semibase__` in SemiPlot and SemiStep are in + `SemiPlot/docs/plans/completed/`; SemiPlot's bench pulls `ghcr.io/semiteq/semibase:latest`. +- `.github/workflows/ci.yml:11-29` — the Windows CI job. +- Verified 2026-10-19 on the development machine: `HKLM\SOFTWARE\PostgreSQL\Installations\ + postgresql-x64-14` holds `Version`, `Data Directory`, `Service ID`, `Super User`; + `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-14` holds `Port` (`0x3c48` = 15432) and + `Locale` (`Russian_Russia.1251`). A second service, `postgresql-14`, runs with no EDB registry + entry. Inno Setup 6 is installed at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`. +- Read 2026-10-19 from `actions/runner-images` `images/windows/Windows2025-Readme.md` + (`windows-latest`): PostgreSQL 17.11, service `postgresql-x64-17`, Stopped, start type + Disabled, user `postgres`, password `root`, `PGDATA=C:\PostgreSQL\17\data`, + `PGBIN=C:\Program Files\PostgreSQL\17\bin`. + +## Development Approach +- **testing approach**: Regular (code first, then tests in the same task) +- complete each task fully before moving to the next +- the installer's tests are end-to-end runs in a dedicated Windows CI job, created in task 3 and + extended by one case per task +- update this plan when scope changes + +## Testing Strategy +- end-to-end: the `installer` CI job builds the setup and runs it silently on the runner, one + case per reachable row of the state tables +- shell: `shellcheck` on the fetch script, in the Linux CI job + +## Acceptance Evidence +All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting +`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per +run; `$psql = "$env:PGBIN\psql.exe"`. +1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` + does not exist; the runner's `postgresql-x64-17` is still Disabled. +2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): + exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads + `semiplot_tags`. +3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and + `HKLM\SOFTWARE\PostgreSQL` does not exist. +4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; + the key is deleted afterwards. +5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit + each time; no `postgresql*` service exists afterwards. +6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains + `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE + pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, + datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN + ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in + with password `semiplot`. +7. The same command again: exit 0; the new site log does not contain `restarting service`. +8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. +9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, + `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case + passes `/LOG`: the Inno log carries Setup's command line by design. +10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is + still present and `semiplot` still logs in. + +## Solution Overview +- All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return + string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in + `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. +- `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to + `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and + the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a + `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid + archive first. The uninstaller removes only `semibase.exe`. +- The installer decides the engine state from the EDB registry (`HKLM64`), never from the service + list alone. `semibase` owns every database-level decision; the installer duplicates none. +- Passwords stay off every child command line and off the disk where the installer controls it: + `[Code]` sets `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and + `SEMIBASE_PLOT_PASSWORD=semiplot` in Setup's own environment with `SetEnvironmentVariableW`, and + `semibase` inherits them. EDB reads its password from an option file under `{tmp}`, deleted as + soon as the EDB process returns. +- What remains exposed: Setup's own command line when the caller passes parameters, which Inno + writes into its log under `/LOG` (`SetupLogging` stays off); the environment block of the + elevated Setup and its children, readable by another administrator during the run; EDB's own + `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a + positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as + `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). +- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes + the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII + superuser password produces a cluster nobody can log into. The superuser password also must + not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the + install fails half-done (`installer.md#password-characters`). +- The superuser password is required on every run and stored nowhere. +- Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the + convention, so the role has one writer and no site-specific value to lose on an upgrade. + +## Technical Details + +### Engine states (decided in `PrepareToInstall`, first matching row wins) + +| State | Action | +| --- | --- | +| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | +| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | +| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | +| Another major is registered, 17 is not | Refuse: name the version, port and service | +| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | +| Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | +| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | + +- Every refusal message points to the recovery section of `docs/deployment.md`. +- EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, + naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required + code was reproduced, so Setup has no reboot branch. A failed install leaves a partial + registration, which the next run refuses through the second row. +- EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup + never starts EDB without one. +- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to + accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run + through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. + +### Database states (decided by `semibase site`, unchanged) + +| State | Behaviour | +| --- | --- | +| `scada_archive` exists | Used as is (`create.go:263-266`) | +| A role exists, no password given | Password kept (`create.go:216-236`) | +| A role is missing, no password given | Error (`create.go:222-223`); the writer password is needed on a first install only | +| `public.trends` exists | Never altered; `tpdefault` is checked (`create.go:166-189`); a missing grant fails the tail check, and the message names the fix | +| Existing 17 cluster with a locale other than `C` | Used as is: `scada_archive` inherits that cluster's `template1` (`create.go:18-20`). Locale `C` with UTF8 holds for clusters this setup installs | +| Server major is not 17 | Error from `--expected-major 17` (`create.go:197-199`) | + +### Run states + +| State | Behaviour | +| --- | --- | +| Setup re-run | First engine row; `site` is idempotent; the restart runs only for pending settings | +| EDB fails | Refusal naming the EDB log; the partial registration it leaves takes the second engine row on a re-run | +| `site` fails after EDB installed | PostgreSQL stays; a re-run with the same superuser password takes the first engine row | +| Uninstall | Removes `semibase.exe` only; the engine, its data and the roles stay | + +### EDB invocation +- Verified in task 1 (`installer.md#command-line`): `--mode unattended --unattendedmodeui none + --optionfile --disable-components pgAdmin,stackbuilder`, the file holding + `superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and + `datadir={#PgDataDir}`. EDB leaves the option file in place. +- `initcluster.ps1` passes `--encoding=UTF8` and `--locale="C"` to `initdb` in 17.11-5 as well; + the result is `UTF8` with `datcollate` and `datctype` `C`. Case 6 asserts it. +- `{#PgDataDir}` is one `#define`, `C:\Program Files\PostgreSQL\17\data` (EDB's default), used + by the option file and by the data-directory row. + +### `semibase` invocation +- `semibase.exe site --port --superuser --expected-major 17 + --restart-service `, output redirected through `cmd.exe /c` to + `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. +- Before the `Exec`, `[Code]` sets the three variables through + `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. + +### Versions +- `iscc /DAppVersion= /DFileVersion= /DSemibaseExe=`. + `AppVersion` keeps a prerelease suffix; `VersionInfoVersion` takes `FileVersion`, digits and + dots only. `SemibaseExe` names the binary: CI's plain build or the `build` job's + `semibase__windows_amd64.exe`. + +### Payload pinning +- `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, + downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release + both call it, so a retired URL fails on a pull request rather than at tag time. + +### Release workflow shape +- `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both + targets, upload them as an artifact. Its job outputs are every `Read the tag` output. The + Windows binary has one consumer, the `installer` job. +- `installer` (windows, needs `build`): download the artifact, fetch the payload, install Inno + Setup, run `iscc`, upload `SemiBase-Setup.exe`. +- `release` (ubuntu, needs both): download both artifacts, `chmod +x` the Linux binary (artifact + transfer drops file modes, and the `Dockerfile` copies without `--chmod`), build and smoke the + image, create the GitHub release with the Linux binary and the setup in one `action-gh-release` + call, then push the image. The workflow-level concurrency group covers all three jobs. +- The Linux binary stays on the release: it is the published reference that the image carries + the same bytes (`release.yml:146-152`). +- The release still comes before the image push, for a different reason than + `release.yml:198-200` states: `:latest` is the publish consumers act on, and the release page + is its record. A push that lands while the release step fails leaves `:latest` on a version + with no release and no notes, and nobody re-runs a push that worked. + +## What Goes Where +- Implementation Steps: the script, the `.iss`, workflows and docs in this repository. +- Post-Completion: SemiPlot-side changes and checks on an installation machine. + +## Implementation Steps + +### Task 1: Verify the EDB facts the design rests on + +**Files:** +- Create: `docs/architecture/installer.md` + +- [x] run the EDB 17 installer with `--help`; record the flags the design names +- [x] run it unattended on a scratch runner or VM; record its exit codes (success, wrong option, + reboot required), the registry keys it writes, `server_encoding` and `datcollate` of the + result, and whether `install-postgresql.log` contains the password (reboot required was + not reproduced: the runner's Visual C++ runtime is newer than EDB's, so EDB runs no + `vcredist`; recorded as such) +- [x] read the 17 `initcluster.ps1`: confirm the ANSI `Set-Content` of the password and + `--encoding=UTF8` +- [x] record these results and the Inno facts from Context as dated measurements in + `docs/architecture/installer.md`; revise this plan where a result contradicts it + +### Task 2: Pin and fetch the EDB payload + +**Files:** +- Create: `Installer/fetch-postgresql.sh` +- Modify: `.gitignore`, `.github/workflows/ci.yml` + +- [ ] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in + `installer.md#the-pinned-payload`), download to + `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches +- [ ] add `Installer/payload/` and `Installer/Output/` to `.gitignore` +- [ ] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job +- [ ] run it twice locally: the second run downloads nothing; a corrupted file fails the run + +### Task 3: Installer skeleton and its CI job + +**Files:** +- Create: `Installer/SemiBase.iss` +- Modify: `.github/workflows/ci.yml` + +- [ ] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, + `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required +- [ ] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; + `[Files]` as in Solution Overview +- [ ] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` + variable; refuse an empty superuser password in silent mode, any password that is not + printable ASCII, and a superuser password that contains `"` or ends in `\`, from + `PrepareToInstall` +- [ ] new CI job `installer` on `windows-latest`: `go build`, cache `Installer/payload/` keyed + on the fetch script's hash, fetch, install Inno Setup via `winget`, `iscc`, Acceptance case 1 +- [ ] push and read the CI run - green before task 4 + +### Task 4: Engine state detection + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` + among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` +- [ ] map them to the engine-state table; each refusal returns its message from + `PrepareToInstall`, including the silent writer-password guard +- [ ] run once on the development machine (14 registered): Setup refuses and names + `postgresql-x64-14` and port 15432 +- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, + the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the + case after the removal +- [ ] push and read the CI run - green before task 5 + +### Task 5: Provisioning run + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] install EDB through the option file when the state requires it; delete the option file as + soon as the EDB `Exec` returns; handle its exit codes as in Technical Details +- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port +- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the + log; a non-zero exit code is a refusal naming the log +- [ ] extend the CI job with Acceptance case 2 +- [ ] push and read the CI run - green before task 6 + +### Task 6: Password page for an interactive install + +**Files:** +- Modify: `Installer/SemiBase.iss` + +- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the + parameters or variables; not shown under `/SILENT` or `/VERYSILENT` +- [ ] the superuser field is required; the writer field is required when the engine state is the + fresh-install row +- [ ] build and click through once in a VM + +### Task 7: Full CI case list + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended + --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it + starts to exit (the uninstaller itself exits before doing anything, + `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, + `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 +- [ ] add Acceptance cases 4 to 10 in order +- [ ] push and read the CI run - green before task 8 + +### Task 8: Release the setup + +**Files:** +- Modify: `.github/workflows/release.yml` + +- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every + comment whose reasoning still holds, and move each one to the step it explains +- [ ] the `release` job restores the exec bit, then attaches the Linux binary and + `SemiBase-Setup.exe` in one call; the Windows binary is not attached +- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details +- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, + `:latest` did not move, and nothing is published to `ghcr.io/semiteq` + +### Task 9: Verify acceptance criteria +- [ ] every case in Acceptance Evidence passes on the CI run +- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the + Linux job + +### Task 10: [Final] Update documentation +- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment + fallbacks and exit codes, the three state tables in operator terms, the commissioning order + with the setup as step 1; `winget` and the manual restart go +- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running + `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service + ` with the password variables set in the shell; `deployment.md:22` stops pointing + at a bare Windows binary +- [ ] `README.md:28-36` becomes the setup command +- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored + in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index + gain the setup and `installer.md` +- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the + artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux + row's consumer becomes the image build and its provenance +- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 + (`overview.md:56`) becomes the setup +- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the + ordering rationale and "consumers ... still download the release assets" +- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is + updated, and SemiPlot's go to Post-Completion +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion +- SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while + `semibase` defaults to `scada_archive` (`cmd/semibase/main.go:164`). One of them changes before + the suite installer chains both. +- SemiPlot `ConfigFiles/connection/connection.yaml:2` fixes port 5432, while an existing 17 may + sit on another registered port; `connection.yaml:5` ships an empty password, which becomes the + `semiplot` convention. +- SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` + (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and + `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". +- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. +- The suite installer passes the superuser password to every SemiBase run, through + `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade + that may restart the service. From 64e66a0dd5e2d329505b4e4fe0036fce13608550 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:08:11 +0300 Subject: [PATCH 07/35] docs(installer): state how initcluster was compared --- docs/architecture/installer.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 22e7c12..271644a 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -114,8 +114,9 @@ Builder present, and machine-level `PGUSER=postgres` and `PGPASSWORD` set. page. - The `initdb` call (`initcluster.ps1:257-266`): `--pgdata="" --username="postgres" --encoding=UTF8 --pwfile="" --auth=scram-sha-256 --locale="C"`. -- 17.11-5's `initcluster.ps1` equals the EDB 14.24 file on the development machine except for the - Authenticode signature block. +- 17.11-5's `initcluster.ps1` has the same code, line for line, as the EDB 14.24 file on the + development machine; only the signature block differs. The 17 copy was compared as printed in + the CI log, so whitespace and line endings were not compared. ## Password characters From a8d69952c3aed695b20321039364026505c52ed4 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:15:09 +0300 Subject: [PATCH 08/35] build(installer): pin and fetch the EDB payload --- .github/workflows/ci.yml | 3 ++ .gitignore | 4 ++ Installer/fetch-postgresql.sh | 53 ++++++++++++++++++++++++ docs/plans/20261019-windows-installer.md | 12 +++--- 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 Installer/fetch-postgresql.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22f3f9c..b2179e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,9 @@ jobs: - name: Test run: go test -race ./... + - name: Shellcheck the payload fetch script + run: shellcheck Installer/fetch-postgresql.sh + # The site path, the only thing that covers the ALTER SYSTEM tuning: the # same binary provisions the service container the way it provisions an # installation machine. Run twice to prove idempotency - the second run diff --git a/.gitignore b/.gitignore index 8159d90..07f68f0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,9 @@ Thumbs.db # Container image build context /image/ +# Installer: the fetched EDB payload and the iscc output +/Installer/payload/ +/Installer/Output/ + # Archive dumps taken from a customer installation. The repository is public. /samples/ diff --git a/Installer/fetch-postgresql.sh b/Installer/fetch-postgresql.sh new file mode 100644 index 0000000..8e43c93 --- /dev/null +++ b/Installer/fetch-postgresql.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Puts the EDB PostgreSQL installer that SemiBase-Setup.exe carries into Installer/payload/. +# The constants are docs/architecture/installer.md#the-pinned-payload. +set -euo pipefail + +readonly VERSION=17.11-5 +readonly URL="https://get.enterprisedb.com/postgresql/postgresql-${VERSION}-windows-x64.exe" +readonly SHA256=612c7f5400a003aaffa0372cb273eed52278f93c680ab911eb9cb2229e329072 + +readonly FILE_NAME="${URL##*/}" +PAYLOAD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/payload" +readonly PAYLOAD_DIR +readonly PART_NAME="${FILE_NAME}.part" + +matches_pin() { + local name=$1 + (cd "$PAYLOAD_DIR" && printf '%s *%s\n' "$SHA256" "$name" | sha256sum -c --status) +} + +main() { + if [[ $# -ne 0 ]]; then + echo "usage: fetch-postgresql.sh" >&2 + exit 2 + fi + local tool + for tool in curl sha256sum; do + command -v "$tool" >/dev/null || { echo "$tool required" >&2; exit 1; } + done + + mkdir -p "$PAYLOAD_DIR" + if [[ -e "$PAYLOAD_DIR/$FILE_NAME" ]]; then + if matches_pin "$FILE_NAME"; then + echo "$PAYLOAD_DIR/$FILE_NAME matches the pinned SHA256, nothing to download" >&2 + return 0 + fi + # A download lands under its final name only after it matched, so this file was changed. + echo "$PAYLOAD_DIR/$FILE_NAME does not match SHA256 $SHA256; delete it and run again" >&2 + exit 1 + fi + + trap 'rm -f "$PAYLOAD_DIR/$PART_NAME"' EXIT + echo "downloading $URL" >&2 + curl -fsSL --retry 3 -o "$PAYLOAD_DIR/$PART_NAME" "$URL" + if ! matches_pin "$PART_NAME"; then + echo "$URL does not match the pinned SHA256 $SHA256:" \ + "the server changed the file, so the pin needs a new measurement" >&2 + exit 1 + fi + mv "$PAYLOAD_DIR/$PART_NAME" "$PAYLOAD_DIR/$FILE_NAME" + echo "$PAYLOAD_DIR/$FILE_NAME downloaded and verified" >&2 +} + +main "$@" diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 899acf9..84a3c1e 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -254,12 +254,14 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - Create: `Installer/fetch-postgresql.sh` - Modify: `.gitignore`, `.github/workflows/ci.yml` -- [ ] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in +- [x] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in `installer.md#the-pinned-payload`), download to - `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches -- [ ] add `Installer/payload/` and `Installer/Output/` to `.gitignore` -- [ ] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job -- [ ] run it twice locally: the second run downloads nothing; a corrupted file fails the run + `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches; a + present file that does not match fails the run and is left for the operator to delete, + because a download lands under its final name only after it matched +- [x] add `Installer/payload/` and `Installer/Output/` to `.gitignore` +- [x] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job +- [x] run it twice locally: the second run downloads nothing; a corrupted file fails the run ### Task 3: Installer skeleton and its CI job From 89cbf7e46221e649f873caf0e954ac31e6130d0b Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:29:07 +0300 Subject: [PATCH 09/35] build(installer): add the setup skeleton and password checks SemiBase-Setup.exe needs a script before it can provision anything. This one carries semibase.exe into {app}, the EDB installer stored uncompressed for [Code] to extract, and the C:\DISTR log folder. PrepareToInstall refuses a missing superuser password, a password that is not printable ASCII, and a superuser password with " or a trailing \, so a refusal exits 7 before a file is copied. Setup's parser drops every " from its command line, measured here, so a password parameter that does not appear verbatim in GetCmdTail is refused as well. --- Installer/SemiBase.iss | 145 +++++++++++++++++++++++ docs/architecture/installer.md | 11 ++ docs/plans/20261019-windows-installer.md | 5 +- 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 Installer/SemiBase.iss diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss new file mode 100644 index 0000000..17b165c --- /dev/null +++ b/Installer/SemiBase.iss @@ -0,0 +1,145 @@ +; SemiBase Inno Setup installer script +; Build with: iscc.exe /DAppVersion=1.2.3 /DFileVersion=1.2.3 /DSemibaseExe= Installer\SemiBase.iss +; Run `bash Installer/fetch-postgresql.sh` first; it puts the EDB installer into Installer\payload\. + +#ifndef AppVersion + #error AppVersion must be defined. Pass it as: iscc.exe /DAppVersion=1.2.3 SemiBase.iss +#endif +#ifndef FileVersion + #error FileVersion must be defined, digits and dots only. Pass it as: iscc.exe /DFileVersion=1.2.3 SemiBase.iss +#endif +#ifndef SemibaseExe + #error SemibaseExe must be defined. Pass it as: iscc.exe /DSemibaseExe=C:\path\to\semibase.exe SemiBase.iss +#endif + +#define AppName "SemiBase" +#define AppPublisher "Inc Semiteq" +#define AppId "{{5AFFFB1F-3C84-45EB-800A-67812EBAE460}" + +; The file name carries the version pinned in fetch-postgresql.sh; a new pin changes both +#define PgInstaller "postgresql-17.11-5-windows-x64.exe" + +#define LogRoot "C:\DISTR\Logs\SemiBase" + +[Setup] +AppId={#AppId} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +AppVerName={#AppName} {#AppVersion} +VersionInfoVersion={#FileVersion} + +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +DefaultDirName={autopf}\{#AppName} +DisableProgramGroupPage=yes + +OutputDir=Output +OutputBaseFilename=SemiBase-Setup + +Compression=lzma2/ultra64 +SolidCompression=yes +LZMAUseSeparateProcess=yes + +WizardStyle=modern + +; Program Files, C:\DISTR and the PostgreSQL service all need admin rights +PrivilegesRequired=admin + +[Languages] +Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Dirs] +Name: "{#LogRoot}"; Flags: uninsneveruninstall + +[Files] +Source: "{#SemibaseExe}"; DestDir: "{app}"; DestName: "semibase.exe"; Flags: ignoreversion + +; Stored as is, so extracting it in [Code] copies it instead of decompressing it +Source: "payload\{#PgInstaller}"; Flags: dontcopy nocompression + +[Code] +function CommandLineParameter(const Name: String): String; +var + Prefix: String; + I: Integer; +begin + Result := ''; + Prefix := '/' + Name + '='; + for I := 1 to ParamCount do + if CompareText(Copy(ParamStr(I), 1, Length(Prefix)), Prefix) = 0 then + begin + Result := Copy(ParamStr(I), Length(Prefix) + 1, Length(ParamStr(I))); + Exit; + end; +end; + +// Setup's command-line parser drops every ", so a value typed with one arrives changed +function ParameterRefusal(const Name, VariableName: String): String; +var + Value, Prefix, Tail: String; + ValueStart: Integer; +begin + Result := ''; + Value := CommandLineParameter(Name); + if Value = '' then + Exit; + Prefix := '/' + Name + '='; + Tail := GetCmdTail; + ValueStart := Pos(Uppercase(Prefix), Uppercase(Tail)) + Length(Prefix); + if Copy(Tail, ValueStart, Length(Value)) <> Value then + Result := 'The /' + Name + ' value does not reach Setup as typed: Setup drops every " from ' + + 'its command line. Put the quotes around the whole parameter, or pass the password ' + + 'through ' + VariableName + '.'; +end; + +function ResolvePassword(const ParameterName, VariableName: String): String; +begin + Result := CommandLineParameter(ParameterName); + if Result = '' then + Result := GetEnv(VariableName); +end; + +function IsPrintableAscii(const Value: String): Boolean; +var + I: Integer; +begin + Result := True; + for I := 1 to Length(Value) do + if (Ord(Value[I]) < 32) or (Ord(Value[I]) > 126) then + begin + Result := False; + Exit; + end; +end; + +function PasswordRefusal(const SuperPassword, WriterPassword: String): String; +begin + Result := ''; + if SuperPassword = '' then + Result := 'No superuser password: pass /SUPERPASSWORD= or set SEMIBASE_SUPER_PASSWORD.' + else if not IsPrintableAscii(SuperPassword) then + Result := 'The superuser password must be printable ASCII: the PostgreSQL installer stores it ' + + 'in the ANSI code page, and semibase sends it in UTF-8.' + else if (Pos('"', SuperPassword) > 0) or (SuperPassword[Length(SuperPassword)] = '\') then + Result := 'The superuser password must not contain " or end in \: either one breaks the ' + + 'PostgreSQL installer''s command line.' + else if not IsPrintableAscii(WriterPassword) then + Result := 'The scada_writer password (/WRITERPASSWORD or SEMIBASE_WRITER_PASSWORD) must be ' + + 'printable ASCII.'; +end; + +// A refusal returned here comes before Inno copies a file and exits 7 under /VERYSILENT; one +// raised later comes after the uninstaller is registered and may still exit 0. +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ParameterRefusal('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); + if Result = '' then + Result := ParameterRefusal('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); + if Result = '' then + Result := PasswordRefusal( + ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'), + ResolvePassword('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD')); +end; diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 271644a..339047a 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -179,3 +179,14 @@ built by ISCC 6.7.3: - `PrepareToInstall` returning a non-empty string under `/VERYSILENT` makes Setup exit with 7. - The fourth line of a `/LOG` file is `Setup command line: ...` with every parameter. `Exec` command lines from `[Code]` are not logged. + +Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that has +`PrivilegesRequired=lowest`, no payload and a refusal in place of success: + +- `ParamStr` and `{param:...}` return the same value, and both drop every `"` while keeping `\`: + `/SUPERPASSWORD=Aa1"z` and `"/SUPERPASSWORD=Aa1""z"` arrive as `Aa1z`, + `"/SUPERPASSWORD=Aa1\"z"` as `Aa1\z`, `"/SUPERPASSWORD=a b"` as `a b`. `{x}|y%PATH%` arrives + unchanged. A parameter name matches in any case. +- `GetCmdTail` returns the command line as typed, quotes included, after a leading + `/SL5="..."` that the bootstrapper adds. The setup compares the two and refuses a password + parameter whose value does not appear in it verbatim. diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 84a3c1e..70c53b7 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -131,7 +131,10 @@ run; `$psql = "$env:PGBIN\psql.exe"`. the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII superuser password produces a cluster nobody can log into. The superuser password also must not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the - install fails half-done (`installer.md#password-characters`). + install fails half-done (`installer.md#password-characters`). Setup's own command-line parser + drops every `"`, so a password parameter whose value does not appear verbatim in + `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable + (`installer.md#inno-setup`). - The superuser password is required on every run and stored nowhere. - Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the convention, so the role has one writer and no site-specific value to lose on an upgrade. From 8c7c7cc15348b5aa3b6d91f5aa444b1ce0b8e38e Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:29:07 +0300 Subject: [PATCH 10/35] ci(installer): build the setup and refuse a passwordless run A windows-latest job builds semibase.exe, fetches the cached EDB payload, compiles the setup and runs it with no superuser password: it must exit 7, install nothing and leave postgresql-x64-17 Disabled. --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2179e7..305db86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,3 +319,70 @@ jobs: cp semibase-linux image/semibase docker build --platform linux/amd64 -f Dockerfile -t semibase:ci image docker run --rm semibase:ci version + + # The setup's end-to-end cases. No SEMIBASE_* variable is set at job level: a + # case that wants one sets it, so the first case runs with none. + installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Build + run: go build -o semibase.exe ./cmd/semibase + + - name: Cache the EDB payload + uses: actions/cache@v6 + with: + path: Installer/payload + key: edb-payload-${{ hashFiles('Installer/fetch-postgresql.sh') }} + + # shell: bash is Git Bash; a bare bash on the PATH may be WSL's + - name: Fetch the EDB payload + shell: bash + run: bash Installer/fetch-postgresql.sh + + # The jrsoftware.org direct download intermittently serves a corrupted file, so winget + # (preinstalled on windows runners) installs it instead. Machine scope keeps the + # Program Files (x86) path the next step expects. + - name: Install Inno Setup + run: | + winget install --id JRSoftware.InnoSetup --exact --scope machine --silent ` + --accept-source-agreements --accept-package-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { exit 1 } + "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Build the setup + run: | + iscc /DAppVersion=0.0.0-ci /DFileVersion=0.0.0 "/DSemibaseExe=$env:GITHUB_WORKSPACE\semibase.exe" ` + Installer\SemiBase.iss + if ($LASTEXITCODE -ne 0) { exit 1 } + + # Setup is a GUI program: the call operator returns before it exits, so + # Start-Process waits for it. Exit 7 is what Setup returns when + # PrepareToInstall refuses (docs/architecture/installer.md#inno-setup). + - name: Refuse a run without a superuser password + run: | + $service = 'postgresql-x64-17' + if (Test-Path Env:SEMIBASE_SUPER_PASSWORD) { + Write-Host '::error::SEMIBASE_SUPER_PASSWORD is set, so this case tests nothing' + exit 1 + } + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + if ($setup.ExitCode -ne 7) { + Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" + exit 1 + } + if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { + Write-Host '::error::the refused run installed semibase.exe' + exit 1 + } + $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode + if ($startMode -ne 'Disabled') { + Write-Host "::error::the refused run changed $service to start type '$startMode' (want Disabled)" + exit 1 + } From 158ae1bcaf774f25aa37f7d50d23e9a1d60067dd Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:05:19 +0300 Subject: [PATCH 11/35] docs(plans): mark the installer skeleton task done --- docs/plans/20261019-windows-installer.md | 788 +++++++++++------------ 1 file changed, 394 insertions(+), 394 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 70c53b7..7058cac 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -1,394 +1,394 @@ -# Windows installer for SemiBase - -## Overview -- `SemiBase-Setup.exe` (Inno Setup) brings a Windows machine to the commissioned state in one - run: PostgreSQL 17 present, `semibase site` passed, the service restarted when tuning needs it. -- The headless form is the main channel: `SemiBase-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES - /NORESTART /SUPERPASSWORD=... [/WRITERPASSWORD=...]`, exit code 0 means provisioned, anything - else means not. An absent parameter falls back to `SEMIBASE_SUPER_PASSWORD` or - `SEMIBASE_WRITER_PASSWORD` in Setup's environment, so a caller can keep passwords off Setup's - command line. A future suite installer chains it the same way it chains SemiPlot's. -- The `semiplot` password is the convention `semiplot`, and Setup takes no parameter for it. -- The setup carries the EDB PostgreSQL 17 installer and downloads nothing. -- The setup replaces every other commissioning path: the release no longer attaches a bare - Windows binary, and `docs/deployment.md` describes the setup plus one recovery section for the - states the setup refuses. -- Depends on `20261019-site-loopback-and-restart.md` (`--restart-service`) and - `20261019-password-hygiene.md` (passwords from flags and environment only, none in the server - log). Both are satisfied: this branch is stacked on `site-loopback-and-restart` (PR #19), which - is stacked on `password-hygiene` (PR #17). - -## Context (from discovery) -- SemiPlot's installer is the convention source: `SemiPlot/Installer/SemiPlot.iss` (admin - privileges, ru and en languages, `C:\DISTR\...` site folders with `uninsneveruninstall`, - `/VERYSILENT` contract; `SemiPlot.iss:30` sets `VersionInfoVersion`, which accepts digits and - dots only) and its Windows release job `SemiPlot/.github/workflows/release.yml` (Inno Setup via - `winget`, `iscc.exe /DAppVersion=...`). -- `cmd/semibase/main.go:42-46,191-201` — passwords come from flags, then the `SEMIBASE_*` - environment variables, read with no parsing; `main.go:161-173` — the connection flags, - `--superuser` and `--expected-major` among them. -- Verified 2026-10-19 with a scratch setup built by ISCC 6.7.3: a variable set in `[Code]` through - `SetEnvironmentVariableW` (kernel32) reaches a child started by `Exec`, directly and through - `cmd.exe /c "... > log 2>&1"`, byte for byte, including Cyrillic and `"q" %x% ^&|`; - `PrepareToInstall` returning a string under `/VERYSILENT` exits 7; the `/LOG` file's fourth line - is `Setup command line: ...` with every parameter, and `[Code]` `Exec` command lines are not - logged. -- EDB 14.24 `installer\server\initcluster.ps1` on the development machine: writes the superuser - password with `Set-Content` and no `-Encoding` (`:244`), so in the ANSI code page, for - `initdb --pwfile`, and passes `--encoding=UTF8` (`:260`). The 17.11-5 file is the same apart - from its signature block (task 1, `docs/architecture/installer.md`). -- `internal/provision/create.go:216-236` — an existing role keeps its password unless one is - given; a missing role without a password is an error (`create.go:222-223`). -- `internal/provision/create.go:18-20,257-272` — `CREATE DATABASE` with no options, so a new - database inherits `template1`'s encoding and locale; an existing database is used as is. -- `internal/provision/create.go:138-147,166-189` — an existing `public.trends` is never altered, - only inspected. -- `internal/provision/create.go:191-205` — `--expected-major` mismatch is an error. -- `.github/workflows/release.yml:17-238` — one `ubuntu-latest` job: test, read the tag - (`release.yml:48-122`, outputs `tag`, `version`, `image`, `prerelease`, `newest`, - `make_latest`, `body`), cross-compile both targets (`release.yml:126-153`), build and smoke the - image, create the release with `dist/*`, push the image; the concurrency group is the workflow - (`release.yml:13-15`). `release.yml:198-200` justifies the release-before-push order with - consumers downloading release assets. -- Nothing outside this repository downloads a release asset: the only matches for - `releases/download`, `gh release download` or `semibase__` in SemiPlot and SemiStep are in - `SemiPlot/docs/plans/completed/`; SemiPlot's bench pulls `ghcr.io/semiteq/semibase:latest`. -- `.github/workflows/ci.yml:11-29` — the Windows CI job. -- Verified 2026-10-19 on the development machine: `HKLM\SOFTWARE\PostgreSQL\Installations\ - postgresql-x64-14` holds `Version`, `Data Directory`, `Service ID`, `Super User`; - `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-14` holds `Port` (`0x3c48` = 15432) and - `Locale` (`Russian_Russia.1251`). A second service, `postgresql-14`, runs with no EDB registry - entry. Inno Setup 6 is installed at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`. -- Read 2026-10-19 from `actions/runner-images` `images/windows/Windows2025-Readme.md` - (`windows-latest`): PostgreSQL 17.11, service `postgresql-x64-17`, Stopped, start type - Disabled, user `postgres`, password `root`, `PGDATA=C:\PostgreSQL\17\data`, - `PGBIN=C:\Program Files\PostgreSQL\17\bin`. - -## Development Approach -- **testing approach**: Regular (code first, then tests in the same task) -- complete each task fully before moving to the next -- the installer's tests are end-to-end runs in a dedicated Windows CI job, created in task 3 and - extended by one case per task -- update this plan when scope changes - -## Testing Strategy -- end-to-end: the `installer` CI job builds the setup and runs it silently on the runner, one - case per reachable row of the state tables -- shell: `shellcheck` on the fetch script, in the Linux CI job - -## Acceptance Evidence -All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting -`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per -run; `$psql = "$env:PGBIN\psql.exe"`. -1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` - does not exist; the runner's `postgresql-x64-17` is still Disabled. -2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): - exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads - `semiplot_tags`. -3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and - `HKLM\SOFTWARE\PostgreSQL` does not exist. -4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; - the key is deleted afterwards. -5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit - each time; no `postgresql*` service exists afterwards. -6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains - `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE - pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, - datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN - ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in - with password `semiplot`. -7. The same command again: exit 0; the new site log does not contain `restarting service`. -8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. -9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, - `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case - passes `/LOG`: the Inno log carries Setup's command line by design. -10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is - still present and `semiplot` still logs in. - -## Solution Overview -- All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return - string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in - `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. -- `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to - `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and - the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a - `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid - archive first. The uninstaller removes only `semibase.exe`. -- The installer decides the engine state from the EDB registry (`HKLM64`), never from the service - list alone. `semibase` owns every database-level decision; the installer duplicates none. -- Passwords stay off every child command line and off the disk where the installer controls it: - `[Code]` sets `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and - `SEMIBASE_PLOT_PASSWORD=semiplot` in Setup's own environment with `SetEnvironmentVariableW`, and - `semibase` inherits them. EDB reads its password from an option file under `{tmp}`, deleted as - soon as the EDB process returns. -- What remains exposed: Setup's own command line when the caller passes parameters, which Inno - writes into its log under `/LOG` (`SetupLogging` stays off); the environment block of the - elevated Setup and its children, readable by another administrator during the run; EDB's own - `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a - positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as - `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). -- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes - the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII - superuser password produces a cluster nobody can log into. The superuser password also must - not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the - install fails half-done (`installer.md#password-characters`). Setup's own command-line parser - drops every `"`, so a password parameter whose value does not appear verbatim in - `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable - (`installer.md#inno-setup`). -- The superuser password is required on every run and stored nowhere. -- Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the - convention, so the role has one writer and no site-specific value to lose on an upgrade. - -## Technical Details - -### Engine states (decided in `PrepareToInstall`, first matching row wins) - -| State | Action | -| --- | --- | -| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | -| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | -| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | -| Another major is registered, 17 is not | Refuse: name the version, port and service | -| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | -| Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | -| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | - -- Every refusal message points to the recovery section of `docs/deployment.md`. -- EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, - naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required - code was reproduced, so Setup has no reboot branch. A failed install leaves a partial - registration, which the next run refuses through the second row. -- EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup - never starts EDB without one. -- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to - accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run - through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. - -### Database states (decided by `semibase site`, unchanged) - -| State | Behaviour | -| --- | --- | -| `scada_archive` exists | Used as is (`create.go:263-266`) | -| A role exists, no password given | Password kept (`create.go:216-236`) | -| A role is missing, no password given | Error (`create.go:222-223`); the writer password is needed on a first install only | -| `public.trends` exists | Never altered; `tpdefault` is checked (`create.go:166-189`); a missing grant fails the tail check, and the message names the fix | -| Existing 17 cluster with a locale other than `C` | Used as is: `scada_archive` inherits that cluster's `template1` (`create.go:18-20`). Locale `C` with UTF8 holds for clusters this setup installs | -| Server major is not 17 | Error from `--expected-major 17` (`create.go:197-199`) | - -### Run states - -| State | Behaviour | -| --- | --- | -| Setup re-run | First engine row; `site` is idempotent; the restart runs only for pending settings | -| EDB fails | Refusal naming the EDB log; the partial registration it leaves takes the second engine row on a re-run | -| `site` fails after EDB installed | PostgreSQL stays; a re-run with the same superuser password takes the first engine row | -| Uninstall | Removes `semibase.exe` only; the engine, its data and the roles stay | - -### EDB invocation -- Verified in task 1 (`installer.md#command-line`): `--mode unattended --unattendedmodeui none - --optionfile --disable-components pgAdmin,stackbuilder`, the file holding - `superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and - `datadir={#PgDataDir}`. EDB leaves the option file in place. -- `initcluster.ps1` passes `--encoding=UTF8` and `--locale="C"` to `initdb` in 17.11-5 as well; - the result is `UTF8` with `datcollate` and `datctype` `C`. Case 6 asserts it. -- `{#PgDataDir}` is one `#define`, `C:\Program Files\PostgreSQL\17\data` (EDB's default), used - by the option file and by the data-directory row. - -### `semibase` invocation -- `semibase.exe site --port --superuser --expected-major 17 - --restart-service `, output redirected through `cmd.exe /c` to - `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. -- Before the `Exec`, `[Code]` sets the three variables through - `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. - -### Versions -- `iscc /DAppVersion= /DFileVersion= /DSemibaseExe=`. - `AppVersion` keeps a prerelease suffix; `VersionInfoVersion` takes `FileVersion`, digits and - dots only. `SemibaseExe` names the binary: CI's plain build or the `build` job's - `semibase__windows_amd64.exe`. - -### Payload pinning -- `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, - downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release - both call it, so a retired URL fails on a pull request rather than at tag time. - -### Release workflow shape -- `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both - targets, upload them as an artifact. Its job outputs are every `Read the tag` output. The - Windows binary has one consumer, the `installer` job. -- `installer` (windows, needs `build`): download the artifact, fetch the payload, install Inno - Setup, run `iscc`, upload `SemiBase-Setup.exe`. -- `release` (ubuntu, needs both): download both artifacts, `chmod +x` the Linux binary (artifact - transfer drops file modes, and the `Dockerfile` copies without `--chmod`), build and smoke the - image, create the GitHub release with the Linux binary and the setup in one `action-gh-release` - call, then push the image. The workflow-level concurrency group covers all three jobs. -- The Linux binary stays on the release: it is the published reference that the image carries - the same bytes (`release.yml:146-152`). -- The release still comes before the image push, for a different reason than - `release.yml:198-200` states: `:latest` is the publish consumers act on, and the release page - is its record. A push that lands while the release step fails leaves `:latest` on a version - with no release and no notes, and nobody re-runs a push that worked. - -## What Goes Where -- Implementation Steps: the script, the `.iss`, workflows and docs in this repository. -- Post-Completion: SemiPlot-side changes and checks on an installation machine. - -## Implementation Steps - -### Task 1: Verify the EDB facts the design rests on - -**Files:** -- Create: `docs/architecture/installer.md` - -- [x] run the EDB 17 installer with `--help`; record the flags the design names -- [x] run it unattended on a scratch runner or VM; record its exit codes (success, wrong option, - reboot required), the registry keys it writes, `server_encoding` and `datcollate` of the - result, and whether `install-postgresql.log` contains the password (reboot required was - not reproduced: the runner's Visual C++ runtime is newer than EDB's, so EDB runs no - `vcredist`; recorded as such) -- [x] read the 17 `initcluster.ps1`: confirm the ANSI `Set-Content` of the password and - `--encoding=UTF8` -- [x] record these results and the Inno facts from Context as dated measurements in - `docs/architecture/installer.md`; revise this plan where a result contradicts it - -### Task 2: Pin and fetch the EDB payload - -**Files:** -- Create: `Installer/fetch-postgresql.sh` -- Modify: `.gitignore`, `.github/workflows/ci.yml` - -- [x] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in - `installer.md#the-pinned-payload`), download to - `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches; a - present file that does not match fails the run and is left for the operator to delete, - because a download lands under its final name only after it matched -- [x] add `Installer/payload/` and `Installer/Output/` to `.gitignore` -- [x] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job -- [x] run it twice locally: the second run downloads nothing; a corrupted file fails the run - -### Task 3: Installer skeleton and its CI job - -**Files:** -- Create: `Installer/SemiBase.iss` -- Modify: `.github/workflows/ci.yml` - -- [ ] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, - `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required -- [ ] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; - `[Files]` as in Solution Overview -- [ ] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` - variable; refuse an empty superuser password in silent mode, any password that is not - printable ASCII, and a superuser password that contains `"` or ends in `\`, from - `PrepareToInstall` -- [ ] new CI job `installer` on `windows-latest`: `go build`, cache `Installer/payload/` keyed - on the fetch script's hash, fetch, install Inno Setup via `winget`, `iscc`, Acceptance case 1 -- [ ] push and read the CI run - green before task 4 - -### Task 4: Engine state detection - -**Files:** -- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` - -- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` - among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` -- [ ] map them to the engine-state table; each refusal returns its message from - `PrepareToInstall`, including the silent writer-password guard -- [ ] run once on the development machine (14 registered): Setup refuses and names - `postgresql-x64-14` and port 15432 -- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, - the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the - case after the removal -- [ ] push and read the CI run - green before task 5 - -### Task 5: Provisioning run - -**Files:** -- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` - -- [ ] install EDB through the option file when the state requires it; delete the option file as - soon as the EDB `Exec` returns; handle its exit codes as in Technical Details -- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port -- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the - log; a non-zero exit code is a refusal naming the log -- [ ] extend the CI job with Acceptance case 2 -- [ ] push and read the CI run - green before task 6 - -### Task 6: Password page for an interactive install - -**Files:** -- Modify: `Installer/SemiBase.iss` - -- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the - parameters or variables; not shown under `/SILENT` or `/VERYSILENT` -- [ ] the superuser field is required; the writer field is required when the engine state is the - fresh-install row -- [ ] build and click through once in a VM - -### Task 7: Full CI case list - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended - --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it - starts to exit (the uninstaller itself exits before doing anything, - `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, - `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 -- [ ] add Acceptance cases 4 to 10 in order -- [ ] push and read the CI run - green before task 8 - -### Task 8: Release the setup - -**Files:** -- Modify: `.github/workflows/release.yml` - -- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every - comment whose reasoning still holds, and move each one to the step it explains -- [ ] the `release` job restores the exec bit, then attaches the Linux binary and - `SemiBase-Setup.exe` in one call; the Windows binary is not attached -- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details -- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, - `:latest` did not move, and nothing is published to `ghcr.io/semiteq` - -### Task 9: Verify acceptance criteria -- [ ] every case in Acceptance Evidence passes on the CI run -- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the - Linux job - -### Task 10: [Final] Update documentation -- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment - fallbacks and exit codes, the three state tables in operator terms, the commissioning order - with the setup as step 1; `winget` and the manual restart go -- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running - `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service - ` with the password variables set in the shell; `deployment.md:22` stops pointing - at a bare Windows binary -- [ ] `README.md:28-36` becomes the setup command -- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored - in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index - gain the setup and `installer.md` -- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the - artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux - row's consumer becomes the image build and its provenance -- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 - (`overview.md:56`) becomes the setup -- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the - ordering rationale and "consumers ... still download the release assets" -- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is - updated, and SemiPlot's go to Post-Completion -- [ ] move this plan to `docs/plans/completed/` - -## Post-Completion -- SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while - `semibase` defaults to `scada_archive` (`cmd/semibase/main.go:164`). One of them changes before - the suite installer chains both. -- SemiPlot `ConfigFiles/connection/connection.yaml:2` fixes port 5432, while an existing 17 may - sit on another registered port; `connection.yaml:5` ships an empty password, which becomes the - `semiplot` convention. -- SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` - (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and - `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". -- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. -- The suite installer passes the superuser password to every SemiBase run, through - `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade - that may restart the service. +# Windows installer for SemiBase + +## Overview +- `SemiBase-Setup.exe` (Inno Setup) brings a Windows machine to the commissioned state in one + run: PostgreSQL 17 present, `semibase site` passed, the service restarted when tuning needs it. +- The headless form is the main channel: `SemiBase-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES + /NORESTART /SUPERPASSWORD=... [/WRITERPASSWORD=...]`, exit code 0 means provisioned, anything + else means not. An absent parameter falls back to `SEMIBASE_SUPER_PASSWORD` or + `SEMIBASE_WRITER_PASSWORD` in Setup's environment, so a caller can keep passwords off Setup's + command line. A future suite installer chains it the same way it chains SemiPlot's. +- The `semiplot` password is the convention `semiplot`, and Setup takes no parameter for it. +- The setup carries the EDB PostgreSQL 17 installer and downloads nothing. +- The setup replaces every other commissioning path: the release no longer attaches a bare + Windows binary, and `docs/deployment.md` describes the setup plus one recovery section for the + states the setup refuses. +- Depends on `20261019-site-loopback-and-restart.md` (`--restart-service`) and + `20261019-password-hygiene.md` (passwords from flags and environment only, none in the server + log). Both are satisfied: this branch is stacked on `site-loopback-and-restart` (PR #19), which + is stacked on `password-hygiene` (PR #17). + +## Context (from discovery) +- SemiPlot's installer is the convention source: `SemiPlot/Installer/SemiPlot.iss` (admin + privileges, ru and en languages, `C:\DISTR\...` site folders with `uninsneveruninstall`, + `/VERYSILENT` contract; `SemiPlot.iss:30` sets `VersionInfoVersion`, which accepts digits and + dots only) and its Windows release job `SemiPlot/.github/workflows/release.yml` (Inno Setup via + `winget`, `iscc.exe /DAppVersion=...`). +- `cmd/semibase/main.go:42-46,191-201` — passwords come from flags, then the `SEMIBASE_*` + environment variables, read with no parsing; `main.go:161-173` — the connection flags, + `--superuser` and `--expected-major` among them. +- Verified 2026-10-19 with a scratch setup built by ISCC 6.7.3: a variable set in `[Code]` through + `SetEnvironmentVariableW` (kernel32) reaches a child started by `Exec`, directly and through + `cmd.exe /c "... > log 2>&1"`, byte for byte, including Cyrillic and `"q" %x% ^&|`; + `PrepareToInstall` returning a string under `/VERYSILENT` exits 7; the `/LOG` file's fourth line + is `Setup command line: ...` with every parameter, and `[Code]` `Exec` command lines are not + logged. +- EDB 14.24 `installer\server\initcluster.ps1` on the development machine: writes the superuser + password with `Set-Content` and no `-Encoding` (`:244`), so in the ANSI code page, for + `initdb --pwfile`, and passes `--encoding=UTF8` (`:260`). The 17.11-5 file is the same apart + from its signature block (task 1, `docs/architecture/installer.md`). +- `internal/provision/create.go:216-236` — an existing role keeps its password unless one is + given; a missing role without a password is an error (`create.go:222-223`). +- `internal/provision/create.go:18-20,257-272` — `CREATE DATABASE` with no options, so a new + database inherits `template1`'s encoding and locale; an existing database is used as is. +- `internal/provision/create.go:138-147,166-189` — an existing `public.trends` is never altered, + only inspected. +- `internal/provision/create.go:191-205` — `--expected-major` mismatch is an error. +- `.github/workflows/release.yml:17-238` — one `ubuntu-latest` job: test, read the tag + (`release.yml:48-122`, outputs `tag`, `version`, `image`, `prerelease`, `newest`, + `make_latest`, `body`), cross-compile both targets (`release.yml:126-153`), build and smoke the + image, create the release with `dist/*`, push the image; the concurrency group is the workflow + (`release.yml:13-15`). `release.yml:198-200` justifies the release-before-push order with + consumers downloading release assets. +- Nothing outside this repository downloads a release asset: the only matches for + `releases/download`, `gh release download` or `semibase__` in SemiPlot and SemiStep are in + `SemiPlot/docs/plans/completed/`; SemiPlot's bench pulls `ghcr.io/semiteq/semibase:latest`. +- `.github/workflows/ci.yml:11-29` — the Windows CI job. +- Verified 2026-10-19 on the development machine: `HKLM\SOFTWARE\PostgreSQL\Installations\ + postgresql-x64-14` holds `Version`, `Data Directory`, `Service ID`, `Super User`; + `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-14` holds `Port` (`0x3c48` = 15432) and + `Locale` (`Russian_Russia.1251`). A second service, `postgresql-14`, runs with no EDB registry + entry. Inno Setup 6 is installed at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`. +- Read 2026-10-19 from `actions/runner-images` `images/windows/Windows2025-Readme.md` + (`windows-latest`): PostgreSQL 17.11, service `postgresql-x64-17`, Stopped, start type + Disabled, user `postgres`, password `root`, `PGDATA=C:\PostgreSQL\17\data`, + `PGBIN=C:\Program Files\PostgreSQL\17\bin`. + +## Development Approach +- **testing approach**: Regular (code first, then tests in the same task) +- complete each task fully before moving to the next +- the installer's tests are end-to-end runs in a dedicated Windows CI job, created in task 3 and + extended by one case per task +- update this plan when scope changes + +## Testing Strategy +- end-to-end: the `installer` CI job builds the setup and runs it silently on the runner, one + case per reachable row of the state tables +- shell: `shellcheck` on the fetch script, in the Linux CI job + +## Acceptance Evidence +All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting +`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per +run; `$psql = "$env:PGBIN\psql.exe"`. +1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` + does not exist; the runner's `postgresql-x64-17` is still Disabled. +2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): + exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads + `semiplot_tags`. +3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and + `HKLM\SOFTWARE\PostgreSQL` does not exist. +4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; + the key is deleted afterwards. +5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit + each time; no `postgresql*` service exists afterwards. +6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains + `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE + pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, + datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN + ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in + with password `semiplot`. +7. The same command again: exit 0; the new site log does not contain `restarting service`. +8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. +9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, + `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case + passes `/LOG`: the Inno log carries Setup's command line by design. +10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is + still present and `semiplot` still logs in. + +## Solution Overview +- All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return + string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in + `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. +- `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to + `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and + the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a + `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid + archive first. The uninstaller removes only `semibase.exe`. +- The installer decides the engine state from the EDB registry (`HKLM64`), never from the service + list alone. `semibase` owns every database-level decision; the installer duplicates none. +- Passwords stay off every child command line and off the disk where the installer controls it: + `[Code]` sets `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and + `SEMIBASE_PLOT_PASSWORD=semiplot` in Setup's own environment with `SetEnvironmentVariableW`, and + `semibase` inherits them. EDB reads its password from an option file under `{tmp}`, deleted as + soon as the EDB process returns. +- What remains exposed: Setup's own command line when the caller passes parameters, which Inno + writes into its log under `/LOG` (`SetupLogging` stays off); the environment block of the + elevated Setup and its children, readable by another administrator during the run; EDB's own + `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a + positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as + `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). +- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes + the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII + superuser password produces a cluster nobody can log into. The superuser password also must + not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the + install fails half-done (`installer.md#password-characters`). Setup's own command-line parser + drops every `"`, so a password parameter whose value does not appear verbatim in + `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable + (`installer.md#inno-setup`). +- The superuser password is required on every run and stored nowhere. +- Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the + convention, so the role has one writer and no site-specific value to lose on an upgrade. + +## Technical Details + +### Engine states (decided in `PrepareToInstall`, first matching row wins) + +| State | Action | +| --- | --- | +| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | +| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | +| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | +| Another major is registered, 17 is not | Refuse: name the version, port and service | +| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | +| Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | +| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | + +- Every refusal message points to the recovery section of `docs/deployment.md`. +- EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, + naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required + code was reproduced, so Setup has no reboot branch. A failed install leaves a partial + registration, which the next run refuses through the second row. +- EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup + never starts EDB without one. +- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to + accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run + through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. + +### Database states (decided by `semibase site`, unchanged) + +| State | Behaviour | +| --- | --- | +| `scada_archive` exists | Used as is (`create.go:263-266`) | +| A role exists, no password given | Password kept (`create.go:216-236`) | +| A role is missing, no password given | Error (`create.go:222-223`); the writer password is needed on a first install only | +| `public.trends` exists | Never altered; `tpdefault` is checked (`create.go:166-189`); a missing grant fails the tail check, and the message names the fix | +| Existing 17 cluster with a locale other than `C` | Used as is: `scada_archive` inherits that cluster's `template1` (`create.go:18-20`). Locale `C` with UTF8 holds for clusters this setup installs | +| Server major is not 17 | Error from `--expected-major 17` (`create.go:197-199`) | + +### Run states + +| State | Behaviour | +| --- | --- | +| Setup re-run | First engine row; `site` is idempotent; the restart runs only for pending settings | +| EDB fails | Refusal naming the EDB log; the partial registration it leaves takes the second engine row on a re-run | +| `site` fails after EDB installed | PostgreSQL stays; a re-run with the same superuser password takes the first engine row | +| Uninstall | Removes `semibase.exe` only; the engine, its data and the roles stay | + +### EDB invocation +- Verified in task 1 (`installer.md#command-line`): `--mode unattended --unattendedmodeui none + --optionfile --disable-components pgAdmin,stackbuilder`, the file holding + `superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and + `datadir={#PgDataDir}`. EDB leaves the option file in place. +- `initcluster.ps1` passes `--encoding=UTF8` and `--locale="C"` to `initdb` in 17.11-5 as well; + the result is `UTF8` with `datcollate` and `datctype` `C`. Case 6 asserts it. +- `{#PgDataDir}` is one `#define`, `C:\Program Files\PostgreSQL\17\data` (EDB's default), used + by the option file and by the data-directory row. + +### `semibase` invocation +- `semibase.exe site --port --superuser --expected-major 17 + --restart-service `, output redirected through `cmd.exe /c` to + `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. +- Before the `Exec`, `[Code]` sets the three variables through + `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. + +### Versions +- `iscc /DAppVersion= /DFileVersion= /DSemibaseExe=`. + `AppVersion` keeps a prerelease suffix; `VersionInfoVersion` takes `FileVersion`, digits and + dots only. `SemibaseExe` names the binary: CI's plain build or the `build` job's + `semibase__windows_amd64.exe`. + +### Payload pinning +- `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, + downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release + both call it, so a retired URL fails on a pull request rather than at tag time. + +### Release workflow shape +- `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both + targets, upload them as an artifact. Its job outputs are every `Read the tag` output. The + Windows binary has one consumer, the `installer` job. +- `installer` (windows, needs `build`): download the artifact, fetch the payload, install Inno + Setup, run `iscc`, upload `SemiBase-Setup.exe`. +- `release` (ubuntu, needs both): download both artifacts, `chmod +x` the Linux binary (artifact + transfer drops file modes, and the `Dockerfile` copies without `--chmod`), build and smoke the + image, create the GitHub release with the Linux binary and the setup in one `action-gh-release` + call, then push the image. The workflow-level concurrency group covers all three jobs. +- The Linux binary stays on the release: it is the published reference that the image carries + the same bytes (`release.yml:146-152`). +- The release still comes before the image push, for a different reason than + `release.yml:198-200` states: `:latest` is the publish consumers act on, and the release page + is its record. A push that lands while the release step fails leaves `:latest` on a version + with no release and no notes, and nobody re-runs a push that worked. + +## What Goes Where +- Implementation Steps: the script, the `.iss`, workflows and docs in this repository. +- Post-Completion: SemiPlot-side changes and checks on an installation machine. + +## Implementation Steps + +### Task 1: Verify the EDB facts the design rests on + +**Files:** +- Create: `docs/architecture/installer.md` + +- [x] run the EDB 17 installer with `--help`; record the flags the design names +- [x] run it unattended on a scratch runner or VM; record its exit codes (success, wrong option, + reboot required), the registry keys it writes, `server_encoding` and `datcollate` of the + result, and whether `install-postgresql.log` contains the password (reboot required was + not reproduced: the runner's Visual C++ runtime is newer than EDB's, so EDB runs no + `vcredist`; recorded as such) +- [x] read the 17 `initcluster.ps1`: confirm the ANSI `Set-Content` of the password and + `--encoding=UTF8` +- [x] record these results and the Inno facts from Context as dated measurements in + `docs/architecture/installer.md`; revise this plan where a result contradicts it + +### Task 2: Pin and fetch the EDB payload + +**Files:** +- Create: `Installer/fetch-postgresql.sh` +- Modify: `.gitignore`, `.github/workflows/ci.yml` + +- [x] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in + `installer.md#the-pinned-payload`), download to + `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches; a + present file that does not match fails the run and is left for the operator to delete, + because a download lands under its final name only after it matched +- [x] add `Installer/payload/` and `Installer/Output/` to `.gitignore` +- [x] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job +- [x] run it twice locally: the second run downloads nothing; a corrupted file fails the run + +### Task 3: Installer skeleton and its CI job + +**Files:** +- Create: `Installer/SemiBase.iss` +- Modify: `.github/workflows/ci.yml` + +- [x] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, + `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required +- [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; + `[Files]` as in Solution Overview +- [x] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` + variable; refuse an empty superuser password in silent mode, any password that is not + printable ASCII, and a superuser password that contains `"` or ends in `\`, from + `PrepareToInstall` +- [x] new CI job `installer` on `windows-latest`: `go build`, cache `Installer/payload/` keyed + on the fetch script's hash, fetch, install Inno Setup via `winget`, `iscc`, Acceptance case 1 +- [x] push and read the CI run - green before task 4 (run 39278350797) + +### Task 4: Engine state detection + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` + among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` +- [ ] map them to the engine-state table; each refusal returns its message from + `PrepareToInstall`, including the silent writer-password guard +- [ ] run once on the development machine (14 registered): Setup refuses and names + `postgresql-x64-14` and port 15432 +- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, + the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the + case after the removal +- [ ] push and read the CI run - green before task 5 + +### Task 5: Provisioning run + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] install EDB through the option file when the state requires it; delete the option file as + soon as the EDB `Exec` returns; handle its exit codes as in Technical Details +- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port +- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the + log; a non-zero exit code is a refusal naming the log +- [ ] extend the CI job with Acceptance case 2 +- [ ] push and read the CI run - green before task 6 + +### Task 6: Password page for an interactive install + +**Files:** +- Modify: `Installer/SemiBase.iss` + +- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the + parameters or variables; not shown under `/SILENT` or `/VERYSILENT` +- [ ] the superuser field is required; the writer field is required when the engine state is the + fresh-install row +- [ ] build and click through once in a VM + +### Task 7: Full CI case list + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended + --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it + starts to exit (the uninstaller itself exits before doing anything, + `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, + `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 +- [ ] add Acceptance cases 4 to 10 in order +- [ ] push and read the CI run - green before task 8 + +### Task 8: Release the setup + +**Files:** +- Modify: `.github/workflows/release.yml` + +- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every + comment whose reasoning still holds, and move each one to the step it explains +- [ ] the `release` job restores the exec bit, then attaches the Linux binary and + `SemiBase-Setup.exe` in one call; the Windows binary is not attached +- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details +- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, + `:latest` did not move, and nothing is published to `ghcr.io/semiteq` + +### Task 9: Verify acceptance criteria +- [ ] every case in Acceptance Evidence passes on the CI run +- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the + Linux job + +### Task 10: [Final] Update documentation +- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment + fallbacks and exit codes, the three state tables in operator terms, the commissioning order + with the setup as step 1; `winget` and the manual restart go +- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running + `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service + ` with the password variables set in the shell; `deployment.md:22` stops pointing + at a bare Windows binary +- [ ] `README.md:28-36` becomes the setup command +- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored + in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index + gain the setup and `installer.md` +- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the + artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux + row's consumer becomes the image build and its provenance +- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 + (`overview.md:56`) becomes the setup +- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the + ordering rationale and "consumers ... still download the release assets" +- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is + updated, and SemiPlot's go to Post-Completion +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion +- SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while + `semibase` defaults to `scada_archive` (`cmd/semibase/main.go:164`). One of them changes before + the suite installer chains both. +- SemiPlot `ConfigFiles/connection/connection.yaml:2` fixes port 5432, while an existing 17 may + sit on another registered port; `connection.yaml:5` ships an empty password, which becomes the + `semiplot` convention. +- SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` + (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and + `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". +- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. +- The suite installer passes the superuser password to every SemiBase run, through + `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade + that may restart the service. From 8d82970a88bf05b5f9ddff25d192d94c5059a969 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:05:37 +0300 Subject: [PATCH 12/35] docs(plans): restore LF line endings in the plan --- docs/plans/20261019-windows-installer.md | 788 +++++++++++------------ 1 file changed, 394 insertions(+), 394 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 7058cac..b50da0b 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -1,394 +1,394 @@ -# Windows installer for SemiBase - -## Overview -- `SemiBase-Setup.exe` (Inno Setup) brings a Windows machine to the commissioned state in one - run: PostgreSQL 17 present, `semibase site` passed, the service restarted when tuning needs it. -- The headless form is the main channel: `SemiBase-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES - /NORESTART /SUPERPASSWORD=... [/WRITERPASSWORD=...]`, exit code 0 means provisioned, anything - else means not. An absent parameter falls back to `SEMIBASE_SUPER_PASSWORD` or - `SEMIBASE_WRITER_PASSWORD` in Setup's environment, so a caller can keep passwords off Setup's - command line. A future suite installer chains it the same way it chains SemiPlot's. -- The `semiplot` password is the convention `semiplot`, and Setup takes no parameter for it. -- The setup carries the EDB PostgreSQL 17 installer and downloads nothing. -- The setup replaces every other commissioning path: the release no longer attaches a bare - Windows binary, and `docs/deployment.md` describes the setup plus one recovery section for the - states the setup refuses. -- Depends on `20261019-site-loopback-and-restart.md` (`--restart-service`) and - `20261019-password-hygiene.md` (passwords from flags and environment only, none in the server - log). Both are satisfied: this branch is stacked on `site-loopback-and-restart` (PR #19), which - is stacked on `password-hygiene` (PR #17). - -## Context (from discovery) -- SemiPlot's installer is the convention source: `SemiPlot/Installer/SemiPlot.iss` (admin - privileges, ru and en languages, `C:\DISTR\...` site folders with `uninsneveruninstall`, - `/VERYSILENT` contract; `SemiPlot.iss:30` sets `VersionInfoVersion`, which accepts digits and - dots only) and its Windows release job `SemiPlot/.github/workflows/release.yml` (Inno Setup via - `winget`, `iscc.exe /DAppVersion=...`). -- `cmd/semibase/main.go:42-46,191-201` — passwords come from flags, then the `SEMIBASE_*` - environment variables, read with no parsing; `main.go:161-173` — the connection flags, - `--superuser` and `--expected-major` among them. -- Verified 2026-10-19 with a scratch setup built by ISCC 6.7.3: a variable set in `[Code]` through - `SetEnvironmentVariableW` (kernel32) reaches a child started by `Exec`, directly and through - `cmd.exe /c "... > log 2>&1"`, byte for byte, including Cyrillic and `"q" %x% ^&|`; - `PrepareToInstall` returning a string under `/VERYSILENT` exits 7; the `/LOG` file's fourth line - is `Setup command line: ...` with every parameter, and `[Code]` `Exec` command lines are not - logged. -- EDB 14.24 `installer\server\initcluster.ps1` on the development machine: writes the superuser - password with `Set-Content` and no `-Encoding` (`:244`), so in the ANSI code page, for - `initdb --pwfile`, and passes `--encoding=UTF8` (`:260`). The 17.11-5 file is the same apart - from its signature block (task 1, `docs/architecture/installer.md`). -- `internal/provision/create.go:216-236` — an existing role keeps its password unless one is - given; a missing role without a password is an error (`create.go:222-223`). -- `internal/provision/create.go:18-20,257-272` — `CREATE DATABASE` with no options, so a new - database inherits `template1`'s encoding and locale; an existing database is used as is. -- `internal/provision/create.go:138-147,166-189` — an existing `public.trends` is never altered, - only inspected. -- `internal/provision/create.go:191-205` — `--expected-major` mismatch is an error. -- `.github/workflows/release.yml:17-238` — one `ubuntu-latest` job: test, read the tag - (`release.yml:48-122`, outputs `tag`, `version`, `image`, `prerelease`, `newest`, - `make_latest`, `body`), cross-compile both targets (`release.yml:126-153`), build and smoke the - image, create the release with `dist/*`, push the image; the concurrency group is the workflow - (`release.yml:13-15`). `release.yml:198-200` justifies the release-before-push order with - consumers downloading release assets. -- Nothing outside this repository downloads a release asset: the only matches for - `releases/download`, `gh release download` or `semibase__` in SemiPlot and SemiStep are in - `SemiPlot/docs/plans/completed/`; SemiPlot's bench pulls `ghcr.io/semiteq/semibase:latest`. -- `.github/workflows/ci.yml:11-29` — the Windows CI job. -- Verified 2026-10-19 on the development machine: `HKLM\SOFTWARE\PostgreSQL\Installations\ - postgresql-x64-14` holds `Version`, `Data Directory`, `Service ID`, `Super User`; - `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-14` holds `Port` (`0x3c48` = 15432) and - `Locale` (`Russian_Russia.1251`). A second service, `postgresql-14`, runs with no EDB registry - entry. Inno Setup 6 is installed at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`. -- Read 2026-10-19 from `actions/runner-images` `images/windows/Windows2025-Readme.md` - (`windows-latest`): PostgreSQL 17.11, service `postgresql-x64-17`, Stopped, start type - Disabled, user `postgres`, password `root`, `PGDATA=C:\PostgreSQL\17\data`, - `PGBIN=C:\Program Files\PostgreSQL\17\bin`. - -## Development Approach -- **testing approach**: Regular (code first, then tests in the same task) -- complete each task fully before moving to the next -- the installer's tests are end-to-end runs in a dedicated Windows CI job, created in task 3 and - extended by one case per task -- update this plan when scope changes - -## Testing Strategy -- end-to-end: the `installer` CI job builds the setup and runs it silently on the runner, one - case per reachable row of the state tables -- shell: `shellcheck` on the fetch script, in the Linux CI job - -## Acceptance Evidence -All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting -`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per -run; `$psql = "$env:PGBIN\psql.exe"`. -1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` - does not exist; the runner's `postgresql-x64-17` is still Disabled. -2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): - exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads - `semiplot_tags`. -3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and - `HKLM\SOFTWARE\PostgreSQL` does not exist. -4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; - the key is deleted afterwards. -5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit - each time; no `postgresql*` service exists afterwards. -6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains - `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE - pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, - datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN - ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in - with password `semiplot`. -7. The same command again: exit 0; the new site log does not contain `restarting service`. -8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. -9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, - `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case - passes `/LOG`: the Inno log carries Setup's command line by design. -10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is - still present and `semiplot` still logs in. - -## Solution Overview -- All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return - string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in - `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. -- `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to - `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and - the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a - `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid - archive first. The uninstaller removes only `semibase.exe`. -- The installer decides the engine state from the EDB registry (`HKLM64`), never from the service - list alone. `semibase` owns every database-level decision; the installer duplicates none. -- Passwords stay off every child command line and off the disk where the installer controls it: - `[Code]` sets `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and - `SEMIBASE_PLOT_PASSWORD=semiplot` in Setup's own environment with `SetEnvironmentVariableW`, and - `semibase` inherits them. EDB reads its password from an option file under `{tmp}`, deleted as - soon as the EDB process returns. -- What remains exposed: Setup's own command line when the caller passes parameters, which Inno - writes into its log under `/LOG` (`SetupLogging` stays off); the environment block of the - elevated Setup and its children, readable by another administrator during the run; EDB's own - `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a - positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as - `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). -- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes - the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII - superuser password produces a cluster nobody can log into. The superuser password also must - not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the - install fails half-done (`installer.md#password-characters`). Setup's own command-line parser - drops every `"`, so a password parameter whose value does not appear verbatim in - `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable - (`installer.md#inno-setup`). -- The superuser password is required on every run and stored nowhere. -- Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the - convention, so the role has one writer and no site-specific value to lose on an upgrade. - -## Technical Details - -### Engine states (decided in `PrepareToInstall`, first matching row wins) - -| State | Action | -| --- | --- | -| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | -| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | -| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | -| Another major is registered, 17 is not | Refuse: name the version, port and service | -| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | -| Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | -| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | - -- Every refusal message points to the recovery section of `docs/deployment.md`. -- EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, - naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required - code was reproduced, so Setup has no reboot branch. A failed install leaves a partial - registration, which the next run refuses through the second row. -- EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup - never starts EDB without one. -- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to - accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run - through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. - -### Database states (decided by `semibase site`, unchanged) - -| State | Behaviour | -| --- | --- | -| `scada_archive` exists | Used as is (`create.go:263-266`) | -| A role exists, no password given | Password kept (`create.go:216-236`) | -| A role is missing, no password given | Error (`create.go:222-223`); the writer password is needed on a first install only | -| `public.trends` exists | Never altered; `tpdefault` is checked (`create.go:166-189`); a missing grant fails the tail check, and the message names the fix | -| Existing 17 cluster with a locale other than `C` | Used as is: `scada_archive` inherits that cluster's `template1` (`create.go:18-20`). Locale `C` with UTF8 holds for clusters this setup installs | -| Server major is not 17 | Error from `--expected-major 17` (`create.go:197-199`) | - -### Run states - -| State | Behaviour | -| --- | --- | -| Setup re-run | First engine row; `site` is idempotent; the restart runs only for pending settings | -| EDB fails | Refusal naming the EDB log; the partial registration it leaves takes the second engine row on a re-run | -| `site` fails after EDB installed | PostgreSQL stays; a re-run with the same superuser password takes the first engine row | -| Uninstall | Removes `semibase.exe` only; the engine, its data and the roles stay | - -### EDB invocation -- Verified in task 1 (`installer.md#command-line`): `--mode unattended --unattendedmodeui none - --optionfile --disable-components pgAdmin,stackbuilder`, the file holding - `superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and - `datadir={#PgDataDir}`. EDB leaves the option file in place. -- `initcluster.ps1` passes `--encoding=UTF8` and `--locale="C"` to `initdb` in 17.11-5 as well; - the result is `UTF8` with `datcollate` and `datctype` `C`. Case 6 asserts it. -- `{#PgDataDir}` is one `#define`, `C:\Program Files\PostgreSQL\17\data` (EDB's default), used - by the option file and by the data-directory row. - -### `semibase` invocation -- `semibase.exe site --port --superuser --expected-major 17 - --restart-service `, output redirected through `cmd.exe /c` to - `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. -- Before the `Exec`, `[Code]` sets the three variables through - `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. - -### Versions -- `iscc /DAppVersion= /DFileVersion= /DSemibaseExe=`. - `AppVersion` keeps a prerelease suffix; `VersionInfoVersion` takes `FileVersion`, digits and - dots only. `SemibaseExe` names the binary: CI's plain build or the `build` job's - `semibase__windows_amd64.exe`. - -### Payload pinning -- `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, - downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release - both call it, so a retired URL fails on a pull request rather than at tag time. - -### Release workflow shape -- `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both - targets, upload them as an artifact. Its job outputs are every `Read the tag` output. The - Windows binary has one consumer, the `installer` job. -- `installer` (windows, needs `build`): download the artifact, fetch the payload, install Inno - Setup, run `iscc`, upload `SemiBase-Setup.exe`. -- `release` (ubuntu, needs both): download both artifacts, `chmod +x` the Linux binary (artifact - transfer drops file modes, and the `Dockerfile` copies without `--chmod`), build and smoke the - image, create the GitHub release with the Linux binary and the setup in one `action-gh-release` - call, then push the image. The workflow-level concurrency group covers all three jobs. -- The Linux binary stays on the release: it is the published reference that the image carries - the same bytes (`release.yml:146-152`). -- The release still comes before the image push, for a different reason than - `release.yml:198-200` states: `:latest` is the publish consumers act on, and the release page - is its record. A push that lands while the release step fails leaves `:latest` on a version - with no release and no notes, and nobody re-runs a push that worked. - -## What Goes Where -- Implementation Steps: the script, the `.iss`, workflows and docs in this repository. -- Post-Completion: SemiPlot-side changes and checks on an installation machine. - -## Implementation Steps - -### Task 1: Verify the EDB facts the design rests on - -**Files:** -- Create: `docs/architecture/installer.md` - -- [x] run the EDB 17 installer with `--help`; record the flags the design names -- [x] run it unattended on a scratch runner or VM; record its exit codes (success, wrong option, - reboot required), the registry keys it writes, `server_encoding` and `datcollate` of the - result, and whether `install-postgresql.log` contains the password (reboot required was - not reproduced: the runner's Visual C++ runtime is newer than EDB's, so EDB runs no - `vcredist`; recorded as such) -- [x] read the 17 `initcluster.ps1`: confirm the ANSI `Set-Content` of the password and - `--encoding=UTF8` -- [x] record these results and the Inno facts from Context as dated measurements in - `docs/architecture/installer.md`; revise this plan where a result contradicts it - -### Task 2: Pin and fetch the EDB payload - -**Files:** -- Create: `Installer/fetch-postgresql.sh` -- Modify: `.gitignore`, `.github/workflows/ci.yml` - -- [x] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in - `installer.md#the-pinned-payload`), download to - `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches; a - present file that does not match fails the run and is left for the operator to delete, - because a download lands under its final name only after it matched -- [x] add `Installer/payload/` and `Installer/Output/` to `.gitignore` -- [x] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job -- [x] run it twice locally: the second run downloads nothing; a corrupted file fails the run - -### Task 3: Installer skeleton and its CI job - -**Files:** -- Create: `Installer/SemiBase.iss` -- Modify: `.github/workflows/ci.yml` - -- [x] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, - `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required -- [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; - `[Files]` as in Solution Overview -- [x] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` - variable; refuse an empty superuser password in silent mode, any password that is not - printable ASCII, and a superuser password that contains `"` or ends in `\`, from - `PrepareToInstall` -- [x] new CI job `installer` on `windows-latest`: `go build`, cache `Installer/payload/` keyed - on the fetch script's hash, fetch, install Inno Setup via `winget`, `iscc`, Acceptance case 1 -- [x] push and read the CI run - green before task 4 (run 39278350797) - -### Task 4: Engine state detection - -**Files:** -- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` - -- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` - among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` -- [ ] map them to the engine-state table; each refusal returns its message from - `PrepareToInstall`, including the silent writer-password guard -- [ ] run once on the development machine (14 registered): Setup refuses and names - `postgresql-x64-14` and port 15432 -- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, - the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the - case after the removal -- [ ] push and read the CI run - green before task 5 - -### Task 5: Provisioning run - -**Files:** -- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` - -- [ ] install EDB through the option file when the state requires it; delete the option file as - soon as the EDB `Exec` returns; handle its exit codes as in Technical Details -- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port -- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the - log; a non-zero exit code is a refusal naming the log -- [ ] extend the CI job with Acceptance case 2 -- [ ] push and read the CI run - green before task 6 - -### Task 6: Password page for an interactive install - -**Files:** -- Modify: `Installer/SemiBase.iss` - -- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the - parameters or variables; not shown under `/SILENT` or `/VERYSILENT` -- [ ] the superuser field is required; the writer field is required when the engine state is the - fresh-install row -- [ ] build and click through once in a VM - -### Task 7: Full CI case list - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended - --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it - starts to exit (the uninstaller itself exits before doing anything, - `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, - `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 -- [ ] add Acceptance cases 4 to 10 in order -- [ ] push and read the CI run - green before task 8 - -### Task 8: Release the setup - -**Files:** -- Modify: `.github/workflows/release.yml` - -- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every - comment whose reasoning still holds, and move each one to the step it explains -- [ ] the `release` job restores the exec bit, then attaches the Linux binary and - `SemiBase-Setup.exe` in one call; the Windows binary is not attached -- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details -- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, - `:latest` did not move, and nothing is published to `ghcr.io/semiteq` - -### Task 9: Verify acceptance criteria -- [ ] every case in Acceptance Evidence passes on the CI run -- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the - Linux job - -### Task 10: [Final] Update documentation -- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment - fallbacks and exit codes, the three state tables in operator terms, the commissioning order - with the setup as step 1; `winget` and the manual restart go -- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running - `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service - ` with the password variables set in the shell; `deployment.md:22` stops pointing - at a bare Windows binary -- [ ] `README.md:28-36` becomes the setup command -- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored - in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index - gain the setup and `installer.md` -- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the - artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux - row's consumer becomes the image build and its provenance -- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 - (`overview.md:56`) becomes the setup -- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the - ordering rationale and "consumers ... still download the release assets" -- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is - updated, and SemiPlot's go to Post-Completion -- [ ] move this plan to `docs/plans/completed/` - -## Post-Completion -- SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while - `semibase` defaults to `scada_archive` (`cmd/semibase/main.go:164`). One of them changes before - the suite installer chains both. -- SemiPlot `ConfigFiles/connection/connection.yaml:2` fixes port 5432, while an existing 17 may - sit on another registered port; `connection.yaml:5` ships an empty password, which becomes the - `semiplot` convention. -- SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` - (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and - `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". -- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. -- The suite installer passes the superuser password to every SemiBase run, through - `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade - that may restart the service. +# Windows installer for SemiBase + +## Overview +- `SemiBase-Setup.exe` (Inno Setup) brings a Windows machine to the commissioned state in one + run: PostgreSQL 17 present, `semibase site` passed, the service restarted when tuning needs it. +- The headless form is the main channel: `SemiBase-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES + /NORESTART /SUPERPASSWORD=... [/WRITERPASSWORD=...]`, exit code 0 means provisioned, anything + else means not. An absent parameter falls back to `SEMIBASE_SUPER_PASSWORD` or + `SEMIBASE_WRITER_PASSWORD` in Setup's environment, so a caller can keep passwords off Setup's + command line. A future suite installer chains it the same way it chains SemiPlot's. +- The `semiplot` password is the convention `semiplot`, and Setup takes no parameter for it. +- The setup carries the EDB PostgreSQL 17 installer and downloads nothing. +- The setup replaces every other commissioning path: the release no longer attaches a bare + Windows binary, and `docs/deployment.md` describes the setup plus one recovery section for the + states the setup refuses. +- Depends on `20261019-site-loopback-and-restart.md` (`--restart-service`) and + `20261019-password-hygiene.md` (passwords from flags and environment only, none in the server + log). Both are satisfied: this branch is stacked on `site-loopback-and-restart` (PR #19), which + is stacked on `password-hygiene` (PR #17). + +## Context (from discovery) +- SemiPlot's installer is the convention source: `SemiPlot/Installer/SemiPlot.iss` (admin + privileges, ru and en languages, `C:\DISTR\...` site folders with `uninsneveruninstall`, + `/VERYSILENT` contract; `SemiPlot.iss:30` sets `VersionInfoVersion`, which accepts digits and + dots only) and its Windows release job `SemiPlot/.github/workflows/release.yml` (Inno Setup via + `winget`, `iscc.exe /DAppVersion=...`). +- `cmd/semibase/main.go:42-46,191-201` — passwords come from flags, then the `SEMIBASE_*` + environment variables, read with no parsing; `main.go:161-173` — the connection flags, + `--superuser` and `--expected-major` among them. +- Verified 2026-10-19 with a scratch setup built by ISCC 6.7.3: a variable set in `[Code]` through + `SetEnvironmentVariableW` (kernel32) reaches a child started by `Exec`, directly and through + `cmd.exe /c "... > log 2>&1"`, byte for byte, including Cyrillic and `"q" %x% ^&|`; + `PrepareToInstall` returning a string under `/VERYSILENT` exits 7; the `/LOG` file's fourth line + is `Setup command line: ...` with every parameter, and `[Code]` `Exec` command lines are not + logged. +- EDB 14.24 `installer\server\initcluster.ps1` on the development machine: writes the superuser + password with `Set-Content` and no `-Encoding` (`:244`), so in the ANSI code page, for + `initdb --pwfile`, and passes `--encoding=UTF8` (`:260`). The 17.11-5 file is the same apart + from its signature block (task 1, `docs/architecture/installer.md`). +- `internal/provision/create.go:216-236` — an existing role keeps its password unless one is + given; a missing role without a password is an error (`create.go:222-223`). +- `internal/provision/create.go:18-20,257-272` — `CREATE DATABASE` with no options, so a new + database inherits `template1`'s encoding and locale; an existing database is used as is. +- `internal/provision/create.go:138-147,166-189` — an existing `public.trends` is never altered, + only inspected. +- `internal/provision/create.go:191-205` — `--expected-major` mismatch is an error. +- `.github/workflows/release.yml:17-238` — one `ubuntu-latest` job: test, read the tag + (`release.yml:48-122`, outputs `tag`, `version`, `image`, `prerelease`, `newest`, + `make_latest`, `body`), cross-compile both targets (`release.yml:126-153`), build and smoke the + image, create the release with `dist/*`, push the image; the concurrency group is the workflow + (`release.yml:13-15`). `release.yml:198-200` justifies the release-before-push order with + consumers downloading release assets. +- Nothing outside this repository downloads a release asset: the only matches for + `releases/download`, `gh release download` or `semibase__` in SemiPlot and SemiStep are in + `SemiPlot/docs/plans/completed/`; SemiPlot's bench pulls `ghcr.io/semiteq/semibase:latest`. +- `.github/workflows/ci.yml:11-29` — the Windows CI job. +- Verified 2026-10-19 on the development machine: `HKLM\SOFTWARE\PostgreSQL\Installations\ + postgresql-x64-14` holds `Version`, `Data Directory`, `Service ID`, `Super User`; + `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-14` holds `Port` (`0x3c48` = 15432) and + `Locale` (`Russian_Russia.1251`). A second service, `postgresql-14`, runs with no EDB registry + entry. Inno Setup 6 is installed at `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`. +- Read 2026-10-19 from `actions/runner-images` `images/windows/Windows2025-Readme.md` + (`windows-latest`): PostgreSQL 17.11, service `postgresql-x64-17`, Stopped, start type + Disabled, user `postgres`, password `root`, `PGDATA=C:\PostgreSQL\17\data`, + `PGBIN=C:\Program Files\PostgreSQL\17\bin`. + +## Development Approach +- **testing approach**: Regular (code first, then tests in the same task) +- complete each task fully before moving to the next +- the installer's tests are end-to-end runs in a dedicated Windows CI job, created in task 3 and + extended by one case per task +- update this plan when scope changes + +## Testing Strategy +- end-to-end: the `installer` CI job builds the setup and runs it silently on the runner, one + case per reachable row of the state tables +- shell: `shellcheck` on the fetch script, in the Linux CI job + +## Acceptance Evidence +All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting +`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per +run; `$psql = "$env:PGBIN\psql.exe"`. +1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` + does not exist; the runner's `postgresql-x64-17` is still Disabled. +2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): + exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads + `semiplot_tags`. +3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and + `HKLM\SOFTWARE\PostgreSQL` does not exist. +4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; + the key is deleted afterwards. +5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit + each time; no `postgresql*` service exists afterwards. +6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains + `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE + pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, + datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN + ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in + with password `semiplot`. +7. The same command again: exit 0; the new site log does not contain `restarting service`. +8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. +9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, + `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case + passes `/LOG`: the Inno log carries Setup's command line by design. +10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is + still present and `semiplot` still logs in. + +## Solution Overview +- All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return + string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in + `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. +- `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to + `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and + the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a + `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid + archive first. The uninstaller removes only `semibase.exe`. +- The installer decides the engine state from the EDB registry (`HKLM64`), never from the service + list alone. `semibase` owns every database-level decision; the installer duplicates none. +- Passwords stay off every child command line and off the disk where the installer controls it: + `[Code]` sets `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and + `SEMIBASE_PLOT_PASSWORD=semiplot` in Setup's own environment with `SetEnvironmentVariableW`, and + `semibase` inherits them. EDB reads its password from an option file under `{tmp}`, deleted as + soon as the EDB process returns. +- What remains exposed: Setup's own command line when the caller passes parameters, which Inno + writes into its log under `/LOG` (`SetupLogging` stays off); the environment block of the + elevated Setup and its children, readable by another administrator during the run; EDB's own + `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a + positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as + `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). +- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes + the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII + superuser password produces a cluster nobody can log into. The superuser password also must + not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the + install fails half-done (`installer.md#password-characters`). Setup's own command-line parser + drops every `"`, so a password parameter whose value does not appear verbatim in + `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable + (`installer.md#inno-setup`). +- The superuser password is required on every run and stored nowhere. +- Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the + convention, so the role has one writer and no site-specific value to lose on an upgrade. + +## Technical Details + +### Engine states (decided in `PrepareToInstall`, first matching row wins) + +| State | Action | +| --- | --- | +| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | +| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | +| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | +| Another major is registered, 17 is not | Refuse: name the version, port and service | +| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | +| Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | +| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | + +- Every refusal message points to the recovery section of `docs/deployment.md`. +- EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, + naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required + code was reproduced, so Setup has no reboot branch. A failed install leaves a partial + registration, which the next run refuses through the second row. +- EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup + never starts EDB without one. +- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to + accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run + through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. + +### Database states (decided by `semibase site`, unchanged) + +| State | Behaviour | +| --- | --- | +| `scada_archive` exists | Used as is (`create.go:263-266`) | +| A role exists, no password given | Password kept (`create.go:216-236`) | +| A role is missing, no password given | Error (`create.go:222-223`); the writer password is needed on a first install only | +| `public.trends` exists | Never altered; `tpdefault` is checked (`create.go:166-189`); a missing grant fails the tail check, and the message names the fix | +| Existing 17 cluster with a locale other than `C` | Used as is: `scada_archive` inherits that cluster's `template1` (`create.go:18-20`). Locale `C` with UTF8 holds for clusters this setup installs | +| Server major is not 17 | Error from `--expected-major 17` (`create.go:197-199`) | + +### Run states + +| State | Behaviour | +| --- | --- | +| Setup re-run | First engine row; `site` is idempotent; the restart runs only for pending settings | +| EDB fails | Refusal naming the EDB log; the partial registration it leaves takes the second engine row on a re-run | +| `site` fails after EDB installed | PostgreSQL stays; a re-run with the same superuser password takes the first engine row | +| Uninstall | Removes `semibase.exe` only; the engine, its data and the roles stay | + +### EDB invocation +- Verified in task 1 (`installer.md#command-line`): `--mode unattended --unattendedmodeui none + --optionfile --disable-components pgAdmin,stackbuilder`, the file holding + `superpassword`, `serverport=5432`, `servicename=postgresql-x64-17`, `locale=C` and + `datadir={#PgDataDir}`. EDB leaves the option file in place. +- `initcluster.ps1` passes `--encoding=UTF8` and `--locale="C"` to `initdb` in 17.11-5 as well; + the result is `UTF8` with `datcollate` and `datctype` `C`. Case 6 asserts it. +- `{#PgDataDir}` is one `#define`, `C:\Program Files\PostgreSQL\17\data` (EDB's default), used + by the option file and by the data-directory row. + +### `semibase` invocation +- `semibase.exe site --port --superuser --expected-major 17 + --restart-service `, output redirected through `cmd.exe /c` to + `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. +- Before the `Exec`, `[Code]` sets the three variables through + `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. + +### Versions +- `iscc /DAppVersion= /DFileVersion= /DSemibaseExe=`. + `AppVersion` keeps a prerelease suffix; `VersionInfoVersion` takes `FileVersion`, digits and + dots only. `SemibaseExe` names the binary: CI's plain build or the `build` job's + `semibase__windows_amd64.exe`. + +### Payload pinning +- `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, + downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release + both call it, so a retired URL fails on a pull request rather than at tag time. + +### Release workflow shape +- `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both + targets, upload them as an artifact. Its job outputs are every `Read the tag` output. The + Windows binary has one consumer, the `installer` job. +- `installer` (windows, needs `build`): download the artifact, fetch the payload, install Inno + Setup, run `iscc`, upload `SemiBase-Setup.exe`. +- `release` (ubuntu, needs both): download both artifacts, `chmod +x` the Linux binary (artifact + transfer drops file modes, and the `Dockerfile` copies without `--chmod`), build and smoke the + image, create the GitHub release with the Linux binary and the setup in one `action-gh-release` + call, then push the image. The workflow-level concurrency group covers all three jobs. +- The Linux binary stays on the release: it is the published reference that the image carries + the same bytes (`release.yml:146-152`). +- The release still comes before the image push, for a different reason than + `release.yml:198-200` states: `:latest` is the publish consumers act on, and the release page + is its record. A push that lands while the release step fails leaves `:latest` on a version + with no release and no notes, and nobody re-runs a push that worked. + +## What Goes Where +- Implementation Steps: the script, the `.iss`, workflows and docs in this repository. +- Post-Completion: SemiPlot-side changes and checks on an installation machine. + +## Implementation Steps + +### Task 1: Verify the EDB facts the design rests on + +**Files:** +- Create: `docs/architecture/installer.md` + +- [x] run the EDB 17 installer with `--help`; record the flags the design names +- [x] run it unattended on a scratch runner or VM; record its exit codes (success, wrong option, + reboot required), the registry keys it writes, `server_encoding` and `datcollate` of the + result, and whether `install-postgresql.log` contains the password (reboot required was + not reproduced: the runner's Visual C++ runtime is newer than EDB's, so EDB runs no + `vcredist`; recorded as such) +- [x] read the 17 `initcluster.ps1`: confirm the ANSI `Set-Content` of the password and + `--encoding=UTF8` +- [x] record these results and the Inno facts from Context as dated measurements in + `docs/architecture/installer.md`; revise this plan where a result contradicts it + +### Task 2: Pin and fetch the EDB payload + +**Files:** +- Create: `Installer/fetch-postgresql.sh` +- Modify: `.gitignore`, `.github/workflows/ci.yml` + +- [x] write the script: `set -euo pipefail`, version, URL and SHA256 constants (17.11-5, values in + `installer.md#the-pinned-payload`), download to + `Installer/payload/`, `sha256sum -c`, skip the download when the file already matches; a + present file that does not match fails the run and is left for the operator to delete, + because a download lands under its final name only after it matched +- [x] add `Installer/payload/` and `Installer/Output/` to `.gitignore` +- [x] add `shellcheck Installer/fetch-postgresql.sh` to the Linux CI job +- [x] run it twice locally: the second run downloads nothing; a corrupted file fails the run + +### Task 3: Installer skeleton and its CI job + +**Files:** +- Create: `Installer/SemiBase.iss` +- Modify: `.github/workflows/ci.yml` + +- [x] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, + `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required +- [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; + `[Files]` as in Solution Overview +- [x] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` + variable; refuse an empty superuser password in silent mode, any password that is not + printable ASCII, and a superuser password that contains `"` or ends in `\`, from + `PrepareToInstall` +- [x] new CI job `installer` on `windows-latest`: `go build`, cache `Installer/payload/` keyed + on the fetch script's hash, fetch, install Inno Setup via `winget`, `iscc`, Acceptance case 1 +- [x] push and read the CI run - green before task 4 (run 39278350797) + +### Task 4: Engine state detection + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` + among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` +- [ ] map them to the engine-state table; each refusal returns its message from + `PrepareToInstall`, including the silent writer-password guard +- [ ] run once on the development machine (14 registered): Setup refuses and names + `postgresql-x64-14` and port 15432 +- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, + the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the + case after the removal +- [ ] push and read the CI run - green before task 5 + +### Task 5: Provisioning run + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [ ] install EDB through the option file when the state requires it; delete the option file as + soon as the EDB `Exec` returns; handle its exit codes as in Technical Details +- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port +- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the + log; a non-zero exit code is a refusal naming the log +- [ ] extend the CI job with Acceptance case 2 +- [ ] push and read the CI run - green before task 6 + +### Task 6: Password page for an interactive install + +**Files:** +- Modify: `Installer/SemiBase.iss` + +- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the + parameters or variables; not shown under `/SILENT` or `/VERYSILENT` +- [ ] the superuser field is required; the writer field is required when the engine state is the + fresh-install row +- [ ] build and click through once in a VM + +### Task 7: Full CI case list + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended + --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it + starts to exit (the uninstaller itself exits before doing anything, + `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, + `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 +- [ ] add Acceptance cases 4 to 10 in order +- [ ] push and read the CI run - green before task 8 + +### Task 8: Release the setup + +**Files:** +- Modify: `.github/workflows/release.yml` + +- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every + comment whose reasoning still holds, and move each one to the step it explains +- [ ] the `release` job restores the exec bit, then attaches the Linux binary and + `SemiBase-Setup.exe` in one call; the Windows binary is not attached +- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details +- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, + `:latest` did not move, and nothing is published to `ghcr.io/semiteq` + +### Task 9: Verify acceptance criteria +- [ ] every case in Acceptance Evidence passes on the CI run +- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the + Linux job + +### Task 10: [Final] Update documentation +- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment + fallbacks and exit codes, the three state tables in operator terms, the commissioning order + with the setup as step 1; `winget` and the manual restart go +- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running + `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service + ` with the password variables set in the shell; `deployment.md:22` stops pointing + at a bare Windows binary +- [ ] `README.md:28-36` becomes the setup command +- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored + in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index + gain the setup and `installer.md` +- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the + artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux + row's consumer becomes the image build and its provenance +- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 + (`overview.md:56`) becomes the setup +- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the + ordering rationale and "consumers ... still download the release assets" +- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is + updated, and SemiPlot's go to Post-Completion +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion +- SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while + `semibase` defaults to `scada_archive` (`cmd/semibase/main.go:164`). One of them changes before + the suite installer chains both. +- SemiPlot `ConfigFiles/connection/connection.yaml:2` fixes port 5432, while an existing 17 may + sit on another registered port; `connection.yaml:5` ships an empty password, which becomes the + `semiplot` convention. +- SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` + (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and + `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". +- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. +- The suite installer passes the superuser password to every SemiBase run, through + `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade + that may restart the service. From d63ec34c2089cb76e9c969ff8ca4bc70e0bc28c6 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:21:18 +0300 Subject: [PATCH 13/35] build(installer): detect the PostgreSQL engine state --- Installer/SemiBase.iss | 196 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 3 deletions(-) diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index 17b165c..e472959 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -21,6 +21,11 @@ #define LogRoot "C:\DISTR\Logs\SemiBase" +; The PostgreSQL 17 install this setup creates: its registry key, port and data directory +#define PgInstallation "postgresql-x64-17" +#define PgPort "5432" +#define PgDataDir "C:\Program Files\PostgreSQL\17\data" + [Setup] AppId={#AppId} AppName={#AppName} @@ -131,15 +136,200 @@ begin 'printable ASCII.'; end; +const + InstallationsKey = 'SOFTWARE\PostgreSQL\Installations'; + ServicesKey = 'SOFTWARE\PostgreSQL\Services'; + RecoveryHint = ' See the recovery section of docs/deployment.md.'; + +type + TEngine = record + Fresh: Boolean; + ServiceName: String; + Port: Cardinal; + SuperUser: String; + end; + +function LogPath(const Prefix: String): String; +begin + Result := '{#LogRoot}\' + Prefix + '-' + GetDateTimeString('yyyymmdd-hhnnss', #0, #0) + '.log'; +end; + +// Under /VERYSILENT the returned refusal reaches no one, and /LOG would record the passwords +procedure LogRefusal(const Message: String); +var + Lines: TArrayOfString; +begin + SetArrayLength(Lines, 1); + Lines[0] := Message; + ForceDirectories('{#LogRoot}'); + SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); +end; + +function ServiceExists(const Name: String): Boolean; +var + ExitCode: Integer; +begin + Result := Exec(ExpandConstant('{sys}\sc.exe'), 'query "' + Name + '"', '', SW_HIDE, + ewWaitUntilTerminated, ExitCode) and (ExitCode = 0); +end; + +// Pascal Script has no sockets. Returns False when the probe itself did not run. +function ProbePort(Port: Cardinal; var Accepts: Boolean): Boolean; +var + ExitCode: Integer; +begin + Result := Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoProfile -NonInteractive -Command "$c = New-Object Net.Sockets.TcpClient; ' + + 'try { $open = $c.ConnectAsync(''127.0.0.1'', ' + IntToStr(Port) + ').Wait(2000) } ' + + 'catch { $open = $false }; $c.Close(); if ($open) { exit 0 } else { exit 2 }"', + '', SW_HIDE, ewWaitUntilTerminated, ExitCode) and ((ExitCode = 0) or (ExitCode = 2)); + Accepts := ExitCode = 0; +end; + +function IsNonEmptyDirectory(const Path: String): Boolean; +var + Entry: TFindRec; +begin + Result := False; + if FindFirst(AddBackslash(Path) + '*', Entry) then + try + repeat + Result := (Entry.Name <> '.') and (Entry.Name <> '..'); + until Result or not FindNext(Entry); + finally + FindClose(Entry); + end; +end; + +// The 17 key exists: it is used only when it is a complete install with a service +function RegisteredEngineRefusal(var Engine: TEngine): String; +var + Key, Version, SuperUser, DataDirectory, BaseDirectory, ServiceName, Missing: String; + Port: Cardinal; +begin + Result := ''; + Key := InstallationsKey + '\{#PgInstallation}'; + RegQueryStringValue(HKLM64, Key, 'Version', Version); + RegQueryStringValue(HKLM64, Key, 'Super User', SuperUser); + RegQueryStringValue(HKLM64, Key, 'Data Directory', DataDirectory); + RegQueryStringValue(HKLM64, Key, 'Service ID', ServiceName); + + if Copy(Version, 1, 3) <> '17.' then + Missing := 'its Version is ''' + Version + '''' + else if SuperUser = '' then + Missing := 'it has no Super User' + else if DataDirectory = '' then + Missing := 'it has no Data Directory' + else if not FileExists(AddBackslash(DataDirectory) + 'PG_VERSION') then + Missing := AddBackslash(DataDirectory) + 'PG_VERSION does not exist'; + if Missing <> '' then + begin + if not RegQueryStringValue(HKLM64, Key, 'Base Directory', BaseDirectory) then + BaseDirectory := ''; + Result := 'HKLM\' + Key + ' is not a complete PostgreSQL 17 installation: ' + Missing + + '. A failed PostgreSQL install leaves this; remove it with ' + BaseDirectory + + '\uninstall-postgresql.exe, then run Setup again.' + RecoveryHint; + Exit; + end; + + if ServiceName = '' then + Result := 'HKLM\' + Key + ' names no service. Setup does not repair an orphaned ' + + 'registration.' + RecoveryHint + else if not ServiceExists(ServiceName) then + Result := 'HKLM\' + Key + ' names service ' + ServiceName + ', which does not exist. Setup ' + + 'does not repair an orphaned registration.' + RecoveryHint + else if not RegQueryDWordValue(HKLM64, ServicesKey + '\' + ServiceName, 'Port', Port) then + Result := 'HKLM\' + ServicesKey + '\' + ServiceName + ' has no Port value. Setup does not ' + + 'repair an orphaned registration.' + RecoveryHint; + if Result <> '' then + Exit; + + Engine.ServiceName := ServiceName; + Engine.Port := Port; + Engine.SuperUser := SuperUser; +end; + +// The 17 key does not exist, and another major may be registered +function OtherMajorRefusal: String; +var + Names: TArrayOfString; + I: Integer; + Version, ServiceName, PortText, Registered: String; + Port: Cardinal; +begin + Result := ''; + if not RegGetSubkeyNames(HKLM64, InstallationsKey, Names) then + Exit; + for I := 0 to GetArrayLength(Names) - 1 do + begin + Version := ''; + ServiceName := ''; + RegQueryStringValue(HKLM64, InstallationsKey + '\' + Names[I], 'Version', Version); + RegQueryStringValue(HKLM64, InstallationsKey + '\' + Names[I], 'Service ID', ServiceName); + if RegQueryDWordValue(HKLM64, ServicesKey + '\' + ServiceName, 'Port', Port) then + PortText := 'port ' + IntToStr(Port) + else + PortText := 'port unknown'; + if Registered <> '' then + Registered := Registered + '; '; + Registered := Registered + 'HKLM\' + InstallationsKey + '\' + Names[I] + ' (version ' + + Version + ', service ' + ServiceName + ', ' + PortText + ')'; + end; + if Registered <> '' then + Result := 'PostgreSQL 17 is not registered, and another major is: ' + Registered + + '. Setup provisions PostgreSQL 17 only.' + RecoveryHint; +end; + +// Nothing is registered: a fresh install must not meet a server or data it does not know +function FreshInstallRefusal(const WriterPassword: String): String; +var + Accepts: Boolean; +begin + Result := ''; + if not ProbePort({#PgPort}, Accepts) then + Result := 'Setup could not probe 127.0.0.1:{#PgPort} through powershell.exe.' + RecoveryHint + else if Accepts then + Result := 'No PostgreSQL is registered, but 127.0.0.1:{#PgPort} accepts TCP. A server the ' + + 'registry does not know is not Setup''s to provision.' + RecoveryHint + else if IsNonEmptyDirectory('{#PgDataDir}') then + Result := 'No PostgreSQL is registered, but {#PgDataDir} exists and is not empty. Setup never ' + + 'overwrites existing data: remove the directory, for example after an interrupted ' + + 'install, then run Setup again.' + RecoveryHint + else if WriterPassword = '' then + Result := 'No scada_writer password: this run installs PostgreSQL 17 and creates the ' + + 'scada_writer role. Pass /WRITERPASSWORD= or set SEMIBASE_WRITER_PASSWORD.'; +end; + +// The first matching state wins. An empty result fills Engine. +function EngineRefusal(const WriterPassword: String; var Engine: TEngine): String; +begin + Engine.Fresh := not RegKeyExists(HKLM64, InstallationsKey + '\{#PgInstallation}'); + if not Engine.Fresh then + Result := RegisteredEngineRefusal(Engine) + else + begin + Result := OtherMajorRefusal; + if Result = '' then + Result := FreshInstallRefusal(WriterPassword); + end; +end; + // A refusal returned here comes before Inno copies a file and exits 7 under /VERYSILENT; one // raised later comes after the uninstaller is registered and may still exit 0. function PrepareToInstall(var NeedsRestart: Boolean): String; +var + SuperPassword, WriterPassword: String; + Engine: TEngine; begin + SuperPassword := ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); + WriterPassword := ResolvePassword('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); Result := ParameterRefusal('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); if Result = '' then Result := ParameterRefusal('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); if Result = '' then - Result := PasswordRefusal( - ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'), - ResolvePassword('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD')); + Result := PasswordRefusal(SuperPassword, WriterPassword); + if Result = '' then + Result := EngineRefusal(WriterPassword, Engine); + if Result <> '' then + LogRefusal(Result); end; From e485c7ea57df0133e24453a76d3dc0e41e73442a Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:21:18 +0300 Subject: [PATCH 14/35] ci(installer): refuse a registered major other than 17 --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 305db86..bb6ee17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -377,6 +377,11 @@ jobs: Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" exit 1 } + $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $log -or (Get-Content -Raw $log.FullName) -notlike '*No superuser password*') { + Write-Host '::error::the setup log does not record the missing superuser password' + exit 1 + } if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { Write-Host '::error::the refused run installed semibase.exe' exit 1 @@ -386,3 +391,65 @@ jobs: Write-Host "::error::the refused run changed $service to start type '$startMode' (want Disabled)" exit 1 } + + # The runner's own 17 registration would match first, so it waits outside + # Installations: an aside key inside it would count as one more major. + - name: Refuse a registered major other than 17 + run: | + $installations = 'HKLM\SOFTWARE\PostgreSQL\Installations' + reg.exe copy "$installations\postgresql-x64-17" 'HKLM\SOFTWARE\SemiBaseCiAside\postgresql-x64-17' /s /f + if ($LASTEXITCODE -ne 0) { exit 1 } + reg.exe delete "$installations\postgresql-x64-17" /f + if ($LASTEXITCODE -ne 0) { exit 1 } + $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14' + New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '14.20-1' | Out-Null + New-ItemProperty -Path $fake.PSPath -Name 'Service ID' -Value 'postgresql-x64-14' | Out-Null + $fakeService = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' + New-ItemProperty -Path $fakeService.PSPath -Name 'Port' -PropertyType DWord -Value 15432 | Out-Null + + $token = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) + $env:SEMIBASE_SUPER_PASSWORD = $token + $started = Get-Date + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + if ($setup.ExitCode -ne 7) { + Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" + exit 1 + } + $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $log -or $log.LastWriteTime -lt $started.AddSeconds(-1)) { + Write-Host '::error::the refused run wrote no setup log' + exit 1 + } + $text = Get-Content -Raw $log.FullName + Write-Host $text + $want = 'Installations\postgresql-x64-14 (version 14.20-1, service postgresql-x64-14, port 15432)' + if (-not $text.Contains($want)) { + Write-Host "::error::the setup log does not name '$want'" + exit 1 + } + if ($text.Contains($token)) { + Write-Host '::error::the setup log contains the superuser password' + exit 1 + } + if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { + Write-Host '::error::the refused run installed semibase.exe' + exit 1 + } + + - name: Restore the runner's PostgreSQL 17 registration + if: always() + run: | + Remove-Item -Recurse -ErrorAction SilentlyContinue -Path ` + 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14', 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' + if (Test-Path 'HKLM:\SOFTWARE\SemiBaseCiAside') { + reg.exe copy 'HKLM\SOFTWARE\SemiBaseCiAside\postgresql-x64-17' 'HKLM\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' /s /f + if ($LASTEXITCODE -ne 0) { exit 1 } + reg.exe delete 'HKLM\SOFTWARE\SemiBaseCiAside' /f + if ($LASTEXITCODE -ne 0) { exit 1 } + } + $restored = Get-ItemProperty -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' + if (-not $restored.'Super User' -or (Test-Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14')) { + Write-Host '::error::the runner''s postgresql-x64-17 registration is not back as it was' + exit 1 + } From 40b2968488f8a10a17ee2b458c2de6e88e9b7368 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:37:56 +0300 Subject: [PATCH 15/35] docs(plans): mark the engine state task done --- docs/plans/20261019-windows-installer.md | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index b50da0b..cf16054 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -81,14 +81,16 @@ All cases run in the `installer` CI job on `windows-latest`, in this order, each `$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per run; `$psql = "$env:PGBIN\psql.exe"`. 1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` - does not exist; the runner's `postgresql-x64-17` is still Disabled. + does not exist; the runner's `postgresql-x64-17` is still Disabled; the newest + `C:\DISTR\Logs\SemiBase\setup-*.log` names the missing superuser password. 2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads `semiplot_tags`. 3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and `HKLM\SOFTWARE\PostgreSQL` does not exist. -4. A fake `Installations\postgresql-x64-14` key: non-zero exit; the message names version 14; - the key is deleted afterwards. +4. A fake `Installations\postgresql-x64-14` key with a `Services` port: non-zero exit; the + newest `setup-*.log` names the key, version 14 and the port, and not the password; the key + is deleted afterwards. 5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit each time; no `postgresql*` service exists afterwards. 6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains @@ -109,6 +111,10 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return string from `PrepareToInstall` ends Setup with exit code 7 under `/VERYSILENT`. Raising in `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. +- Under `/VERYSILENT` the returned refusal text reaches no one, and `/LOG` records Setup's + command line, so `PrepareToInstall` also appends every refusal, with no password in it, to + `C:\DISTR\Logs\SemiBase\setup-.log`, creating the directory itself because + `[Dirs]` has not run yet. - `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a @@ -146,12 +152,12 @@ run; `$psql = "$env:PGBIN\psql.exe"`. | State | Action | | --- | --- | | `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | -| `Installations\postgresql-x64-17` exists without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | -| `Installations\postgresql-x64-17` exists, its service does not | Refuse: name the key; an orphaned registration is not ours to repair | +| `Installations\postgresql-x64-17` exists with a `Version` not starting with `17.`, without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | +| `Installations\postgresql-x64-17` exists, its service or its `Services\\Port` does not | Refuse: name the key; an orphaned registration is not ours to repair | | Another major is registered, 17 is not | Refuse: name the version, port and service | | Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: name the port; a server the registry does not know is not ours to provision | | Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | -| Nothing registered, port free, no data directory | Without a writer password in silent mode: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | +| Nothing registered, port free, no data directory | Without a writer password: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | - Every refusal message points to the recovery section of `docs/deployment.md`. - EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, @@ -289,16 +295,16 @@ run; `$psql = "$env:PGBIN\psql.exe"`. **Files:** - Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` -- [ ] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` +- [x] `[Code]`: read the `HKLM64` EDB keys, `Super User` and `\PG_VERSION` among them, check the service named by `Service ID`, probe the port, check `{#PgDataDir}` -- [ ] map them to the engine-state table; each refusal returns its message from +- [x] map them to the engine-state table; each refusal returns its message from `PrepareToInstall`, including the silent writer-password guard -- [ ] run once on the development machine (14 registered): Setup refuses and names +- [x] run once on the development machine (14 registered): Setup refuses and names `postgresql-x64-14` and port 15432 -- [ ] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, +- [x] extend the CI job with Acceptance case 4; until task 7 removes the runner's PostgreSQL, the case renames the runner's 17 key aside and restores it afterwards, and task 7 moves the case after the removal -- [ ] push and read the CI run - green before task 5 +- [x] push and read the CI run - green before task 5 (run 39284793107) ### Task 5: Provisioning run From cc8c13264eec09c28c0244ef62a46bff4e7b9215 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:47:29 +0300 Subject: [PATCH 16/35] build(installer): install and provision PostgreSQL 17 Until now a usable 17 or a clean machine made Setup copy semibase.exe and exit 0 with nothing provisioned. PrepareToInstall now does the work before a file is copied, so exit 0 means provisioned. On a clean machine it runs the EDB installer with an option file under {tmp}, deleted as soon as EDB returns, and reads the install back from the registry like any registered 17. It then sets the service to Automatic, starts it, waits up to 60 s for the port, and runs semibase site with the passwords in Setup's environment and its output in C:\DISTR\Logs\SemiBase\site-*.log. A non-zero EDB or site exit, or a port that stays closed, is a refusal naming the log. ExtractTemporaryFile matches the DestName of a [Files] entry, measured with a scratch setup and recorded in installer.md. --- Installer/SemiBase.iss | 153 ++++++++++++++++++++++- docs/architecture/installer.md | 9 ++ docs/plans/20261019-windows-installer.md | 7 +- 3 files changed, 162 insertions(+), 7 deletions(-) diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index e472959..09eb2fd 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -21,8 +21,9 @@ #define LogRoot "C:\DISTR\Logs\SemiBase" -; The PostgreSQL 17 install this setup creates: its registry key, port and data directory +; The PostgreSQL 17 install this setup creates: its registry key, service, port and data directory #define PgInstallation "postgresql-x64-17" +#define PgService "postgresql-x64-17" #define PgPort "5432" #define PgDataDir "C:\Program Files\PostgreSQL\17\data" @@ -66,6 +67,11 @@ Source: "{#SemibaseExe}"; DestDir: "{app}"; DestName: "semibase.exe"; Flags: ign Source: "payload\{#PgInstaller}"; Flags: dontcopy nocompression [Code] +function SetEnvironmentVariable(lpName, lpValue: String): BOOL; + external 'SetEnvironmentVariableW@kernel32.dll stdcall'; +function GetTickCount: Cardinal; + external 'GetTickCount@kernel32.dll stdcall'; + function CommandLineParameter(const Name: String): String; var Prefix: String; @@ -140,6 +146,8 @@ const InstallationsKey = 'SOFTWARE\PostgreSQL\Installations'; ServicesKey = 'SOFTWARE\PostgreSQL\Services'; RecoveryHint = ' See the recovery section of docs/deployment.md.'; + PortWaitMilliseconds = 60000; + ErrorServiceAlreadyRunning = 1056; type TEngine = record @@ -165,12 +173,21 @@ begin SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); end; -function ServiceExists(const Name: String): Boolean; +// -1 when sc.exe did not run +function ScExitCode(const Arguments: String): Integer; var ExitCode: Integer; begin - Result := Exec(ExpandConstant('{sys}\sc.exe'), 'query "' + Name + '"', '', SW_HIDE, - ewWaitUntilTerminated, ExitCode) and (ExitCode = 0); + if Exec(ExpandConstant('{sys}\sc.exe'), Arguments, '', SW_HIDE, ewWaitUntilTerminated, + ExitCode) then + Result := ExitCode + else + Result := -1; +end; + +function ServiceExists(const Name: String): Boolean; +begin + Result := ScExitCode('query "' + Name + '"') = 0; end; // Pascal Script has no sockets. Returns False when the probe itself did not run. @@ -314,6 +331,121 @@ begin end; end; +// EDB exits 0 without a superpassword and leaves a cluster nobody can log into, so the option +// file always carries one; EDB leaves the file in place, so Setup deletes it. +function InstallEngineRefusal(const SuperPassword: String): String; +var + Options: TArrayOfString; + OptionFile: String; + Started: Boolean; + ExitCode: Integer; +begin + Result := ''; + ExtractTemporaryFile('{#PgInstaller}'); + OptionFile := ExpandConstant('{tmp}\postgresql-options.txt'); + SetArrayLength(Options, 5); + Options[0] := 'superpassword=' + SuperPassword; + Options[1] := 'serverport={#PgPort}'; + Options[2] := 'servicename={#PgService}'; + Options[3] := 'locale=C'; + Options[4] := 'datadir={#PgDataDir}'; + if not SaveStringsToFile(OptionFile, Options, False) then + begin + Result := 'Setup could not write the PostgreSQL option file into ' + ExpandConstant('{tmp}') + + '.' + RecoveryHint; + Exit; + end; + try + Started := Exec(ExpandConstant('{tmp}\{#PgInstaller}'), '--mode unattended ' + + '--unattendedmodeui none --optionfile "' + OptionFile + '" ' + + '--disable-components pgAdmin,stackbuilder', '', SW_HIDE, ewWaitUntilTerminated, ExitCode); + finally + DeleteFile(OptionFile); + end; + if not Started then + Result := 'Setup could not start the PostgreSQL installer: ' + SysErrorMessage(ExitCode) + + '.' + RecoveryHint + else if ExitCode <> 0 then + Result := 'The PostgreSQL installer exited with code ' + IntToStr(ExitCode) + '; its log is ' + + AddBackslash(GetEnv('TEMP')) + 'install-postgresql.log.' + RecoveryHint; +end; + +// semibase site --restart-service refuses a Disabled or stopped service +function StartServiceRefusal(const ServiceName: String): String; +var + ExitCode: Integer; +begin + Result := ''; + ExitCode := ScExitCode('config "' + ServiceName + '" start= auto'); + if ExitCode <> 0 then + begin + Result := 'sc.exe config ' + ServiceName + ' start= auto exited with code ' + + IntToStr(ExitCode) + '.' + RecoveryHint; + Exit; + end; + ExitCode := ScExitCode('start "' + ServiceName + '"'); + if (ExitCode <> 0) and (ExitCode <> ErrorServiceAlreadyRunning) then + Result := 'sc.exe start ' + ServiceName + ' exited with code ' + IntToStr(ExitCode) + '.' + + RecoveryHint; +end; + +function PortWaitRefusal(const ServiceName: String; Port: Cardinal): String; +var + Started: Cardinal; + Accepts: Boolean; +begin + Result := ''; + Started := GetTickCount; + repeat + if not ProbePort(Port, Accepts) then + begin + Result := 'Setup could not probe 127.0.0.1:' + IntToStr(Port) + ' through powershell.exe.' + + RecoveryHint; + Exit; + end; + if Accepts then + Exit; + Sleep(1000); + until GetTickCount - Started >= PortWaitMilliseconds; + Result := 'Service ' + ServiceName + ' is started, but 127.0.0.1:' + IntToStr(Port) + + ' accepted no TCP connection within 60 s.' + RecoveryHint; +end; + +// semibase inherits the passwords from Setup's environment, so none is on a command line +function SiteRefusal(const Engine: TEngine; const SuperPassword, WriterPassword: String): String; +var + SiteLog: String; + ExitCode: Integer; + Failed: Boolean; +begin + Result := ''; + Failed := False; + if not SetEnvironmentVariable('SEMIBASE_SUPER_PASSWORD', SuperPassword) then + Failed := True; + if not SetEnvironmentVariable('SEMIBASE_PLOT_PASSWORD', 'semiplot') then + Failed := True; + if WriterPassword <> '' then + if not SetEnvironmentVariable('SEMIBASE_WRITER_PASSWORD', WriterPassword) then + Failed := True; + if Failed then + begin + Result := 'Setup could not set the SEMIBASE_* password variables for semibase site.'; + Exit; + end; + ExtractTemporaryFile('semibase.exe'); + ForceDirectories('{#LogRoot}'); + SiteLog := LogPath('site'); + if not Exec(ExpandConstant('{cmd}'), '/s /c ""' + ExpandConstant('{tmp}\semibase.exe') + + '" site --port ' + IntToStr(Engine.Port) + ' --superuser "' + Engine.SuperUser + + '" --expected-major 17 --restart-service "' + Engine.ServiceName + '" > "' + SiteLog + + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then + Result := 'Setup could not start cmd.exe to run semibase site: ' + SysErrorMessage(ExitCode) + + '.' + else if ExitCode <> 0 then + Result := 'semibase site exited with code ' + IntToStr(ExitCode) + '; its log is ' + SiteLog + + '.'; +end; + // A refusal returned here comes before Inno copies a file and exits 7 under /VERYSILENT; one // raised later comes after the uninstaller is registered and may still exit 0. function PrepareToInstall(var NeedsRestart: Boolean): String; @@ -330,6 +462,19 @@ begin Result := PasswordRefusal(SuperPassword, WriterPassword); if Result = '' then Result := EngineRefusal(WriterPassword, Engine); + // The install's own registration, read like any other, fills Engine + if (Result = '') and Engine.Fresh then + begin + Result := InstallEngineRefusal(SuperPassword); + if Result = '' then + Result := RegisteredEngineRefusal(Engine); + end; + if Result = '' then + Result := StartServiceRefusal(Engine.ServiceName); + if Result = '' then + Result := PortWaitRefusal(Engine.ServiceName, Engine.Port); + if Result = '' then + Result := SiteRefusal(Engine, SuperPassword, WriterPassword); if Result <> '' then LogRefusal(Result); end; diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 339047a..5c71a6a 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -190,3 +190,12 @@ Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that h - `GetCmdTail` returns the command line as typed, quotes included, after a leading `/SL5="..."` that the bootstrapper adds. The setup compares the two and refuses a password parameter whose value does not appear in it verbatim. + +Measured 2026-10-19 the same way, with a scratch setup that extracts two text files and refuses: + +- `ExtractTemporaryFile` matches an entry by its `DestName`. For `Source: "source_v1_name.txt"; + DestName: "dest.txt"`, `ExtractTemporaryFile('dest.txt')` puts `{tmp}\dest.txt` in place and + `ExtractTemporaryFile('source_v1_name.txt')` raises `The file "source_v1_name.txt" was not + found`. An entry without `DestName` matches by its source file name. +- It extracts an ordinary `DestDir: "{app}"` entry as well as a `dontcopy` one. So the setup + extracts `semibase.exe` by that name, whatever file `/DSemibaseExe` names. diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index cf16054..2939066 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -83,8 +83,9 @@ run; `$psql = "$env:PGBIN\psql.exe"`. 1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` does not exist; the runner's `postgresql-x64-17` is still Disabled; the newest `C:\DISTR\Logs\SemiBase\setup-*.log` names the missing superuser password. -2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` in the environment, no parameter): - exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads +2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` and a `SEMIBASE_WRITER_PASSWORD` + token in the environment, no parameter; nothing has created `scada_writer` on that cluster + yet): exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads `semiplot_tags`. 3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and `HKLM\SOFTWARE\PostgreSQL` does not exist. @@ -151,7 +152,7 @@ run; `$psql = "$env:PGBIN\psql.exe"`. | State | Action | | --- | --- | -| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; a Disabled service is set to Automatic, a stopped one is started | +| `Installations\postgresql-x64-17`, `Version` starts with `17.`, `Super User` is set, `\PG_VERSION` exists, its `Service ID` names an existing service | Use it, even when other majors are also registered: port from `Services\\Port`, superuser from `Super User`; the service is set to Automatic, a Manual one included, and started | | `Installations\postgresql-x64-17` exists with a `Version` not starting with `17.`, without `Super User` or without `\PG_VERSION` | Refuse: name the key and say that the recovery is `\uninstall-postgresql.exe`; a failed EDB install leaves exactly this (`installer.md#a-failed-install`) | | `Installations\postgresql-x64-17` exists, its service or its `Services\\Port` does not | Refuse: name the key; an orphaned registration is not ours to repair | | Another major is registered, 17 is not | Refuse: name the version, port and service | From 5ff67d97714014611bcea1124a5911397706184a Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:47:30 +0300 Subject: [PATCH 17/35] ci(installer): provision the runner's PostgreSQL 17 Acceptance case 2: Setup with SEMIBASE_SUPER_PASSWORD=root and a writer token in its environment must exit 0, leave postgresql-x64-17 Automatic and let semiplot read semiplot_tags with password semiplot. --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb6ee17..cc10d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,3 +453,44 @@ jobs: Write-Host '::error::the runner''s postgresql-x64-17 registration is not back as it was' exit 1 } + + # The runner's 17 is Disabled and stopped here, and nothing has provisioned + # it, so scada_writer does not exist and its password has to come too. The + # runner image sets a machine-level PGPASSWORD, which the psql check overrides. + - name: Provision the runner's existing PostgreSQL 17 + env: + SEMIBASE_SUPER_PASSWORD: root + run: | + $service = 'postgresql-x64-17' + $psql = "$env:PGBIN\psql.exe" + $env:SEMIBASE_WRITER_PASSWORD = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) + $started = Get-Date + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + # Both logs are password-free, so they are printed whatever the result + Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started.AddSeconds(-1) } | + Sort-Object LastWriteTime | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } + if ($setup.ExitCode -ne 0) { + Write-Host "::error::want exit 0, got $($setup.ExitCode)" + exit 1 + } + if (-not (Get-ChildItem 'C:\DISTR\Logs\SemiBase\site-*.log' | Where-Object { $_.LastWriteTime -ge $started.AddSeconds(-1) })) { + Write-Host '::error::the run wrote no site log' + exit 1 + } + if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { + Write-Host '::error::the run did not install semibase.exe' + exit 1 + } + $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode + if ($startMode -ne 'Auto') { + Write-Host "::error::$service has start type '$startMode' (want Auto)" + exit 1 + } + $env:PGPASSWORD = 'semiplot' + $tags = & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::semiplot with password semiplot cannot read semiplot_tags' + exit 1 + } + Write-Host "semiplot_tags rows: $tags" From 796ae39296bab52cf7b874152ef9b81fafc92fe7 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:50:28 +0300 Subject: [PATCH 18/35] docs(plans): mark the provisioning run task done --- docs/plans/20261019-windows-installer.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 2939066..b12ddfc 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -312,13 +312,14 @@ run; `$psql = "$env:PGBIN\psql.exe"`. **Files:** - Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` -- [ ] install EDB through the option file when the state requires it; delete the option file as - soon as the EDB `Exec` returns; handle its exit codes as in Technical Details -- [ ] set a Disabled service to Automatic, start a stopped one, wait for the port -- [ ] set the three password variables, run `semibase site` as in Technical Details, keep the +- [x] install EDB through the option file when the state requires it; delete the option file as + soon as the EDB `Exec` returns; handle its exit codes as in Technical Details (the EDB path + first runs in CI with case 6, task 7) +- [x] set a Disabled service to Automatic, start a stopped one, wait for the port +- [x] set the three password variables, run `semibase site` as in Technical Details, keep the log; a non-zero exit code is a refusal naming the log -- [ ] extend the CI job with Acceptance case 2 -- [ ] push and read the CI run - green before task 6 +- [x] extend the CI job with Acceptance case 2 +- [x] push and read the CI run - green before task 6 (run 39288473310) ### Task 6: Password page for an interactive install From 62e4fc95e763d078af1796aea22683f2b2d003d8 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:07:31 +0300 Subject: [PATCH 19/35] build(installer): ask for the passwords interactively An interactive Setup had no way to take the superuser and scada_writer passwords: it refused every run that came without a parameter or a SEMIBASE_* variable. A password page now holds both, prefilled from the same resolution, and PrepareToInstall reads its passwords from the page in every mode, so the checks keep one input. Next on the page refuses an empty superuser field, an unprintable or EDB-breaking password, and an empty writer field when no PostgreSQL is registered; PrepareToInstall still enforces each rule. Under /SILENT Setup simulates Next on the page, so the page check returns True there and silent runs refuse exactly as before. --- Installer/SemiBase.iss | 52 ++++++++++++++++++++++-- docs/architecture/installer.md | 11 +++++ docs/plans/20261019-windows-installer.md | 15 +++++-- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index 09eb2fd..4d8d35f 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -317,10 +317,15 @@ begin 'scada_writer role. Pass /WRITERPASSWORD= or set SEMIBASE_WRITER_PASSWORD.'; end; +function EngineIsFresh: Boolean; +begin + Result := not RegKeyExists(HKLM64, InstallationsKey + '\{#PgInstallation}'); +end; + // The first matching state wins. An empty result fills Engine. function EngineRefusal(const WriterPassword: String; var Engine: TEngine): String; begin - Engine.Fresh := not RegKeyExists(HKLM64, InstallationsKey + '\{#PgInstallation}'); + Engine.Fresh := EngineIsFresh; if not Engine.Fresh then Result := RegisteredEngineRefusal(Engine) else @@ -446,6 +451,47 @@ begin '.'; end; +var + PasswordPage: TInputQueryWizardPage; + +// The page holds the resolved passwords in every mode; under /SILENT nothing edits them +procedure InitializeWizard; +begin + PasswordPage := CreateInputQueryPage(wpSelectDir, 'PostgreSQL passwords', + 'Setup provisions PostgreSQL 17 with these passwords.', + 'The superuser password is required on every run. The scada_writer password is required ' + + 'when Setup installs PostgreSQL 17; leave it empty on a server where scada_writer exists ' + + 'to keep its current password.'); + PasswordPage.Add('&Superuser password:', True); + PasswordPage.Add('scada_&writer password:', True); + PasswordPage.Values[0] := ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); + PasswordPage.Values[1] := ResolvePassword('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); +end; + +// PrepareToInstall enforces every rule again; this only lets the user correct a field. A silent +// install simulates Next, and a False here would end it without exit code 7 or a setup log. +function NextButtonClick(CurPageID: Integer): Boolean; +var + Refusal: String; +begin + Result := True; + if WizardSilent or (CurPageID <> PasswordPage.ID) then + Exit; + if PasswordPage.Values[0] = '' then + Refusal := 'Enter the superuser password.' + else + Refusal := PasswordRefusal(PasswordPage.Values[0], PasswordPage.Values[1]); + if (Refusal = '') and (PasswordPage.Values[1] = '') and EngineIsFresh and + (OtherMajorRefusal = '') then + Refusal := 'Enter the scada_writer password: no PostgreSQL 17 is registered, so Setup ' + + 'installs it and creates the scada_writer role.'; + if Refusal <> '' then + begin + MsgBox(Refusal, mbError, MB_OK); + Result := False; + end; +end; + // A refusal returned here comes before Inno copies a file and exits 7 under /VERYSILENT; one // raised later comes after the uninstaller is registered and may still exit 0. function PrepareToInstall(var NeedsRestart: Boolean): String; @@ -453,8 +499,8 @@ var SuperPassword, WriterPassword: String; Engine: TEngine; begin - SuperPassword := ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); - WriterPassword := ResolvePassword('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); + SuperPassword := PasswordPage.Values[0]; + WriterPassword := PasswordPage.Values[1]; Result := ParameterRefusal('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); if Result = '' then Result := ParameterRefusal('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 5c71a6a..05ff037 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -199,3 +199,14 @@ Measured 2026-10-19 the same way, with a scratch setup that extracts two text fi found`. An entry without `DestName` matches by its source file name. - It extracts an ordinary `DestDir: "{app}"` entry as well as a `dontcopy` one. So the setup extracts `semibase.exe` by that name, whatever file `/DSemibaseExe` names. + +Measured 2026-10-19 the same way, with a copy of the setup that has the password page and a +refusal right after the password checks: + +- Under `/SILENT` and `/VERYSILENT` Setup calls `NextButtonClick` for the welcome page, the + custom password page and the Ready page, although it shows none of them. So the page's check + returns `True` under `WizardSilent`; a `False` would end Setup without exit code 7 and without + a setup log. +- A value written to `TInputQueryWizardPage.Values` and read back is unchanged, a tab, CR LF and + Cyrillic included. So `PrepareToInstall` reads the passwords from the page in every mode, and + under `/VERYSILENT` it sees exactly the parameter or `SEMIBASE_*` value. diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index b12ddfc..bd26fe5 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -326,11 +326,13 @@ run; `$psql = "$env:PGBIN\psql.exe"`. **Files:** - Modify: `Installer/SemiBase.iss` -- [ ] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the +- [x] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the parameters or variables; not shown under `/SILENT` or `/VERYSILENT` -- [ ] the superuser field is required; the writer field is required when the engine state is the - fresh-install row -- [ ] build and click through once in a VM +- [x] the superuser field is required; the writer field is required when the engine state is the + fresh-install row (checked on Next from the registry alone: no 17 key and no other major; + `PrepareToInstall` stays the enforcement) +- [x] build and click through once in a VM (skipped - no VM in this run; manual check listed in + Post-Completion) ### Task 7: Full CI case list @@ -397,6 +399,11 @@ run; `$psql = "$env:PGBIN\psql.exe"`. (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". - SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. +- Click through `SemiBase-Setup.exe` once, interactively, in a clean Windows VM: the password page + appears after the folder page; Next with an empty superuser field shows an error and stays; + with no PostgreSQL registered, Next with an empty writer field shows an error and stays; with + both fields typed, Setup installs PostgreSQL 17, provisions it, and `semiplot` logs in with + password `semiplot`. - The suite installer passes the superuser password to every SemiBase run, through `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade that may restart the service. From 2a8accf1c66d89346dc864e9d9309c48ccc3cba6 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:02:56 +0300 Subject: [PATCH 20/35] ci(installer): run the full setup case list --- .github/workflows/ci.yml | 346 ++++++++++++++++++++++++++++++++++----- 1 file changed, 302 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc10d19..ebeee6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,19 +392,106 @@ jobs: exit 1 } - # The runner's own 17 registration would match first, so it waits outside - # Installations: an aside key inside it would count as one more major. + # The runner's 17 is Disabled and stopped here, and nothing has provisioned + # it, so scada_writer does not exist and its password has to come too. The + # runner image sets a machine-level PGPASSWORD, which the psql check overrides. + - name: Provision the runner's existing PostgreSQL 17 + env: + SEMIBASE_SUPER_PASSWORD: root + run: | + $service = 'postgresql-x64-17' + $psql = "$env:PGBIN\psql.exe" + $env:SEMIBASE_WRITER_PASSWORD = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) + $started = Get-Date + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + # Both logs are password-free, so they are printed whatever the result + Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } + if ($setup.ExitCode -ne 0) { + Write-Host "::error::want exit 0, got $($setup.ExitCode)" + exit 1 + } + if (-not (Get-ChildItem 'C:\DISTR\Logs\SemiBase\site-*.log' | Where-Object { $_.LastWriteTime -ge $started })) { + Write-Host '::error::the run wrote no site log' + exit 1 + } + if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { + Write-Host '::error::the run did not install semibase.exe' + exit 1 + } + $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode + if ($startMode -ne 'Auto') { + Write-Host "::error::$service has start type '$startMode' (want Auto)" + exit 1 + } + $env:PGPASSWORD = 'semiplot' + $tags = & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::semiplot with password semiplot cannot read semiplot_tags' + exit 1 + } + Write-Host "semiplot_tags rows: $tags" + + # Not GITHUB_ENV: an exported variable reaches Setup, EDB and initcluster.ps1 by + # inheritance, and the log search would find it there. RUNNER_TEMP is outside + # every directory that search reads. + - name: Generate the case passwords + run: | + $tokens = 1..2 | ForEach-Object { + -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) + } + $tokens | ForEach-Object { Write-Host "::add-mask::$_" } + Set-Content -Path "$env:RUNNER_TEMP\case-passwords.txt" -Value $tokens + + # The remaining cases need a machine with no PostgreSQL. The uninstaller exits + # before it removes anything, and a copy of it in %TEMP% does the work + # (docs/architecture/installer.md#the-uninstaller). + - name: Remove the runner's PostgreSQL + timeout-minutes: 10 + run: | + $base = (Get-ItemProperty 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17').'Base Directory' + $uninstaller = Start-Process -FilePath "$base\uninstall-postgresql.exe" ` + -ArgumentList '--mode', 'unattended', '--unattendedmodeui', 'none' -Wait -PassThru + if ($uninstaller.ExitCode -ne 0) { + Write-Host "::error::uninstall-postgresql.exe exited $($uninstaller.ExitCode)" + exit 1 + } + $deadline = (Get-Date).AddMinutes(5) + while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall*.000' }) { + if ((Get-Date) -gt $deadline) { + Write-Host '::error::the uninstaller copy still runs after 5 minutes' + exit 1 + } + Start-Sleep -Seconds 1 + } + $last = Get-Content "$env:TEMP\uninstall-postgresql.log" | Where-Object { $_.Trim() } | Select-Object -Last 1 + if ("$last" -notlike '*Exiting with code 0*') { + Write-Host "::error::the uninstall log ends with '$last' (want Exiting with code 0)" + exit 1 + } + foreach ($path in 'C:\PostgreSQL\17\data', 'C:\Program Files\PostgreSQL', 'HKLM:\SOFTWARE\PostgreSQL') { + if (Test-Path $path) { Remove-Item -Recurse -Force $path } + } + $services = @(Get-Service postgresql* -ErrorAction SilentlyContinue) + $registered = Test-Path 'HKLM:\SOFTWARE\PostgreSQL' + if ($services.Count -ne 0 -or $registered) { + Write-Host "::error::left behind: $($services.Count) postgresql* services, HKLM\SOFTWARE\PostgreSQL present: $registered" + exit 1 + } + $client = [Net.Sockets.TcpClient]::new() + try { $open = $client.ConnectAsync('127.0.0.1', 5432).Wait(2000) } catch { $open = $false } finally { $client.Close() } + if ($open) { + Write-Host '::error::127.0.0.1:5432 still accepts TCP' + exit 1 + } + - name: Refuse a registered major other than 17 run: | - $installations = 'HKLM\SOFTWARE\PostgreSQL\Installations' - reg.exe copy "$installations\postgresql-x64-17" 'HKLM\SOFTWARE\SemiBaseCiAside\postgresql-x64-17' /s /f - if ($LASTEXITCODE -ne 0) { exit 1 } - reg.exe delete "$installations\postgresql-x64-17" /f - if ($LASTEXITCODE -ne 0) { exit 1 } - $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14' + $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14' -Force New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '14.20-1' | Out-Null New-ItemProperty -Path $fake.PSPath -Name 'Service ID' -Value 'postgresql-x64-14' | Out-Null - $fakeService = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' + $fakeService = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' -Force New-ItemProperty -Path $fakeService.PSPath -Name 'Port' -PropertyType DWord -Value 15432 | Out-Null $token = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) @@ -412,12 +499,15 @@ jobs: $started = Get-Date $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + # The machine had no PostgreSQL key before the fake one + Remove-Item -Recurse -Force 'HKLM:\SOFTWARE\PostgreSQL' if ($setup.ExitCode -ne 7) { Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" exit 1 } - $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 - if (-not $log -or $log.LastWriteTime -lt $started.AddSeconds(-1)) { + $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $log) { Write-Host '::error::the refused run wrote no setup log' exit 1 } @@ -437,60 +527,228 @@ jobs: exit 1 } - - name: Restore the runner's PostgreSQL 17 registration - if: always() + # Start-Process would rebuild the argument string; ProcessStartInfo passes it as + # written, so the " reaches Setup. The password parameter comes last, so an + # unbalanced " swallows no other parameter. LogRefusal appends to a log of the + # same second, so each run reads the last line. + - name: Refuse a non-ASCII password and a password with a quote run: | - Remove-Item -Recurse -ErrorAction SilentlyContinue -Path ` - 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14', 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' - if (Test-Path 'HKLM:\SOFTWARE\SemiBaseCiAside') { - reg.exe copy 'HKLM\SOFTWARE\SemiBaseCiAside\postgresql-x64-17' 'HKLM\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' /s /f - if ($LASTEXITCODE -ne 0) { exit 1 } - reg.exe delete 'HKLM\SOFTWARE\SemiBaseCiAside' /f - if ($LASTEXITCODE -ne 0) { exit 1 } + function Invoke-Refused([string] $arguments, [string] $want) { + $start = [Diagnostics.ProcessStartInfo]::new( + "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe", + "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART $arguments") + $start.UseShellExecute = $false + $started = Get-Date + $setup = [Diagnostics.Process]::Start($start) + $setup.WaitForExit() + if ($setup.ExitCode -ne 7) { + Write-Host "::error::want exit 7 with '$want', got $($setup.ExitCode)" + exit 1 + } + $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime | Select-Object -Last 1 + $last = if ($log) { Get-Content $log.FullName | Select-Object -Last 1 } + Write-Host "refusal: $last" + if (-not "$last".Contains($want)) { + Write-Host "::error::the setup log does not end with '$want'" + exit 1 + } } - $restored = Get-ItemProperty -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' - if (-not $restored.'Super User' -or (Test-Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14')) { - Write-Host '::error::the runner''s postgresql-x64-17 registration is not back as it was' + + Invoke-Refused "/SUPERPASSWORD=Aa1$([char] 0x0436)z" 'The superuser password must be printable ASCII' + Invoke-Refused '/SUPERPASSWORD=Aa1"z' 'does not reach Setup as typed' + $env:SEMIBASE_SUPER_PASSWORD = 'Aa1"z' + Invoke-Refused '' 'The superuser password must not contain "' + Remove-Item Env:SEMIBASE_SUPER_PASSWORD + + if (Get-Service postgresql* -ErrorAction SilentlyContinue) { + Write-Host '::error::a refused run left a postgresql* service' + exit 1 + } + if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { + Write-Host '::error::a refused run installed semibase.exe' exit 1 } - # The runner's 17 is Disabled and stopped here, and nothing has provisioned - # it, so scada_writer does not exist and its password has to come too. The - # runner image sets a machine-level PGPASSWORD, which the psql check overrides. - - name: Provision the runner's existing PostgreSQL 17 - env: - SEMIBASE_SUPER_PASSWORD: root + # The first run of the install path: Setup extracts EDB, installs it through the + # option file, then provisions the new service. The runner image sets a + # machine-level PGPASSWORD, which every psql call here overrides. + - name: Install and provision PostgreSQL 17 on a clean machine + timeout-minutes: 15 run: | - $service = 'postgresql-x64-17' + $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" $psql = "$env:PGBIN\psql.exe" - $env:SEMIBASE_WRITER_PASSWORD = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) $started = Get-Date $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru - # Both logs are password-free, so they are printed whatever the result - Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started.AddSeconds(-1) } | - Sort-Object LastWriteTime | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/SUPERPASSWORD=$super", "/WRITERPASSWORD=$writer" ` + -Wait -PassThru + $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime) + $logs | ForEach-Object { + Write-Host "--- $($_.Name)" + "$(Get-Content -Raw $_.FullName)".Replace($super, '').Replace($writer, '') | Write-Host + } if ($setup.ExitCode -ne 0) { Write-Host "::error::want exit 0, got $($setup.ExitCode)" exit 1 } - if (-not (Get-ChildItem 'C:\DISTR\Logs\SemiBase\site-*.log' | Where-Object { $_.LastWriteTime -ge $started.AddSeconds(-1) })) { + $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 + if (-not $site -or -not "$(Get-Content -Raw $site.FullName)".Contains('restarting service postgresql-x64-17')) { + Write-Host '::error::the site log does not record restarting service postgresql-x64-17' + exit 1 + } + + $env:PGPASSWORD = $super + function Invoke-Super([string] $sql) { + $out = & $psql -w -h 127.0.0.1 -U postgres -d postgres -tAc $sql + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::psql as postgres failed on: $sql" + exit 1 + } + "$out".Trim() + } + $pending = Invoke-Super 'SELECT count(*) FROM pg_settings WHERE pending_restart' + $listen = Invoke-Super 'SHOW listen_addresses' + $databases = Invoke-Super ("SELECT string_agg(concat_ws('|', datname, datcollate, datctype, " + + "pg_encoding_to_char(encoding)), ',' ORDER BY datname) FROM pg_database " + + "WHERE datname IN ('template1', 'scada_archive')") + $wantDatabases = 'scada_archive|C|C|UTF8,template1|C|C|UTF8' + if ($pending -ne '0' -or $listen -ne 'localhost' -or $databases -ne $wantDatabases) { + Write-Host "::error::pending_restart count '$pending' (want 0), listen_addresses '$listen' (want localhost), databases '$databases' (want '$wantDatabases')" + exit 1 + } + $env:PGPASSWORD = 'semiplot' + & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::semiplot with password semiplot cannot log in' + exit 1 + } + + - name: Run the same setup again + timeout-minutes: 15 + run: | + $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" + $started = Get-Date + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/SUPERPASSWORD=$super", "/WRITERPASSWORD=$writer" ` + -Wait -PassThru + $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime) + $logs | ForEach-Object { + Write-Host "--- $($_.Name)" + "$(Get-Content -Raw $_.FullName)".Replace($super, '').Replace($writer, '') | Write-Host + } + if ($setup.ExitCode -ne 0) { + Write-Host "::error::want exit 0, got $($setup.ExitCode)" + exit 1 + } + $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 + if (-not $site) { Write-Host '::error::the run wrote no site log' exit 1 } - if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { - Write-Host '::error::the run did not install semibase.exe' + if ("$(Get-Content -Raw $site.FullName)".Contains('restarting service')) { + Write-Host '::error::the second run restarted the service with nothing pending' exit 1 } - $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode - if ($startMode -ne 'Auto') { - Write-Host "::error::$service has start type '$startMode' (want Auto)" + + - name: Refuse a wrong superuser password + run: | + $started = Get-Date + $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SUPERPASSWORD=wrong' -Wait -PassThru + $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | + Sort-Object LastWriteTime) + $logs | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } + if ($setup.ExitCode -ne 7) { + Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" + exit 1 + } + $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 + if (-not $site -or -not "$(Get-Content -Raw $site.FullName)".Contains('password authentication failed')) { + Write-Host '::error::the site log does not name the failed superuser connection' + exit 1 + } + + # No case passes /LOG: Inno's own log records Setup's command line, passwords + # included. The search prints file names and hit counts, never a matched line. + - name: Find no password in the logs + run: | + $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" + $edbLog = "$env:TEMP\install-postgresql.log" + $temp = Get-ChildItem -LiteralPath $env:TEMP -Force | Where-Object { + $_.Name -like '*postgres*' -or $_.Name -like 'installbuilder*' -or $_.Name -like 'bitrock*' -or $_.Name -like 'is-*.tmp' + } + $semibaseLogs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log') + $serverLogs = @(Get-ChildItem 'C:\Program Files\PostgreSQL\17\data\log' -File -Force) + if (-not (Test-Path $edbLog) -or $semibaseLogs.Count -eq 0 -or $serverLogs.Count -eq 0) { + Write-Host "::error::nothing to search: $edbLog present $(Test-Path $edbLog), $($semibaseLogs.Count) SemiBase logs, $($serverLogs.Count) server logs" + exit 1 + } + $files = @($temp | Where-Object { -not $_.PSIsContainer }) + + @($temp | Where-Object { $_.PSIsContainer } | Get-ChildItem -Recurse -File -Force) + + $semibaseLogs + $serverLogs + $files | ForEach-Object { Write-Host "searched: $($_.FullName)" } + $hits = @(Select-String -LiteralPath $files.FullName -SimpleMatch -Pattern $super, $writer) + if ($hits.Count -ne 0) { + $hits | ForEach-Object { Write-Host "password found in $($_.Path):$($_.LineNumber)" } + Write-Host "::error::$($hits.Count) password hits" + exit 1 + } + $optionFiles = @(Get-ChildItem -LiteralPath $env:TEMP -Recurse -Force -Filter 'postgresql-options.txt' -ErrorAction SilentlyContinue) + if ($optionFiles.Count -ne 0) { + Write-Host "::error::option file left: $($optionFiles.FullName -join ', ')" + exit 1 + } + # Case 6 passes parameters, so this tells whether EDB's log would carry an + # inherited SEMIBASE_* variable at all + Write-Host "install-postgresql.log records the environment: $(Select-String -LiteralPath $edbLog -SimpleMatch -Pattern 'RUNNER_TEMP' -Quiet)" + + # Inno's uninstaller re-runs itself from %TEMP%, so semibase.exe may outlive + # the process Start-Process waited for. + - name: Uninstall SemiBase and keep PostgreSQL + run: | + $app = "$env:ProgramFiles\SemiBase" + $uninstall = Start-Process -FilePath "$app\unins000.exe" ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + if ($uninstall.ExitCode -ne 0) { + Write-Host "::error::want exit 0, got $($uninstall.ExitCode)" + exit 1 + } + $deadline = (Get-Date).AddSeconds(60) + while (Test-Path "$app\semibase.exe") { + if ((Get-Date) -gt $deadline) { + Write-Host '::error::semibase.exe is still there 60 s after the uninstaller exited' + exit 1 + } + Start-Sleep -Seconds 1 + } + if (-not (Get-Service postgresql-x64-17 -ErrorAction SilentlyContinue)) { + Write-Host '::error::the uninstall removed postgresql-x64-17' exit 1 } $env:PGPASSWORD = 'semiplot' - $tags = & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' + & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' if ($LASTEXITCODE -ne 0) { - Write-Host '::error::semiplot with password semiplot cannot read semiplot_tags' + Write-Host '::error::semiplot with password semiplot cannot log in after the uninstall' exit 1 } - Write-Host "semiplot_tags rows: $tags" + + # Everything printed here passes the redaction first: the EDB log masks the + # superuser password itself, and the setup and site logs carry none by design. + - name: Print the installer diagnostics + if: failure() + run: | + $ErrorActionPreference = 'Continue' + $tokens = @(Get-Content "$env:RUNNER_TEMP\case-passwords.txt" -ErrorAction SilentlyContinue) + function Write-Redacted([string] $text) { + foreach ($t in $tokens) { $text = $text.Replace($t, '') } + Write-Host $text + } + Get-Service postgresql* | Format-Table Name, Status, StartType | Out-String | Write-Host + Write-Redacted ((reg.exe query HKLM\SOFTWARE\PostgreSQL /s 2>&1) -join "`n") + Get-ChildItem "$env:TEMP\install-postgresql.log", "$env:TEMP\installbuilder_installer_*.log" -ErrorAction SilentlyContinue | + ForEach-Object { Write-Host "--- $($_.Name), last 200 lines"; Write-Redacted ((Get-Content $_.FullName -Tail 200) -join "`n") } + Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | + ForEach-Object { Write-Host "--- $($_.Name)"; Write-Redacted "$(Get-Content -Raw $_.FullName)" } + exit 0 From 5c4b2e37318feed88959434fc06254ea6057e1d8 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:06:46 +0300 Subject: [PATCH 21/35] ci(installer): drop the stale semibase.exe checks --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebeee6c..081b625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -522,10 +522,6 @@ jobs: Write-Host '::error::the setup log contains the superuser password' exit 1 } - if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { - Write-Host '::error::the refused run installed semibase.exe' - exit 1 - } # Start-Process would rebuild the argument string; ProcessStartInfo passes it as # written, so the " reaches Setup. The password parameter comes last, so an @@ -565,10 +561,6 @@ jobs: Write-Host '::error::a refused run left a postgresql* service' exit 1 } - if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { - Write-Host '::error::a refused run installed semibase.exe' - exit 1 - } # The first run of the install path: Setup extracts EDB, installs it through the # option file, then provisions the new service. The runner image sets a From 704245c8884bb51dde2dc90c264a3f5d56650bd9 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:11:02 +0300 Subject: [PATCH 22/35] docs(plans): mark the full CI case list task done --- docs/plans/20261019-windows-installer.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index bd26fe5..2d18555 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -339,13 +339,13 @@ run; `$psql = "$env:PGBIN\psql.exe"`. **Files:** - Modify: `.github/workflows/ci.yml` -- [ ] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended +- [x] remove the runner's PostgreSQL after case 2: `uninstall-postgresql.exe --mode unattended --unattendedmodeui none`, then wait for the `%TEMP%\_uninstall*\_uninstall*.000` copy it starts to exit (the uninstaller itself exits before doing anything, `installer.md#the-uninstaller`), delete `C:\PostgreSQL\17\data`, `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL`; assert Acceptance case 3 -- [ ] add Acceptance cases 4 to 10 in order -- [ ] push and read the CI run - green before task 8 +- [x] add Acceptance cases 4 to 10 in order +- [x] push and read the CI run - green before task 8 (run 39299830501) ### Task 8: Release the setup From b20a9f8dff2a42e957f4a9752907591c1d932253 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:17:29 +0300 Subject: [PATCH 23/35] ci(release): attach the setup to the release The setup replaces the bare Windows binary as the commissioning path, and Inno Setup needs a Windows runner while the image needs Linux. The one release job becomes build, installer and release: the Windows binary only feeds the setup, and the release page carries the Linux binary and SemiBase-Setup.exe. "Latest" is still decided once in the build job, and the image is still built and smoke-run before the release and pushed after it. --- .github/workflows/release.yml | 166 ++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 605538e..828ff00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,19 @@ concurrency: cancel-in-progress: false jobs: - release: - name: Build & Release + build: + name: Build runs-on: ubuntu-latest permissions: - contents: write - packages: write + contents: read + outputs: + tag: ${{ steps.tag.outputs.tag }} + version: ${{ steps.tag.outputs.version }} + image: ${{ steps.tag.outputs.image }} + prerelease: ${{ steps.tag.outputs.prerelease }} + newest: ${{ steps.tag.outputs.newest }} + make_latest: ${{ steps.tag.outputs.make_latest }} + body: ${{ steps.tag.outputs.body }} steps: # fetch-depth: 0 is load-bearing twice over. At a finite depth @@ -142,23 +149,124 @@ jobs: # no extension on linux - an ELF is executable by its mode bit build windows amd64 .exe build linux amd64 "" + ls -l dist + + - name: Upload the binaries + uses: actions/upload-artifact@v7 + with: + name: binaries + path: dist/ + if-no-files-found: error + + installer: + name: Build the setup + needs: build + runs-on: windows-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download the binaries + uses: actions/download-artifact@v8 + with: + name: binaries + path: dist + + - name: Cache the EDB payload + uses: actions/cache@v6 + with: + path: Installer/payload + key: edb-payload-${{ hashFiles('Installer/fetch-postgresql.sh') }} + + # shell: bash is Git Bash; a bare bash on the PATH may be WSL's + - name: Fetch the EDB payload + shell: bash + run: bash Installer/fetch-postgresql.sh + + # The jrsoftware.org direct download intermittently serves a corrupted file, so winget + # (preinstalled on windows runners) installs it instead. Machine scope keeps the + # Program Files (x86) path the next step expects. + - name: Install Inno Setup + run: | + winget install --id JRSoftware.InnoSetup --exact --scope machine --silent ` + --accept-source-agreements --accept-package-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { exit 1 } + "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # VersionInfoVersion takes digits and dots only, so a prerelease suffix + # reaches AppVersion alone + - name: Build the setup + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + $fileVersion = $env:VERSION.Split('-')[0] + iscc "/DAppVersion=$env:VERSION" "/DFileVersion=$fileVersion" ` + "/DSemibaseExe=$env:GITHUB_WORKSPACE\dist\semibase_$($env:VERSION)_windows_amd64.exe" ` + Installer\SemiBase.iss + if ($LASTEXITCODE -ne 0) { exit 1 } + + - name: Upload the setup + uses: actions/upload-artifact@v7 + with: + name: setup + path: Installer/Output/SemiBase-Setup.exe + if-no-files-found: error + + release: + name: Release + needs: [build, installer] + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false - # The image gets a copy of the linux artifact under the plain name the - # Dockerfile expects, in a directory of its own: the release attaches - # dist/*, and an unversioned duplicate in there would be attached too. - # A copy rather than a second build, so the bytes in the image and the - # bytes on the release page are provably the same binary. + - name: Download the binaries + uses: actions/download-artifact@v8 + with: + name: binaries + path: dist + + - name: Download the setup + uses: actions/download-artifact@v8 + with: + name: setup + path: setup + + # Artifact transfer drops file modes, and the Dockerfile copies without + # --chmod, so the bit is restored before the copy inherits it. + # + # The image gets a copy of the linux artifact under the plain name the + # Dockerfile expects, in a directory of its own, so the build context + # carries that one file and nothing else reaches the daemon. A copy + # rather than a second build, so the bytes in the image and the bytes on + # the release page are provably the same binary. + - name: Stage the linux binary for the image + env: + VERSION: ${{ needs.build.outputs.version }} + shell: bash + run: | + set -euo pipefail + chmod +x "dist/semibase_${VERSION}_linux_amd64" mkdir -p image cp "dist/semibase_${VERSION}_linux_amd64" image/semibase - ls -l dist image + ls -l dist setup image # Built before the release exists, pushed after it. The build is the half # that fails on a bad copy or a bad Dockerfile, and it has to fail while # there is still nothing published to withdraw. - name: Build the container image env: - TAG: ${{ steps.tag.outputs.tag }} - IMAGE: ${{ steps.tag.outputs.image }} + TAG: ${{ needs.build.outputs.tag }} + IMAGE: ${{ needs.build.outputs.image }} shell: bash run: | set -euo pipefail @@ -184,31 +292,37 @@ jobs: # Pinned to a commit, not a tag: this is the one third-party action that # runs with the job's contents: write token, so a moved tag in its # repository would be a write-capable supply chain change. + # + # The Windows binary is not attached: the setup carries it. - name: Create GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - tag_name: ${{ steps.tag.outputs.tag }} - name: ${{ steps.tag.outputs.tag }} - body: ${{ steps.tag.outputs.body }} + tag_name: ${{ needs.build.outputs.tag }} + name: ${{ needs.build.outputs.tag }} + body: ${{ needs.build.outputs.body }} draft: false - prerelease: ${{ steps.tag.outputs.prerelease }} - make_latest: ${{ steps.tag.outputs.make_latest }} - files: dist/* + prerelease: ${{ needs.build.outputs.prerelease }} + make_latest: ${{ needs.build.outputs.make_latest }} + fail_on_unmatched_files: true + files: | + dist/semibase_${{ needs.build.outputs.version }}_linux_amd64 + setup/SemiBase-Setup.exe - # After the release, never before: SemiPlot's bench tracks :latest and its - # CI still downloads the release assets, so an image published first would - # leave a window where :latest names a version whose assets do not exist. + # After the release, never before: :latest is the publish consumers act + # on, and the release page is its record. A push that lands while the + # release step fails leaves :latest on a version with no release and no + # notes, and nobody re-runs a push that worked. # # docker login rather than docker/login-action: the action would be a # third-party step holding a write-capable token, and the CLI needs no # such trust for one command. - name: Publish the container image env: - TAG: ${{ steps.tag.outputs.tag }} - PRERELEASE: ${{ steps.tag.outputs.prerelease }} - NEWEST: ${{ steps.tag.outputs.newest }} - MOVE_LATEST: ${{ steps.tag.outputs.make_latest }} - IMAGE: ${{ steps.tag.outputs.image }} + TAG: ${{ needs.build.outputs.tag }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + NEWEST: ${{ needs.build.outputs.newest }} + MOVE_LATEST: ${{ needs.build.outputs.make_latest }} + IMAGE: ${{ needs.build.outputs.image }} REGISTRY_USER: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash From 2d305727892a70e57a1948235a61c029f716bd98 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:23:16 +0300 Subject: [PATCH 24/35] docs(plans): mark the release setup task done --- docs/plans/20261019-windows-installer.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 2d18555..069aa61 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -352,13 +352,15 @@ run; `$psql = "$env:PGBIN\psql.exe"`. **Files:** - Modify: `.github/workflows/release.yml` -- [ ] split the job into `build`, `installer`, `release` as in Technical Details; keep every +- [x] split the job into `build`, `installer`, `release` as in Technical Details; keep every comment whose reasoning still holds, and move each one to the step it explains -- [ ] the `release` job restores the exec bit, then attaches the Linux binary and +- [x] the `release` job restores the exec bit, then attaches the Linux binary and `SemiBase-Setup.exe` in one call; the Windows binary is not attached -- [ ] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details -- [ ] push a prerelease tag on a fork of the repository: the release page has two assets, - `:latest` did not move, and nothing is published to `ghcr.io/semiteq` +- [x] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details +- [x] push a prerelease tag on a fork of the repository: the release page has two assets, + `:latest` did not move, and nothing is published to `ghcr.io/semiteq` (skipped - tags, + forks and releases are outside this run's git permission; moved to Post-Completion; + actionlint 1.7.12 is clean on `release.yml`) ### Task 9: Verify acceptance criteria - [ ] every case in Acceptance Evidence passes on the CI run @@ -404,6 +406,11 @@ run; `$psql = "$env:PGBIN\psql.exe"`. with no PostgreSQL registered, Next with an empty writer field shows an error and stays; with both fields typed, Setup installs PostgreSQL 17, provisions it, and `semiplot` logs in with password `semiplot`. +- Push a prerelease tag such as `v0.0.0-rc.1` on a fork of the repository and read the run: + `build`, `installer` and `release` pass, the release page is marked prerelease and has two + assets, `semibase__linux_amd64` and `SemiBase-Setup.exe`, the fork's + `ghcr.io//semibase` has the version tag only, its `:latest` did not move, and + nothing is published to `ghcr.io/semiteq`. - The suite installer passes the superuser password to every SemiBase run, through `SEMIBASE_SUPER_PASSWORD` rather than the command line, and stops the SCADA before an upgrade that may restart the service. From f0baae326e1ec94a1243cdef891c2b4626afaff3 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:28:39 +0300 Subject: [PATCH 25/35] docs(plans): mark the acceptance check task done --- docs/plans/20261019-windows-installer.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 069aa61..5d530fc 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -363,9 +363,10 @@ run; `$psql = "$env:PGBIN\psql.exe"`. actionlint 1.7.12 is clean on `release.yml`) ### Task 9: Verify acceptance criteria -- [ ] every case in Acceptance Evidence passes on the CI run -- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the - Linux job +- [x] every case in Acceptance Evidence passes on the CI run (run 39302087408 on 2d30572: build, + linux and installer green, cases 1 to 10 each asserted by its own installer step) +- [x] `go test ./...`, `go vet ./...`, `golangci-lint run` are clean; `shellcheck` passes in the + Linux job (run 39302087408; locally golangci-lint 2.14.0, CI runs the pinned 2.12.2) ### Task 10: [Final] Update documentation - [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment From 61daac6e2f0dc7a668556cbe14c2427a89e550c0 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:50 +0300 Subject: [PATCH 26/35] docs(installer): document the setup and its recovery The setup replaced winget, the bare Windows binary and the manual restart, but every doc still described them. deployment.md now gives the silent command line with Start-Process -Wait, the parameters, the SEMIBASE_* fallbacks, exit codes 0 and 7, the setup and site logs, the three state tables in operator terms, and the recovery section every engine refusal points to, including the manual semibase.exe run. README, CLAUDE.md, the architecture index, provisioning.md and overview.md follow the three-job release and the two release assets; installer.md gains what the setup does. --- CLAUDE.md | 66 +++- README.md | 20 +- docs/architecture/README.md | 6 +- docs/architecture/installer.md | 77 +++- docs/architecture/overview.md | 41 ++- docs/architecture/provisioning.md | 31 +- docs/deployment.md | 451 ++++++++++++++++------- docs/plans/20261019-windows-installer.md | 25 +- docs/readme.md | 4 +- 9 files changed, 508 insertions(+), 213 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e89ecf8..061b068 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,11 @@ SemiBase is the deployable PostgreSQL service for the semiconductor-tools installation. It provisions and configures the instance that hosts the Simple-Scada 2 archive: the SCADA writes -`trends`/`messages` into it, SemiPlot and future tools read from it. The deliverable is one -CLI binary, `semibase.exe`, a container image carrying its Linux build, plus the instance's -architecture docs. +`trends`/`messages` into it, SemiPlot and future tools read from it. The deliverables are a +Windows setup, `SemiBase-Setup.exe` (Inno Setup, `Installer/`), which installs the vendored EDB +PostgreSQL 17 when none is registered and runs `semibase site`; the CLI binary `semibase.exe` it +carries; a container image carrying the binary's Linux build; and the instance's architecture +docs. Deployment target: Windows. Language: Go (module `github.com/Semiteq/SemiBase`), driver `pgx/v5`, the five `sql/semiplot_*.sql` files and `sql/trends.sql` embedded via `go:embed`. Entry point: `cmd/semibase`. The module is pure Go (`pgx/v5`, plus `golang.org/x/sys` for the Windows service @@ -21,20 +23,38 @@ go build -ldflags "-X main.revision=" -o semibase.exe ./cmd/semibase # re Without ldflags, `version` falls back to the VCS revision recorded by the Go toolchain. +The setup needs Inno Setup 6 (`winget install --id JRSoftware.InnoSetup --exact`) and the pinned +EDB installer, which the fetch script downloads and checks against its SHA256: + +```powershell +bash Installer/fetch-postgresql.sh # Git Bash's bash, not WSL's; into Installer/payload/ +iscc /DAppVersion=0.0.0-dev /DFileVersion=0.0.0 "/DSemibaseExe=$PWD\semibase.exe" Installer\SemiBase.iss +``` + +The output is `Installer/Output/SemiBase-Setup.exe`. Under Git Bash, prefix `iscc` with +`MSYS2_ARG_CONV_EXCL='*'`, or MSYS rewrites the `/D` arguments as paths. + ## Release -`.github/workflows/release.yml` fires on a `v*` tag push. One `ubuntu-latest` job -cross-compiles `windows/amd64` and `linux/amd64` with `CGO_ENABLED=0` — the module is pure -Go, so native runners are CI's job, not the release's — and attaches -`semibase___amd64[.exe]` to the GitHub release — no extension on the Linux -artifact, an ELF is executable by its mode bit. `-X main.revision=` is embedded, so -`semibase version` prints the tag. +`.github/workflows/release.yml` fires on a `v*` tag push and runs three jobs under one +workflow-level concurrency group. `build` (`ubuntu-latest`) tests, reads the tag and +cross-compiles `windows/amd64` and `linux/amd64` with `CGO_ENABLED=0` — the module is pure Go, +so native runners are CI's job, not the release's — into `semibase___amd64[.exe]`; +no extension on the Linux artifact, an ELF is executable by its mode bit. `-X +main.revision=` is embedded, so `semibase version` prints the tag. `installer` +(`windows-latest`) fetches the EDB payload, installs Inno Setup through `winget` and compiles +`SemiBase-Setup.exe` around the Windows binary (`/DAppVersion=`, `/DFileVersion=` the +part before the first `-`). `release` (`ubuntu-latest`) restores the Linux binary's exec bit, +which artifact transfer drops, and attaches two assets, the Linux binary and the setup; the +Windows binary is not attached, the setup carries it. -The same job then pushes `ghcr.io/semiteq/semibase:latest` and `:vX.Y.Z` — the root +`release` then pushes `ghcr.io/semiteq/semibase:latest` and `:vX.Y.Z` — the root `Dockerfile`, `FROM scratch`, a copy of the Linux artifact as the only file. The image is built and smoke-run before the release is created, so a bad `Dockerfile` aborts with nothing -published, and pushed after it, since consumers track `:latest` and still download the release -assets. "Latest" is one decision made once in the `Read the tag` step — the tag has no `-` +published, and pushed after it: `:latest` is the publish consumers act on and the release page +is its record, so a push that lands while the release step fails would leave `:latest` on a +version with no release and no notes. "Latest" is one decision made once in `build`'s +`Read the tag` step and read by `release` through job outputs — the tag has no `-` suffix **and** it is the newest release tag in the repository (`git tag -l --sort=-v:refname` over plain `vN.N.N` tags) — and both the release page's `prerelease`/`make_latest` and the `:latest` push read it, so they cannot disagree. A prerelease publishes its version tag alone, @@ -115,10 +135,25 @@ requires the passwords absent. The `listen_addresses`, the pens and the 17 log s on the runner; to run them locally, point `psql` at a container. The Windows step uses the runner's own `$env:PGBIN\psql.exe`. +The `installer` job (`windows-latest`) builds `semibase.exe`, fetches the EDB payload (cached on +the fetch script's hash), installs Inno Setup and compiles the setup; the Linux job shellchecks +the fetch script. It then runs the setup silently through `Start-Process -Wait -PassThru`, since +the call operator does not wait for a GUI exe, and asserts each exit code, ten cases in order: +no superuser password refuses with 7, installs no `semibase.exe` and leaves the runner's service +Disabled; the runner's own 17 provisions with exit 0, Automatic, and `semiplot`/`semiplot` reads +`semiplot_tags`; the runner's PostgreSQL is uninstalled and its remains deleted; a fake +`postgresql-x64-14` key refuses, the setup log naming key, version, service and port and not the +password; a Cyrillic password and a `"` one refuse; a clean machine installs EDB 17 and +provisions it (`restarting service` in the site log, nothing pending, `listen_addresses` +`localhost`, `template1` and `scada_archive` `C`/`C`/`UTF8`); a second run restarts nothing; a +wrong superuser password refuses with `password authentication failed` in the site log; neither +generated password appears in any EDB, setup, site or server log; the uninstaller removes +`semibase.exe` and keeps PostgreSQL. No case passes `/LOG`, which records Setup's command line. + Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), table-driven. There are no database-touching tests in the repository; the integration checks are -the `semiplot` access check both commands run at the tail and the eight CI database steps above, -against a live server. +the `semiplot` access check both commands run at the tail, the eight CI database steps above and +the `installer` job's cases, against a live server. ## Format @@ -204,6 +239,9 @@ internal/provision/ one file per phase behind the two commands: config.go (ALTE sql/ embedded DDL: the five semiplot_* files (ours: the tables, the pen-registration function, the time markers, the version stamp) and trends (the vendor's shape) +Installer/ SemiBase.iss (the Inno Setup script: engine states, EDB install, the site + run, all in PrepareToInstall) and fetch-postgresql.sh (the pinned EDB + payload into the git-ignored payload/; ISCC writes Output/) docs/ human docs in Russian (enter at docs/readme.md) docs/architecture/ agent-facing design docs in English docs/plans/ dated implementation plans diff --git a/README.md b/README.md index 2b6a7d7..d5c4922 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,22 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус | DB | PostgreSQL 17; минимально поддерживаемая — 14 | | Writer | Simple-Scada 2 с архивацией в PostgreSQL | -Утилита развёртывания — один исполняемый файл, среда исполнения не требуется. +Установщик `SemiBase-Setup.exe` — один файл на странице +[Releases](https://github.com/Semiteq/SemiBase/releases/latest). Он несёт PostgreSQL 17 и ничего не +скачивает. ## Быстрое развёртывание ```powershell -# установка pgsql -winget install --id PostgreSQL.PostgreSQL.17 --exact - -# дальше всё в PowerShell от имени администратора: утилита перезапускает службу PostgreSQL +# всё в PowerShell от имени администратора: установщик ставит PostgreSQL и управляет его службой # пароли ролей: каждая строка запрашивает пароль, в историю PowerShell он не попадает $env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password $env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password -$env:SEMIBASE_PLOT_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль semiplot' -AsSecureString)).Password -# конфигурация сервера, архивная база, роли `scada_writer`, `semiplot` и -# `semiplot_registrar`, права, таблица архива, пять таблиц SemiPlot и функция -# регистрации новых перьев; перезапуск службы, если настройки его ждут -.\semibase.exe site --restart-service postgresql-x64-17 +# PostgreSQL 17, если его нет; конфигурация сервера, архивная база, роли `scada_writer`, +# `semiplot` (пароль `semiplot`) и `semiplot_registrar`, права, таблица архива, пять таблиц +# SemiPlot и функция регистрации новых перьев; перезапуск службы, если настройки его ждут. +# код 0: машина готова; код 7: отказ, причина в C:\DISTR\Logs\SemiBase\setup-*.log +$setup = Start-Process -FilePath .\SemiBase-Setup.exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru +$setup.ExitCode ``` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9b053c8..7251488 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,12 +11,14 @@ Declarative architecture docs (English, present tense). These describe the syste - [provisioning.md](./provisioning.md) — the tool's two commands, idempotency rules, the four roles, the SemiPlot configuration schema, the access chain, and the stated assumption about the SCADA meeting an existing `trends`. +- [installer.md](./installer.md) — what `SemiBase-Setup.exe` does, and the dated EDB and Inno + Setup measurements it rests on. ## Locked decisions | Area | Decision | | --- | --- | -| Installation | PostgreSQL via `winget`, major version pinned, automatic upgrade disabled; engine install stays outside the tool | +| Installation | EDB PostgreSQL 17 vendored in the setup, pinned by SHA256 (`Installer/fetch-postgresql.sh`); the setup installs the engine, `semibase.exe` never does | | Major version | 17 for new installs, pinned latest; floor 14 (SemiPlot needs `date_bin`; Simple-Scada 2 docs require 14+) | | Provisioning tool | Go single binary `semibase.exe` (`pgx/v5`, `golang.org/x/sys` for the Windows service, embedded SQL); no runtime or `psql` on the target machine | | Instance configuration | Fixed `ALTER SYSTEM` deltas applied by `semibase site` via `pg_reload_conf()`, never by editing `postgresql.conf`; `shared_buffers` and `listen_addresses` take effect at the next service restart, reported through `pg_settings.pending_restart`; `site --restart-service ` performs that restart on Windows, opt-in | @@ -28,7 +30,7 @@ Declarative architecture docs (English, present tense). These describe the syste | Objects we add | `semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_markers`, `semiplot_meta`, the `semiplot_register_new_pens()` function the viewer calls, and the vendor-shaped `public.trends` — no triggers, scheduled jobs, extensions, or other functions; `messages` is not created, the SCADA makes it | | SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. When the rule starts, and what a schema change does before then: [provisioning.md](./provisioning.md#the-schema-version-is-a-floor) | | Archive schema | Owned by Simple-Scada 2 and documented in the SemiPlot repository. This tool creates `public.trends` once, with the vendor's shape, connected as `scada_writer`, and never alters it afterwards; day partitions and every later change to the schema remain the vendor's | -| Distribution | Binaries as GitHub release assets; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it | +| Distribution | Two GitHub release assets: `SemiBase-Setup.exe`, which carries the Windows binary, and the Linux binary; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it | | Backup method | `UNDECIDED` — commissioning-time, needs the customer's archive size | | Retention depth / disk sizing | `UNDECIDED` — needs a measured write rate from a working installation | diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 05ff037..fb8d9fd 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -1,12 +1,75 @@ # Installer -The facts about the EDB PostgreSQL 17 installer and Inno Setup that the Windows setup rests on. -Each one is a dated measurement. The EDB runs happened on GitHub's `windows-latest` runner -(Windows Server 2025, build 26100, ANSI code page 1252) on 2026-10-19, in a throwaway CI job: the -probe script is in commits `6f6413e`, `0b7eebd`, `e837805` and `4649439`, its logs in CI runs -39268177188, 39269744425, 39270947396 and 39273096839. Every install ran on a machine whose -earlier PostgreSQL had been uninstalled, its uninstaller process waited for, and its service, -`C:\PostgreSQL`, `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL` deleted. +What `SemiBase-Setup.exe` does, then the facts about the EDB PostgreSQL 17 installer and Inno +Setup that it rests on. Each fact is a dated measurement. The EDB runs happened on GitHub's +`windows-latest` runner (Windows Server 2025, build 26100, ANSI code page 1252) on 2026-10-19, in +a throwaway CI job: the probe script is in commits `6f6413e`, `0b7eebd`, `e837805` and `4649439`, +its logs in CI runs 39268177188, 39269744425, 39270947396 and 39273096839. Every install ran on a +machine whose earlier PostgreSQL had been uninstalled, its uninstaller process waited for, and +its service, `C:\PostgreSQL`, `C:\Program Files\PostgreSQL` and `HKLM\SOFTWARE\PostgreSQL` +deleted. + +## The setup + +`Installer/SemiBase.iss` compiles to `SemiBase-Setup.exe` with `iscc /DAppVersion= +/DFileVersion= /DSemibaseExe=`, around the EDB installer that +`Installer/fetch-postgresql.sh` puts into `Installer/payload/`. The main channel is headless: +`/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`, the passwords from `/SUPERPASSWORD` and +`/WRITERPASSWORD`, else from `SEMIBASE_SUPER_PASSWORD` and `SEMIBASE_WRITER_PASSWORD`. Exit code 0 +means provisioned. + +All of the work runs in `PrepareToInstall`, before Inno copies a file. A string it returns ends +Setup with exit code 7 under `/VERYSILENT`; a failure raised later comes after the uninstaller is +registered and may still exit 0. Under `/VERYSILENT` the returned text reaches no one, and `/LOG` +would record the command line, so every refusal is also appended, password-free, to +`C:\DISTR\Logs\SemiBase\setup-.log`. The refusals about the engine end with +`See the recovery section of docs/deployment.md.` In order, `PrepareToInstall` checks that each +password parameter arrived as typed (`GetCmdTail`), checks the password characters, decides the +engine state, installs EDB on the fresh row, runs `sc.exe config start= auto` and +`sc.exe start`, waits up to 60 s for `127.0.0.1:` to accept TCP (a PowerShell `TcpClient` +probe through `Exec`, since Pascal Script has no sockets), and runs `semibase.exe site --port + --superuser --expected-major 17 --restart-service ` through +`cmd.exe /c` into `site-.log` beside the setup log. A non-zero exit refuses, +naming that log. + +The engine state comes from the `HKLM64` EDB keys, never from the service list alone; the first +matching row wins: + +| State | Action | +| --- | --- | +| `Installations\postgresql-x64-17` with a `Version` starting `17.`, a `Super User`, `\PG_VERSION`, and a `Service ID` naming an existing service with a `Services\\Port` | Use it, even when other majors are registered too | +| `Installations\postgresql-x64-17` with another `Version`, no `Super User` or no `PG_VERSION` | Refuse, naming `\uninstall-postgresql.exe`: a [failed install](#a-failed-install) leaves exactly this | +| `Installations\postgresql-x64-17` whose service or `Port` does not exist | Refuse: an orphaned registration is not the setup's to repair | +| Another major registered, 17 not | Refuse, naming each key's version, service and port | +| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: a server the registry does not know is not the setup's to provision | +| Nothing registered, `C:\Program Files\PostgreSQL\17\data` not empty | Refuse: existing data is never overwritten | +| Nothing registered, port free, no data directory | Refuse without a writer password; otherwise install EDB with the option file under [command line](#command-line), then re-read the new registration as in the first row | + +Any non-zero EDB exit refuses, naming `%TEMP%\install-postgresql.log`; there is no reboot branch, +since no reboot code was reproduced ([exit codes](#exit-codes)). Every database-level decision +belongs to `semibase site`, and the setup duplicates none of them. + +Passwords stay off every child command line the setup controls. `[Code]` sets +`SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_PLOT_PASSWORD=semiplot` and, when given, +`SEMIBASE_WRITER_PASSWORD` in Setup's own environment through `SetEnvironmentVariableW`, after EDB +has run, and `semibase` inherits them. EDB reads its password from `{tmp}\postgresql-options.txt`, +deleted as soon as the EDB process returns. What stays exposed: Setup's own command line when the +caller passes the parameters, which Inno writes into its log under `/LOG` (`SetupLogging` stays +off); Setup's environment block, readable by another administrator during the run; and EDB's +`initcluster.ps1` command line and pwfile ([where the superuser password +goes](#where-the-superuser-password-goes)). Both passwords must be printable ASCII, and the +superuser password must not contain `"` or end in `\` ([password +characters](#password-characters)); a password with `"` reaches Setup only through its variable +([Inno Setup](#inno-setup)). Every run sets the `semiplot` password to `semiplot`, the convention +SemiPlot's connection file relies on, so the role has one writer. + +Without `/SILENT` or `/VERYSILENT`, a password page after the folder page holds both fields, +prefilled the same way; its Next check only lets the user correct a field, and `PrepareToInstall` +stays the enforcement. `semibase.exe` has one `[Files]` entry into `{app}`, and +`ExtractTemporaryFile` takes it to `{tmp}` for the run. So the `{app}` copy, the recovery tool, +exists only after a successful run. The uninstaller removes that copy alone; the engine, its +data, the roles and `C:\DISTR\Logs\SemiBase` stay. The `installer` job in +`.github/workflows/ci.yml` runs the setup end to end on `windows-latest`. ## The pinned payload diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index d5f47c0..8878fef 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -39,6 +39,7 @@ time markers. Both are what operators entered, and they can enter them again; th | `cmd/semibase`, `internal/provision` | `semibase.exe` — idempotent provisioning in two commands, `site` and `bench`: instance tuning (`site` only), database, roles, grants, the five SemiPlot tables, `semiplot_register_new_pens()`, `public.trends`, and the `semiplot` access checks that end every run | | `sql/semiplot_tags.sql`, `sql/semiplot_register.sql`, `sql/semiplot_groups.sql`, `sql/semiplot_markers.sql`, `sql/semiplot_meta.sql` | DDL for the objects we invented — the pen catalogue, the function that registers new pens, the groups and their membership, the time markers, and the schema version — embedded into the binary | | `sql/trends.sql` | The vendor's archive-table shape, transcribed and embedded; applied under `SET ROLE scada_writer`, so the table's owner is the writer | +| `Installer/` | `SemiBase-Setup.exe` (Inno Setup, `SemiBase.iss`) — installs the vendored EDB PostgreSQL 17 when none is registered, then runs `semibase site --restart-service`; `fetch-postgresql.sh` downloads the pinned EDB installer ([installer.md](./installer.md)) | | `Dockerfile` | `ghcr.io/semiteq/semibase` — the Linux binary alone on `scratch`, for consumers that layer it into a bench image | | `docs/architecture/` | The instance as it is: configuration deltas, provisioning order, ownership | @@ -53,29 +54,29 @@ silently stop. The order matters, because the default privileges have to be in place before any table `scada_writer` owns is created. -1. Install the engine: `winget install --id PostgreSQL.PostgreSQL.17 --exact`. -2. `semibase site` — tuning, then the archive database, the three created roles, default privileges, - `public.trends`, the five SemiPlot tables and `semiplot_register_new_pens()`; it ends by proving `semiplot` reads `trends`, writes - what it owns, cannot add, delete or re-key a pen and cannot write the archive, and warns while `shared_buffers` or - `listen_addresses` waits for a service restart. With `--restart-service ` it restarts that Windows service itself - and continues against the restarted server (`provisioning.md#the-service-restart`). -3. Without `--restart-service`, restart the PostgreSQL service or reboot the machine, so `shared_buffers` and - `listen_addresses` take effect. -4. Point the Simple-Scada project at the database on `localhost` or `127.0.0.1`, since the server listens on loopback - only, and start it once. It is expected to find - `trends` in place, write into it, and create the day partitions and `messages` itself — an - **unverified** assumption, with the experiment that settles it, in - `provisioning.md` ("Assumption: the SCADA meeting an existing `trends`"). Set - `log_statement = 'all'` for that first start and read back the DDL the SCADA issued. -5. Start the viewer. Pens appear in `semiplot_tags` on their own, one per variable the SCADA has +1. Run `SemiBase-Setup.exe` ([installer.md](./installer.md)). It installs PostgreSQL 17 when none + is registered, sets the service to Automatic and starts it, then runs `semibase site + --restart-service `: tuning, then the archive database, the three created roles, + default privileges, `public.trends`, the five SemiPlot tables and + `semiplot_register_new_pens()`. The restart puts `shared_buffers` and `listen_addresses` into + effect (`provisioning.md#the-service-restart`), and the run ends by proving `semiplot` reads + `trends`, writes what it owns, cannot add, delete or re-key a pen and cannot write the archive. + Exit code 0 means all of it holds. +2. Point the Simple-Scada project at the database on `localhost` or `127.0.0.1`, since the + server listens on loopback only, and start it once. It is expected to find `trends` in place, + write into it, and create the day partitions and `messages` itself — an **unverified** + assumption, with the experiment that settles it, in `provisioning.md` ("Assumption: the SCADA + meeting an existing `trends`"). Set `log_statement = 'all'` for that first start and read + back the DDL the SCADA issued. +3. Start the viewer. Pens appear in `semiplot_tags` on their own, one per variable the SCADA has written to `trends`, with default settings the viewer's editor then changes. A person never inserts a pen, and only the superuser can delete one. -6. Write the SemiPlot connection file: host `127.0.0.1`, the `semiplot` role and its password. The - file names no time zone: the viewer runs on the SCADA machine and reads the archive in that - machine's zone. +4. Write the SemiPlot connection file: host `127.0.0.1`, the `semiplot` role and its password, + `semiplot` by the setup's convention. The file names no time zone: the viewer runs on the + SCADA machine and reads the archive in that machine's zone. -A bench replaces step 2 with `semibase bench` and has no step 3 or 4: the consumer's seeder plays -the writer's part. +A bench replaces step 1 with `semibase bench` against its own container and has no step 2: the +consumer's seeder plays the writer's part. Every consumer must survive every intermediate state of this sequence — no database, empty `trends`, `trends` without `semiplot_tags`, `semiplot_tags` without rows. Each is a normal diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 2bb2a0b..0f7266f 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -9,9 +9,11 @@ The binary is self-contained: `pgx/v5` speaks the wire protocol itself, over TCP socket, so neither `psql.exe` nor any runtime needs to be present on the target machine. The SQL is embedded at build time: `sql/trends.sql` for the archive table, and `sql/semiplot_tags.sql`, `sql/semiplot_register.sql`, `sql/semiplot_groups.sql`, -`sql/semiplot_markers.sql` and `sql/semiplot_meta.sql` for the SemiPlot configuration schema. Installing the PostgreSQL -engine itself is one documented `winget` line, deliberately outside the tool — OS package management -is not database provisioning. +`sql/semiplot_markers.sql` and `sql/semiplot_meta.sql` for the SemiPlot configuration schema. +Installing the PostgreSQL engine stays outside the tool, deliberately: OS package management is +not database provisioning. On an installation machine `SemiBase-Setup.exe` does both, installing +the vendored EDB PostgreSQL 17 when none is registered and then running `semibase site` +([installer.md](./installer.md)). ## Distribution @@ -20,10 +22,15 @@ A tagged release carries three artifacts, all built from one commit by | Artifact | Form | Consumer | | --- | --- | --- | -| `semibase__windows_amd64.exe` | GitHub release asset | Commissioning on the installation machine | -| `semibase__linux_amd64` | GitHub release asset | Provisioning a bench from a Linux host or CI runner | +| `SemiBase-Setup.exe` | GitHub release asset | Commissioning on the installation machine; it carries the Windows binary, which is not attached on its own | +| `semibase__linux_amd64` | GitHub release asset | The published reference for the image's bytes; provisioning a bench from a Linux host or CI runner | | `ghcr.io/semiteq/semibase:latest` and `:vX.Y.Z` | Container image | Bench images that layer the binary onto `postgres:17-alpine` | +The workflow has three jobs. `build` (ubuntu) tests, reads the tag and cross-compiles both +binaries; `installer` (windows) compiles the setup around the Windows one; `release` (ubuntu) +builds the image, creates the GitHub release with the Linux binary and the setup, and pushes the +image. One workflow-level concurrency group covers all three. + The image is `FROM scratch` and holds one file, `/semibase` — the same bytes as the Linux release asset, copied rather than built a second time. It carries no shell, no libc and no CA certificates, because nothing runs it as a base: the consumer copies the binary out @@ -46,8 +53,9 @@ pair nobody runs. The `vX.Y.Z` tag is immutable and exists to place blame and to failing pair. "Latest" only ever moves forward, and that is enforced rather than assumed. One decision, made -once in the release job, drives all of it: the tag is a release tag (no `-` suffix) **and** it is -the newest release tag in the repository. That answer sets the GitHub release's `prerelease` and +once in the `build` job's `Read the tag` step and read by the `release` job through job outputs, +drives all of it: the tag is a release tag (no `-` suffix) **and** it is the newest release tag +in the repository. That answer sets the GitHub release's `prerelease` and `make_latest` fields and gates the `:latest` push alike, so `/releases/latest` and `:latest` cannot name different versions. Without the newest test, re-running an old tag's workflow from the Actions UI, or pushing two tags at once, walks "latest" backwards with no signal to anyone @@ -61,10 +69,11 @@ prereleases out of the comparison, and with them git's own placement of `v1.0.0- of it to mean anything; `fetch-depth: 0` is what puts them there, and the job fails loudly if the tag being released is not among them. -The image is pushed after the GitHub release exists, never before: consumers still download the -release assets, so `latest` must never name a version whose assets are missing. The image is -*built* before it, because a bad copy or a bad `Dockerfile` has to abort while there is still -nothing published to withdraw. +The image is pushed after the GitHub release exists, never before: `:latest` is the publish +consumers act on, and the release page is its record. A push that lands while the release step +fails leaves `:latest` on a version with no release and no notes, and nobody re-runs a push that +worked. The image is *built* before it, because a bad copy or a bad `Dockerfile` has to abort +while there is still nothing published to withdraw. The GHCR package is public, because consumers pull it anonymously from their own CI. The first push creates it private; that is switched once, in the package's settings, and the workflow has diff --git a/docs/deployment.md b/docs/deployment.md index ec25351..056dde4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,16 +1,15 @@ # Развёртывание -SemiBase разворачивается одной утилитой — `semibase.exe`. Это скрипт установки: одна команда -приводит экземпляр в заданное состояние и завершается. Утилита идемпотентна: каждый шаг сначала -проверяет состояние, затем создаёт недостающее. Повторный запуск безопасен. Пароли существующих -ролей меняются только при явной передаче соответствующего флага или переменной окружения. +SemiBase разворачивается установщиком `SemiBase-Setup.exe`. Один запуск приводит машину в рабочее +состояние: ставит PostgreSQL 17, если его нет, настраивает сервер, создаёт архивную базу, роли, +права и таблицы, перезапускает службу PostgreSQL, когда настройки этого ждут, и проверяет доступ +роли `semiplot`. Повторный запуск безопасен: каждый шаг сначала проверяет состояние, затем создаёт +недостающее. -Справка: - -```powershell -.\semibase.exe --help # список команд -.\semibase.exe site --help # флаги конкретной команды -``` +Работу с базой установщик поручает утилите `semibase.exe` и кладёт её копию в +`C:\Program Files\SemiBase`. Вручную утилиту запускают для +[восстановления](#восстановление-после-отказа-установщика), когда установщик отказывается +работать, и на тестовых стендах. ## Требования @@ -19,15 +18,291 @@ SemiBase разворачивается одной утилитой — `semibas | СУБД | PostgreSQL 17; минимально поддерживаемая — 14 | | Писатель | Simple-Scada 2 с архивацией в PostgreSQL | -Утилита — один исполняемый файл. Готовые сборки для Windows и Linux (x86-64) находятся на странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest). +На странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest) у каждого выпуска два +файла: + +| Файл | Назначение | +| ------------------------------- | -------------------------------------------------------------------------------------------- | +| `SemiBase-Setup.exe` | Установщик для Windows x86-64. Несёт установщик PostgreSQL 17.11 от EDB и ничего не скачивает | +| `semibase_<версия>_linux_amd64` | Утилита для Linux, для тестовых стендов смежных проектов | + +Каждый выпуск также публикует образ контейнера `ghcr.io/semiteq/semibase` с той же Linux-сборкой. +Он нужен только тестовым стендам смежных проектов. При пусконаладке ни образ, ни Linux-сборка не +используются. + +Сборка утилиты из исходников: `go build -o semibase.exe ./cmd/semibase`. + +## Установщик + +### Запуск без окон + +Это основной способ. Установщик запускают в PowerShell от имени администратора: он ставит +PostgreSQL и управляет его службой. Пароли передают переменными окружения: + +```powershell +$env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password +$env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password +$setup = Start-Process -FilePath .\SemiBase-Setup.exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru +$setup.ExitCode +``` + +Каждая строка `Read-Host` запрашивает пароль, и в историю команд PowerShell попадает только сама +строка, без пароля. Переменные живут, пока окно открыто. Установщик — оконная программа, поэтому +без `Start-Process -Wait` PowerShell не ждёт её завершения и не получает код возврата. + +### Параметры и пароли + +| Параметр | Переменная окружения | Назначение | +| -------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `/SUPERPASSWORD=<пароль>` | `SEMIBASE_SUPER_PASSWORD` | Пароль суперпользователя PostgreSQL. Нужен при каждом запуске, установщик его нигде не хранит. При новой установке PostgreSQL он становится паролем `postgres` | +| `/WRITERPASSWORD=<пароль>` | `SEMIBASE_WRITER_PASSWORD` | Пароль `scada_writer`. Нужен, когда роли ещё нет, то есть при первой установке. Если роль есть, пустое значение оставляет ей прежний пароль | +| `/VERYSILENT` | | Запуск без окон | +| `/SUPPRESSMSGBOXES` | | Без диалоговых окон | +| `/NORESTART` | | Без перезагрузки машины по завершении | + +Переменная окружения используется, только когда параметра нет. + +Роли `semiplot` установщик на каждом запуске задаёт пароль `semiplot`. На этот пароль рассчитан +файл подключения SemiPlot, поэтому параметра для него нет. + +Оба пароля состоят только из печатных символов ASCII: латиница, цифры, пробел и знаки препинания. +Пароль суперпользователя к тому же не содержит `"` и не заканчивается на `\`. Причина в установщике +PostgreSQL: он записывает пароль в кодировке ANSI, а утилита отправляет его в UTF-8, и кавычка или +конечная обратная косая черта ломают его командную строку. Эти правила установщик проверяет на +каждом запуске, в том числе на машине, где PostgreSQL 17 уже стоит. Все `"` из своей командной +строки установщик выбрасывает, поэтому пароль `scada_writer` с кавычкой передают только +переменной. Параметр, значение которого дошло не таким, каким его набрали, установщик отвергает. + +Пароль, переданный параметром, виден в списке процессов, пока установщик работает, и попадает в +историю команд PowerShell, если его набрали в строке. С параметром `/LOG` установщик пишет в свой +журнал всю командную строку вместе с паролями, поэтому `/LOG` не используют. Переменная окружения +в командную строку не попадает. При новой установке PostgreSQL пароль суперпользователя на время +создания кластера появляется в командной строке процесса установщика EDB: так устроен этот +установщик. + +### Код возврата и журналы + +| Код | Значение | +| ------ | ---------------------------------------------------------------------------------------------------------------- | +| `0` | Машина в рабочем состоянии, все проверки пройдены | +| `7` | Установщик отказался работать. Причина записана последней строкой в самом новом журнале `setup-*.log` | +| другой | Сбой самой программы установки. Машина в рабочее состояние не приведена | + +Журналы лежат в `C:\DISTR\Logs\SemiBase`, паролей в них нет: + +- `setup-<ггггммдд-ччммсс>.log` — причина отказа. Если отказала утилита, строка называет её журнал. +- `site-<ггггммдд-ччммсс>.log` — полный вывод утилиты за каждый запуск, успешный или нет. + +### Запуск с окнами + +Без `/VERYSILENT` установщик показывает свои страницы. После выбора папки идёт страница паролей: +поля суперпользователя и `scada_writer` скрывают ввод и заполнены из параметров или переменных +окружения. Поле суперпользователя обязательно. Поле `scada_writer` обязательно, когда на машине не +зарегистрирован ни один PostgreSQL. Отказ установщик показывает в своём окне и пишет в тот же +журнал `setup-*.log`. + +### Что делает установщик + +Состояние PostgreSQL установщик определяет по реестру `HKLM\SOFTWARE\PostgreSQL`, который ведёт +установщик PostgreSQL от EDB, а не по списку служб. Строки проверяются сверху вниз, срабатывает +первая подходящая. + +| На машине | Установщик | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| PostgreSQL 17 зарегистрирован (`Installations\postgresql-x64-17`), у него есть суперпользователь, каталог данных и служба | Работает с ним, даже если зарегистрированы и другие версии. Порт и суперпользователь берёт из реестра. Службу переводит в автоматический запуск и запускает | +| PostgreSQL 17 зарегистрирован, но установка неполная: версия не 17.x, нет суперпользователя или нет `PG_VERSION` в каталоге данных | Отказывает. Так выглядит неудачная установка PostgreSQL | +| PostgreSQL 17 зарегистрирован, но его службы или порта нет | Отказывает: такую регистрацию установщик не чинит | +| Зарегистрирована другая версия, 17 нет | Отказывает и называет версию, службу и порт | +| Ничего не зарегистрировано, но порт 5432 занят | Отказывает: незнакомый сервер установщик не трогает | +| Ничего не зарегистрировано, но `C:\Program Files\PostgreSQL\17\data` не пуст | Отказывает: существующие данные установщик не перезаписывает | +| Ничего не зарегистрировано, порт свободен, каталога данных нет | Требует пароль `scada_writer`, ставит PostgreSQL 17 и дальше работает с ним, как в первой строке | + +Новая установка PostgreSQL 17: порт 5432, служба `postgresql-x64-17`, суперпользователь `postgres`, +каталог данных `C:\Program Files\PostgreSQL\17\data`, локаль `C`, кодировка UTF8, без pgAdmin и +Stack Builder. Когда служба запущена, установщик до 60 с ждёт, пока порт начнёт принимать +подключения, и запускает утилиту: + +```powershell +semibase.exe site --port <порт> --superuser <суперпользователь> --expected-major 17 --restart-service <служба> +``` + +С базой утилита поступает так: + +| В базе | Утилита | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| База `scada_archive` уже есть | Использует её как есть | +| Роль уже есть, пароль для неё не передан | Оставляет прежний пароль | +| Роли нет, пароль для неё не передан | Отказывает. Пароль `scada_writer` нужен только при первой установке | +| Таблица `public.trends` уже есть | Не изменяет её. Если `semiplot` не может её читать, текст ошибки называет команду, которая это чинит | +| Кластер PostgreSQL 17 с локалью не `C` | Использует как есть: новая база наследует локаль `template1` этого кластера | +| Сервер не версии 17 | Отказывает | + +Повторные запуски и сбои: + +| Случай | Результат | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Повторный запуск установщика | Работает с установленным PostgreSQL 17 и перезапускает службу, только если настройки ждут перезапуска | +| Установщик PostgreSQL завершился с ошибкой | Отказ с путём к журналу установщика PostgreSQL. Следующий запуск видит неполную установку и отказывает | +| Утилита отказала после установки PostgreSQL | PostgreSQL остаётся. Повторный запуск с тем же паролем суперпользователя работает с ним | +| Удаление SemiBase | Удаляется только `semibase.exe` | + +Перезапуск службы разрывает подключения SCADA. Список настроек, которые ждут перезапуска, выводит +запрос `SELECT name FROM pg_settings WHERE pending_restart`. Если он не пуст, перед запуском +установщика на работающей установке остановите SCADA. + +### Удаление + +`C:\Program Files\SemiBase\unins000.exe` (или «Установка и удаление программ») удаляет только +`semibase.exe`. PostgreSQL, его данные, роли и журналы в `C:\DISTR\Logs\SemiBase` остаются. + +## Порядок ввода в эксплуатацию + +Порядок важен: утилита выставляет права на чтение до того, как создаёт `public.trends`, поэтому +установщик отрабатывает до первого запуска SCADA. + +1. Запустить установщик, как в разделе [запуск без окон](#запуск-без-окон), и убедиться, что код + возврата `0`. При другом коде перейти к + [восстановлению](#восстановление-после-отказа-установщика). + + Код `0` гарантирует: PostgreSQL 17 работает как служба с автоматическим запуском, настройки + сервера применены, служба перезапущена, если они этого ждали. Архивная база, три роли, права и + права по умолчанию созданы, пять таблиц SemiPlot, функция `semiplot_register_new_pens()` и + `public.trends` (с партицией `tpdefault`) на месте. `semiplot` читает `public.trends`, вызывает + функцию регистрации перьев, пишет свои таблицы, не может добавить, удалить или перенумеровать + перо и не может писать в архив. Отдельной проверки после этого не требуется. + +2. Запустить проект Simple-Scada против базы. Проект подключают к `localhost` или `127.0.0.1`: + сервер принимает подключения только с этой машины. Мы рассчитываем, что таблицу `trends` он + застанет готовой, оставит её как есть и дальше создаст дневные партиции и `messages` сам. + **Это предположение не проверено на реальном проекте.** На первом запуске включите + `log_statement = 'all'` и прочитайте в журнале PostgreSQL, какие DDL-команды SCADA выдала: + если она создаёт или изменяет `trends`, порядок ввода в эксплуатацию нужно пересмотреть. + +3. Запустить SemiPlot. Перья появляются в `semiplot_tags` сами: по одному на каждую переменную, + которую SCADA уже записала в `trends`, с настройками по умолчанию. Переменные, появившиеся + позже, добавляет следующий запуск SemiPlot или кнопка обновления в редакторе перьев. Дальше их + меняют в редакторе SemiPlot. Вручную перья не добавляют, а удалить перо может только + суперпользователь. + +4. Записать файл подключения SemiPlot `connection.yaml`: хост `127.0.0.1`, роль `semiplot`, пароль + `semiplot`. Часовой пояс в файле не указывают: SemiPlot работает на машине SCADA и читает архив + в её часовом поясе. + +Если проверка в конце работы утилиты не прошла, установщик возвращает `7`, а журнал `site` +называет причину. Если `semiplot` не имеет доступа к `trends`, значит таблица была создана раньше, +чем выставлены права по умолчанию; способ починки назван в тексте ошибки. Если роль не может писать +свою таблицу или, наоборот, держит лишнее право на архив, в тексте ошибки названы таблица, операция +и команда `GRANT` или `REVOKE`, которая это чинит. Если роль может добавить, удалить или +перенумеровать перо, в тексте ошибки названа команда `REVOKE`, которая забирает лишнее право у +`semiplot`, у `PUBLIC` или у роли, в которую входит `semiplot`. + +## Восстановление после отказа установщика + +Отказ установщика — это код возврата `7`. Восстановление идёт так: + +1. Прочитать последнюю строку самого нового журнала `C:\DISTR\Logs\SemiBase\setup-*.log`. +2. Найти её начало в таблице ниже и сделать то, что там сказано. +3. Запустить установщик снова. + +Отказы из-за паролей и из-за состояния PostgreSQL установщик выдаёт до любых изменений. Отказ +установщика PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже +запущена. + +| Строка в журнале | Что делать | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `No superuser password` | Передать пароль суперпользователя параметром или переменной | +| `No scada_writer password` | Установщик ставит PostgreSQL и создаёт `scada_writer`: передать пароль этой роли | +| `The superuser password must be printable ASCII`, `The superuser password must not contain "`, `The scada_writer password ... must be printable ASCII` | Для новой установки выбрать пароль по [правилам](#параметры-и-пароли). Если такой пароль у суперпользователя уже стоящего PostgreSQL 17, установщик с ним работать не будет: подготовить сервер [утилитой вручную](#ручной-запуск-утилиты) | +| `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Передать пароль переменной окружения | +| `... is not a complete PostgreSQL 17 installation` | Так выглядит неудачная установка PostgreSQL. Удалить её программой `uninstall-postgresql.exe`, путь к которой назван в строке, и [дождаться конца удаления](#удаление-postgresql) | +| `... names no service`, `... names service ..., which does not exist`, `... has no Port value` | Регистрация PostgreSQL 17 в реестре не совпадает со службами. Если сервер работает, подготовить его [утилитой вручную](#ручной-запуск-утилиты). Иначе удалить остатки программой `uninstall-postgresql.exe` из папки установки PostgreSQL и [дождаться конца удаления](#удаление-postgresql) | +| `PostgreSQL 17 is not registered, and another major is` | Установщик работает только с PostgreSQL 17 и не ставит его рядом с другой версией. Либо подготовить существующий сервер [утилитой вручную](#ручной-запуск-утилиты) с его версией в `--expected-major` (не ниже 14), либо перенести его данные на 17 через `pg_upgrade` в плановый простой, либо удалить старую версию, если её данные не нужны | +| `No PostgreSQL is registered, but 127.0.0.1:5432 accepts TCP` | На порту работает сервер, которого нет в реестре: служба, поставленная не установщиком EDB, или контейнер. Найти процесс: `Get-Process -Id (Get-NetTCPConnection -LocalPort 5432 -State Listen).OwningProcess`. Остановить его или подготовить [утилитой вручную](#ручной-запуск-утилиты) | +| `No PostgreSQL is registered, but C:\Program Files\PostgreSQL\17\data exists and is not empty` | Данные остались после прерванной установки или после удаления PostgreSQL: удаление каталог данных не трогает. Если данные не нужны, удалить каталог. Установщик их никогда не перезаписывает | +| `The PostgreSQL installer exited with code` | Причина записана в журнале установщика PostgreSQL, путь к нему назван в строке. Следующий запуск увидит неполную установку: удалить её, как в строке про неполную установку | +| `sc.exe config ...`, `sc.exe start ...`, `... accepted no TCP connection within 60 s` | Служба не запустилась или сервер не принял подключение. Причина записана в журнале сервера, в папке `log` каталога данных, и в журнале событий Windows. Устранить её | +| `semibase site exited with code` | Причина записана в журнале `site`, путь к нему назван в строке. `password authentication failed` значит неверный пароль суперпользователя. Если служба осталась остановленной, см. [ошибки перезапуска службы](#ошибки-перезапуска-службы) | +| `Setup could not ...` | Не запустился `powershell.exe`, `cmd.exe` или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | + +### Удаление PostgreSQL + +`uninstall-postgresql.exe` завершается раньше, чем удаляет что-либо: работу делает его копия +`_uninstall*.000` в папке `%TEMP%`. Перед следующим запуском установщика дождитесь её завершения: + +```powershell +while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall*.000' }) { Start-Sleep -Seconds 1 } +``` + +Каталог данных удаление не трогает. Если он остался и не пуст, следующий запуск откажет из-за него. + +### Ошибки перезапуска службы + +Утилита запускает службу, только когда старый сервер завершил работу и порт перестал отвечать. +Если журнал `site` сообщает, что служба остановлена и такой останется, дождитесь, пока +`Test-NetConnection localhost -Port 5432` покажет `TcpTestSucceeded : False` и +`Get-Service postgresql-x64-17` покажет `Stopped`, и запустите службу командой +`Start-Service postgresql-x64-17`. + +Если служба остановилась сразу после запуска, журнал `site` сообщает, отвечает ли сервер на порту. +Если ничего не отвечает, сервер завершился при старте. Причина записана в журнале сервера, в папке +`log` каталога данных. Если журнал называет настройку, которую записала утилита (например, серверу +не хватило памяти на `shared_buffers = 2GB`), удалите эту строку из `postgresql.auto.conf` в +каталоге данных и запустите службу командой `Start-Service postgresql-x64-17`. Повторный запуск +установщика запишет строку снова и так же остановит службу, поэтому сначала устраните причину. +Если сервер отвечает, служба не дождалась конца его старта, и сервер работает вне службы. Когда он +начнёт принимать подключения, остановите его командой `pg_ctl stop -D <каталог данных>` +(`pg_ctl.exe` лежит в папке `bin` установки PostgreSQL, каталог данных указан после `-D` в пути к +исполняемому файлу службы) и запустите службу командой `Start-Service postgresql-x64-17`. + +Если установщик или утилиту прервали во время перезапуска (завершили процесс, вышли из системы), +служба может остаться остановленной. Проверьте её командой `Get-Service postgresql-x64-17` и, если +она остановлена, запустите командой `Start-Service postgresql-x64-17`. + +### Ручной запуск утилиты + +Для состояний, с которыми установщик не работает, утилиту запускают сами. Копия утилиты лежит в +`C:\Program Files\SemiBase\semibase.exe` после первого успешного запуска установщика. Отказ +приходит раньше, чем установщик копирует свои файлы, поэтому на машине, где установщик ни разу не +завершился успешно, копии нет. Тогда соберите утилиту из исходников того же выпуска на другой +машине (`go build -o semibase.exe ./cmd/semibase`, нужен Go) и перенесите её. + +Порт, суперпользователь и служба PostgreSQL 17, зарегистрированного установщиком EDB, читаются из +реестра. Для другой версии в имени ключа стоит она, например `postgresql-x64-14`: + +```powershell +$installation = Get-ItemProperty 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' +$service = $installation.'Service ID' +$superuser = $installation.'Super User' +$port = (Get-ItemProperty "HKLM:\SOFTWARE\PostgreSQL\Services\$service").Port +``` + +Утилита не работает с отключённой или остановленной службой, поэтому служба переводится в +автоматический запуск и запускается. Дальше в том же окне PowerShell от имени администратора: + +```powershell +Set-Service $service -StartupType Automatic; Start-Service $service +$env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password +$env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password +$env:SEMIBASE_PLOT_PASSWORD = 'semiplot' +& 'C:\Program Files\SemiBase\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service +$LASTEXITCODE +``` -Каждый выпуск дополнительно публикует образ контейнера `ghcr.io/semiteq/semibase`. Он нужен -только тестовым стендам смежных проектов, которые встраивают эту утилиту в свои сборки. При -пусконаладке образ не используется — берите `semibase.exe` со страницы Releases. +Пароль `scada_writer` нужен, только если роли ещё нет; иначе строку пропускают. Пароль `semiplot` +задан тем же, что задаёт установщик. Перед любыми изменениями утилита сверяет каталог после `-D` в +пути к исполняемому файлу службы с каталогом данных сервера на порту. Если они не совпадают, +`--restart-service` называет не ту службу, и утилита завершается с ошибкой, ничего не изменив. +Код `0` значит то же, что код `0` установщика. -Сборка из исходников: `go build -o semibase.exe ./cmd/semibase`. +## Утилита semibase.exe -## Команды +Утилита — скрипт установки: одна команда приводит экземпляр в заданное состояние и завершается. +Пароли существующих ролей она меняет только при явной передаче флага или переменной окружения. + +```powershell +.\semibase.exe --help # список команд +.\semibase.exe site --help # флаги конкретной команды +``` | Команда | Действие | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -37,18 +312,19 @@ SemiBase разворачивается одной утилитой — `semibas Обе команды заканчиваются проверкой: `semiplot` читает `public.trends` и версию схемы, вызывает функцию регистрации новых перьев, пишет свои собственные таблицы и не может ни писать в архив, ни -менять версию схемы, ни создавать объекты в схеме `public`. Ненулевой код возврата означает, что заявленное состояние не достигнуто. +менять версию схемы, ни создавать объекты в схеме `public`. Ненулевой код возврата означает, что +заявленное состояние не достигнуто. -При обращении за поддержкой приложите вывод `semibase version` — он однозначно называет -сборку утилиты на машине. +При обращении за поддержкой приложите вывод `semibase version`: он однозначно называет сборку +утилиты на машине. -## Пароли +### Пароли Пароли утилита берёт из флагов или переменных окружения, флаг важнее переменной. Файлов с паролями утилита не читает и сама их не записывает. Флаг с паролем, набранный в командной строке, PowerShell сохраняет в истории команд на диске. Поэтому пароли задают переменными окружения, а значение вводят по запросу `Read-Host`, как в -[порядке ввода в эксплуатацию](#порядок-ввода-в-эксплуатацию). +[ручном запуске утилиты](#ручной-запуск-утилиты). Команды, которые задают пароль роли, в журнал PostgreSQL не попадают: ни при `log_statement = 'all'`, ни при журналировании медленных или ошибочных запросов. На соединениях @@ -61,6 +337,23 @@ SemiBase разворачивается одной утилитой — `semibas | `scada_writer` | `--writer-password` | `SEMIBASE_WRITER_PASSWORD` | | `semiplot` | `--plot-password` | `SEMIBASE_PLOT_PASSWORD` | +Пароли ролей нужны только на первом запуске, который эти роли создаёт. Когда роли уже есть, +достаточно пароля суперпользователя; пароль `semiplot` тогда лишь включает дополнительную проверку +входа под этой ролью (только по TCP). + +### Основные флаги + +| Флаг | По умолчанию | Назначение | +| ------------------- | --------------- | ------------------------------------------------------------- | +| `--host` | `localhost` | Адрес сервера или каталог unix-сокета (`/var/run/postgresql`) | +| `--port` | `5432` | Порт сервера | +| `--database` | `scada_archive` | Имя архивной базы | +| `--superuser` | `postgres` | Суперпользователь, под которым подключается утилита | +| `--expected-major` | не проверяется | Требуемая мажорная версия сервера | +| `--restart-service` | не задан | Только `site` в Windows: служба PostgreSQL, которую утилита перезапускает, если настройки ждут перезапуска. Требует `--host` `localhost`, `127.0.0.1` или `::1` и запуска от имени администратора. Отключённую, остановленную или обслуживающую другой каталог данных службу утилита не трогает и завершается с ошибкой; только что запущенную службу ждёт до двух минут | + +Полный перечень — `semibase <команда> --help`. + ## Роли Ролей четыре. Суперпользователя создаёт установщик СУБД, три остальные — утилита. @@ -94,10 +387,6 @@ DDL. Права `CREATE` в схеме `public` у роли `semiplot` нет входит отдельно под `scada_writer`: владелец и права на таблицу выходят те же, а `pg_hba.conf` править не придётся. -Пароли ролей нужны только на первом запуске — том, который эти роли создаёт. Когда роли уже -есть, достаточно пароля суперпользователя; пароль `semiplot` тогда лишь включает -дополнительную проверку входа под этой ролью (только по TCP). - ## Таблицы SemiPlot Утилита создаёт пять таблиц и задаёт их схему. SemiPlot меняет настройки перьев в @@ -144,115 +433,3 @@ Simple-Scada 2 на той же машине, и ей доверяют: она Версия схемы — нижняя граница, а не точное совпадение: SemiPlot отказывается работать с базой, версия которой ниже нужной ей, и принимает базу с версией выше. Этот выпуск пишет `1`. - -## Порядок ввода в эксплуатацию - -Порядок важен: утилита выставляет права на чтение до того, как создаёт `public.trends`, поэтому -она отрабатывает до первого запуска SCADA. - -1. Установить СУБД: - -```powershell -winget install --id PostgreSQL.PostgreSQL.17 --exact -``` - -2. Открыть PowerShell от имени администратора и задать в этом окне пароли ролей переменными - окружения: из него же запускается утилита, а перезапуск службы требует прав администратора. - Каждая строка запрашивает пароль, и в историю команд PowerShell попадает только - сама строка, без пароля. Переменные живут, пока окно открыто. - -```powershell -$env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password -$env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password -$env:SEMIBASE_PLOT_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль semiplot' -AsSecureString)).Password -``` - -3. Выполнить одну команду: - -```powershell -.\semibase.exe site --restart-service postgresql-x64-17 -``` - -`postgresql-x64-17` — имя службы, которую создаёт установщик PostgreSQL 17. Проверить его можно -командой `Get-Service postgresql*`. Перед любыми изменениями утилита сверяет каталог после `-D` в -пути к исполняемому файлу службы с каталогом данных сервера на порту. Если они не совпадают, флаг -называет не ту службу, и утилита завершается с ошибкой, ничего не изменив. - -На выходе утилита гарантирует: настройки сервера применены, архивная база, три роли, права -и права по умолчанию созданы, пять таблиц SemiPlot, функция `semiplot_register_new_pens()` и -`public.trends` (с партицией `tpdefault`) на месте, `semiplot` читает `public.trends`, вызывает -функцию регистрации перьев, пишет свои таблицы, не может добавить, -удалить или перенумеровать перо и не может писать в архив. Отдельной проверки после этого не требуется — она уже выполнена, её результат в коде -возврата. - -4. Убедиться, что служба перезапущена. `shared_buffers` и `listen_addresses` применяются только - после перезапуска службы, остальные настройки сразу. С флагом `--restart-service` утилита на - шаге 3 перезапускает службу сама, если настройки ждут перезапуска, ждёт, пока сервер снова - примет подключения, и продолжает работу. Без флага перезапустите службу или перезагрузите - машину; пока перезапуск не сделан, утилита предупреждает об этом в последних строках вывода. - - Утилита запускает службу, только когда старый сервер завершил работу и порт перестал - отвечать. Если она сообщает, что служба остановлена и такой останется, дождитесь, пока - `Test-NetConnection localhost -Port 5432` покажет `TcpTestSucceeded : False` и - `Get-Service postgresql-x64-17` покажет `Stopped`, и запустите службу командой - `Start-Service postgresql-x64-17`. - - Если служба остановилась сразу после запуска, утилита сообщает, отвечает ли сервер на порту. - Если ничего не отвечает, сервер завершился при старте. Причина записана в журнале сервера, в - папке `log` каталога данных. Если журнал называет настройку, которую записала утилита - (например, серверу не хватило памяти на `shared_buffers = 2GB`), удалите эту строку из - `postgresql.auto.conf` в каталоге данных и запустите службу командой - `Start-Service postgresql-x64-17`. Повторный запуск `site` запишет строку снова и так же - остановит службу, поэтому сначала устраните причину. Если сервер отвечает, служба не - дождалась конца его старта, и сервер работает вне службы. Когда он начнёт принимать - подключения, остановите его командой `pg_ctl stop -D <каталог данных>` (`pg_ctl.exe` лежит в - папке `bin` установки PostgreSQL, каталог данных указан после `-D` в пути к исполняемому файлу - службы) и запустите службу командой `Start-Service postgresql-x64-17`. - - Если во время перезапуска закрыли окно PowerShell, вышли из системы или завершили процесс - утилиты, служба может остаться остановленной. Проверьте её командой - `Get-Service postgresql-x64-17` и, если она остановлена, запустите командой - `Start-Service postgresql-x64-17`. - - Перезапуск разрывает подключения SCADA. Повторный запуск `site` на работающей установке - перезапускает службу, только если какие-то настройки ждут перезапуска. Их список выводит - запрос `SELECT name FROM pg_settings WHERE pending_restart`; если он не пуст, остановите SCADA - перед запуском. - -5. Запустить проект Simple-Scada против базы. Проект подключают к `localhost` или `127.0.0.1`: - после перезапуска сервер принимает подключения только с этой машины. Мы рассчитываем, что - таблицу `trends` он застанет готовой, оставит её как есть и дальше создаст дневные партиции и - `messages` сам. **Это - предположение не проверено на реальном проекте.** На первом запуске включите - `log_statement = 'all'` и прочитайте в журнале PostgreSQL, какие DDL-команды SCADA - выдала: если она создаёт или изменяет `trends`, порядок ввода в эксплуатацию нужно - пересмотреть. - -6. Запустить SemiPlot. Перья появляются в `semiplot_tags` сами: по одному на каждую переменную, - которую SCADA уже записала в `trends`, с настройками по умолчанию. Переменные, появившиеся - позже, добавляет следующий запуск SemiPlot или кнопка обновления в редакторе перьев. Дальше их меняют в - редакторе SemiPlot. Вручную перья не добавляют, а удалить перо может только суперпользователь. - -7. Записать файл подключения SemiPlot `connection.yaml`: хост `127.0.0.1`, роль `semiplot` и её - пароль. Часовой пояс в файле не указывают: SemiPlot работает на машине SCADA и читает архив в - её часовом поясе. - -Если проверка в конце сообщает, что `semiplot` не имеет доступа к `trends`, значит таблица была -создана раньше, чем выставлены права по умолчанию. Способ починки утилита называет в тексте -ошибки. Если проверка сообщает, что роль не может писать свою таблицу или, наоборот, держит -лишнее право на архив, в тексте ошибки названы таблица, операция и команда `GRANT` или `REVOKE`, -которая это чинит. Если роль может добавить, удалить или перенумеровать перо, в тексте ошибки -названа команда `REVOKE`, которая забирает лишнее право у `semiplot`, у `PUBLIC` или у роли, в -которую входит `semiplot`. - -## Основные флаги - -| Флаг | По умолчанию | Назначение | -| ------------------- | --------------- | ------------------------------------------------------------- | -| `--host` | `localhost` | Адрес сервера или каталог unix-сокета (`/var/run/postgresql`) | -| `--port` | `5432` | Порт сервера | -| `--database` | `scada_archive` | Имя архивной базы | -| `--expected-major` | не проверяется | Требуемая мажорная версия сервера | -| `--restart-service` | не задан | Только `site` в Windows: служба PostgreSQL, которую утилита перезапускает, если настройки ждут перезапуска. Требует `--host` `localhost`, `127.0.0.1` или `::1` и запуска от имени администратора. Отключённую, остановленную или обслуживающую другой каталог данных службу утилита не трогает и завершается с ошибкой; только что запущенную службу ждёт до двух минут | - -Полный перечень — `semibase <команда> --help`. diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 5d530fc..d84017d 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -369,27 +369,27 @@ run; `$psql = "$env:PGBIN\psql.exe"`. Linux job (run 39302087408; locally golangci-lint 2.14.0, CI runs the pinned 2.12.2) ### Task 10: [Final] Update documentation -- [ ] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment +- [x] `docs/deployment.md`, in Russian: the setup command line, its parameters, environment fallbacks and exit codes, the three state tables in operator terms, the commissioning order with the setup as step 1; `winget` and the manual restart go -- [ ] `docs/deployment.md`: one recovery section for the states the setup refuses, running +- [x] `docs/deployment.md`: one recovery section for the states the setup refuses, running `{app}\semibase.exe site --port

--superuser --expected-major 17 --restart-service ` with the password variables set in the shell; `deployment.md:22` stops pointing at a bare Windows binary -- [ ] `README.md:28-36` becomes the setup command -- [ ] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored +- [x] `README.md:28-36` becomes the setup command +- [x] `docs/architecture/README.md:19`: the locked decision becomes "EDB PostgreSQL 17 vendored in the setup, pinned by SHA256"; `README.md:30` (Distribution) and the document index gain the setup and `installer.md` -- [ ] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the +- [x] `docs/architecture/provisioning.md:12-14` (the `winget` line outside the tool) and the artifact table at `provisioning.md:18-25`: the Windows row becomes the setup, the Linux row's consumer becomes the image build and its provenance -- [ ] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 +- [x] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 (`overview.md:56`) becomes the setup -- [ ] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the +- [x] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the ordering rationale and "consumers ... still download the release assets" -- [ ] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is +- [x] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is updated, and SemiPlot's go to Post-Completion -- [ ] move this plan to `docs/plans/completed/` +- [x] move this plan to `docs/plans/completed/` (left for the delivery step) ## Post-Completion - SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while @@ -401,7 +401,12 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - SemiPlot tells the operator to "run `semibase site`" in `docs/architecture/data-integration.md` (`:1017-1021`, `:1092`, `:1342`) and in `SemiPlot.UI/Localization/Resources.resx` and `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". -- SemiPlot `docs/architecture/testing-strategy.md:417` names the `winget` install. +- SemiPlot `docs/architecture/testing-strategy.md:418` names the `winget` install. Its other hit, + `docs/plans/completed/20260810-archive-populator.md:1270`, is a completed plan and stays. +- No `semibase.exe` exists for the manual run in the recovery section of `docs/deployment.md` on + a machine where Setup has never succeeded: every refusal precedes the `[Files]` copy, and the + release no longer attaches the Windows binary. Decide whether the release attaches it again or + Setup keeps a copy before it refuses. - Click through `SemiBase-Setup.exe` once, interactively, in a clean Windows VM: the password page appears after the folder page; Next with an empty superuser field shows an error and stays; with no PostgreSQL registered, Next with an empty writer field shows an error and stays; with diff --git a/docs/readme.md b/docs/readme.md index 5d1b1b5..914fce8 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,4 +1,4 @@ # Документация SemiBase -- [Развёртывание](./deployment.md) — требования, установка СУБД, команды утилиты, параметры, - порядок ввода в эксплуатацию. +- [Развёртывание](./deployment.md) — требования, установщик и его параметры, порядок ввода в + эксплуатацию, восстановление после отказа установщика, команды утилиты. From 8fa733b744d13153caa2f63f7adac973ff3747d6 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:36:21 +0300 Subject: [PATCH 27/35] fix(installer): copy the recovery tool, test all rows Every refusal came before the [Files] copy and the release no longer attaches the Windows binary, so a machine where Setup never succeeded had no semibase.exe for the manual recovery, and an offline site could not get one. Every run now copies the carried binary to C:\DISTR\Tools\SemiBase before any check. The EDB password rules (ASCII, ", trailing \) refused a registered 17 whose password reaches semibase byte for byte; they now hold on the fresh-install row only, which also refuses a leading - and leading or trailing spaces nobody measured. An unquoted space in a password parameter no longer passes as its first half. SEMIBASE_WRITER_PASSWORD is always set, so a cleared field does not let the caller's value reset scada_writer. The port probe dials localhost like semibase and tells a failed probe from a closed port; registry values cmd.exe would change are refused; an exception reaches the setup log; the dead [Dirs] entry and the one-read Fresh field are gone. The payload has a fixed name, so the pin lives in the fetch script, and Inno Setup is pinned to the measured 6.7.3 in one composite action. CI now runs every reachable engine row, the remaining password rules, the variable channel on the fresh install with a watch on the option file, a superuser-only re-run, a writer password change under full statement logging, and a wrong registered port. --- .github/actions/build-setup/action.yml | 46 +++ .github/scripts/installer-case.ps1 | 72 ++++ .github/workflows/ci.yml | 513 +++++++++++-------------- Installer/SemiBase.iss | 223 +++++++---- Installer/fetch-postgresql.sh | 3 +- 5 files changed, 488 insertions(+), 369 deletions(-) create mode 100644 .github/actions/build-setup/action.yml create mode 100644 .github/scripts/installer-case.ps1 diff --git a/.github/actions/build-setup/action.yml b/.github/actions/build-setup/action.yml new file mode 100644 index 0000000..272a357 --- /dev/null +++ b/.github/actions/build-setup/action.yml @@ -0,0 +1,46 @@ +name: Build the setup +description: Fetch the pinned EDB payload, install Inno Setup and compile Installer/Output/SemiBase-Setup.exe + +inputs: + app-version: + description: AppVersion; VersionInfoVersion takes its part before the first -, digits and dots only + required: true + semibase-exe: + description: Path of the semibase.exe the setup carries + required: true + +runs: + using: composite + steps: + - name: Cache the EDB payload + uses: actions/cache@v6 + with: + path: Installer/payload + key: edb-payload-${{ hashFiles('Installer/fetch-postgresql.sh') }} + + # shell: bash is Git Bash; a bare bash on the PATH may be WSL's + - name: Fetch the EDB payload + shell: bash + run: bash Installer/fetch-postgresql.sh + + # The jrsoftware.org direct download intermittently serves a corrupted file, so winget + # (preinstalled on windows runners) installs it instead. The version is the one the setup's + # command-line and silent-mode behaviour was measured on (docs/architecture/installer.md#inno-setup). + # Machine scope keeps the Program Files (x86) path the next step calls. + - name: Install Inno Setup + shell: pwsh + run: | + winget install --id JRSoftware.InnoSetup --exact --version 6.7.3 --scope machine --silent ` + --accept-source-agreements --accept-package-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { exit 1 } + + - name: Compile the setup + shell: pwsh + env: + APP_VERSION: ${{ inputs.app-version }} + SEMIBASE_EXE: ${{ inputs.semibase-exe }} + run: | + $fileVersion = $env:APP_VERSION.Split('-')[0] + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" "/DAppVersion=$env:APP_VERSION" ` + "/DFileVersion=$fileVersion" "/DSemibaseExe=$env:SEMIBASE_EXE" Installer\SemiBase.iss + if ($LASTEXITCODE -ne 0) { exit 1 } diff --git a/.github/scripts/installer-case.ps1 b/.github/scripts/installer-case.ps1 new file mode 100644 index 0000000..a4277a2 --- /dev/null +++ b/.github/scripts/installer-case.ps1 @@ -0,0 +1,72 @@ +# Helpers for the setup's end-to-end cases; each installer step dot-sources this file. + +$SetupExe = "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" +$LogDir = 'C:\DISTR\Logs\SemiBase' +$ToolExe = 'C:\DISTR\Tools\SemiBase\semibase.exe' +$TokenFile = "$env:RUNNER_TEMP\case-passwords.txt" + +function Fail([string] $message) { + Write-Host "::error::$message" + exit 1 +} + +function New-Token { + -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) +} + +# Setup is a GUI program, so the call operator would not wait for it. ProcessStartInfo passes the +# argument string as written, so a " reaches Setup; Start-Process would rebuild the string. +function Invoke-Setup([string] $arguments = '') { + $start = [Diagnostics.ProcessStartInfo]::new($SetupExe, "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART $arguments") + $start.UseShellExecute = $false + $since = Get-Date + $setup = [Diagnostics.Process]::Start($start) + $setup.WaitForExit() + $logs = @(Get-ChildItem "$LogDir\*.log" -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -ge $since } | Sort-Object LastWriteTime) + [pscustomobject]@{ ExitCode = $setup.ExitCode; Logs = $logs } +} + +# The setup and site logs carry no password by design; the redaction keeps a regression out of +# the public job log +function Write-Logs($run, [string[]] $tokens = @()) { + foreach ($log in $run.Logs) { + $text = "$(Get-Content -Raw $log.FullName)" + foreach ($token in $tokens) { $text = $text.Replace($token, '') } + Write-Host "--- $($log.Name)" + Write-Host $text + } +} + +function Get-SiteLog($run) { + $site = $run.Logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 + if (-not $site) { Fail 'the run wrote no site log' } + "$(Get-Content -Raw $site.FullName)" +} + +# A refusal is exit 7 with its reason as the last line of the newest setup log: LogRefusal +# appends to a log of the same second, so earlier lines may belong to an earlier run +function Assert-Refused($run, [string] $want) { + if ($run.ExitCode -ne 7) { Fail "want exit 7, a refusal from PrepareToInstall, with '$want', got $($run.ExitCode)" } + $log = $run.Logs | Where-Object { $_.Name -like 'setup-*' } | Select-Object -Last 1 + $last = if ($log) { Get-Content $log.FullName | Select-Object -Last 1 } + Write-Host "refusal: $last" + if (-not "$last".Contains($want)) { Fail "the setup log does not end with '$want'" } +} + +function Assert-NoPostgresService { + if (Get-Service postgresql* -ErrorAction SilentlyContinue) { Fail 'a refused run left a postgresql* service' } +} + +function Assert-NothingRegistered { + foreach ($key in 'Installations', 'Services') { + $path = "HKLM:\SOFTWARE\PostgreSQL\$key" + if ((Test-Path $path) -and @(Get-ChildItem $path).Count -ne 0) { Fail "$path is not empty" } + } +} + +function Assert-Login([string] $role, [string] $password, [string] $database = 'scada_archive') { + $env:PGPASSWORD = $password + & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U $role -d $database -tAc 'SELECT 1' | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "$role cannot log in to $database with the expected password" } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 081b625..abd3cee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,7 +321,9 @@ jobs: docker run --rm semibase:ci version # The setup's end-to-end cases. No SEMIBASE_* variable is set at job level: a - # case that wants one sets it, so the first case runs with none. + # case that wants one sets it, so the first case runs with none. Each step + # dot-sources .github/scripts/installer-case.ps1, and a refusal is exit 7 with + # its reason last in the newest setup log (docs/architecture/installer.md#inno-setup). installer: runs-on: windows-latest steps: @@ -334,269 +336,217 @@ jobs: - name: Build run: go build -o semibase.exe ./cmd/semibase - - name: Cache the EDB payload - uses: actions/cache@v6 - with: - path: Installer/payload - key: edb-payload-${{ hashFiles('Installer/fetch-postgresql.sh') }} - - # shell: bash is Git Bash; a bare bash on the PATH may be WSL's - - name: Fetch the EDB payload - shell: bash - run: bash Installer/fetch-postgresql.sh - - # The jrsoftware.org direct download intermittently serves a corrupted file, so winget - # (preinstalled on windows runners) installs it instead. Machine scope keeps the - # Program Files (x86) path the next step expects. - - name: Install Inno Setup - run: | - winget install --id JRSoftware.InnoSetup --exact --scope machine --silent ` - --accept-source-agreements --accept-package-agreements --disable-interactivity - if ($LASTEXITCODE -ne 0) { exit 1 } - "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Build the setup - run: | - iscc /DAppVersion=0.0.0-ci /DFileVersion=0.0.0 "/DSemibaseExe=$env:GITHUB_WORKSPACE\semibase.exe" ` - Installer\SemiBase.iss - if ($LASTEXITCODE -ne 0) { exit 1 } + uses: ./.github/actions/build-setup + with: + app-version: 0.0.0-ci + semibase-exe: ${{ github.workspace }}\semibase.exe - # Setup is a GUI program: the call operator returns before it exits, so - # Start-Process waits for it. Exit 7 is what Setup returns when - # PrepareToInstall refuses (docs/architecture/installer.md#inno-setup). - name: Refuse a run without a superuser password run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $service = 'postgresql-x64-17' - if (Test-Path Env:SEMIBASE_SUPER_PASSWORD) { - Write-Host '::error::SEMIBASE_SUPER_PASSWORD is set, so this case tests nothing' - exit 1 - } - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru - if ($setup.ExitCode -ne 7) { - Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" - exit 1 - } - $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 - if (-not $log -or (Get-Content -Raw $log.FullName) -notlike '*No superuser password*') { - Write-Host '::error::the setup log does not record the missing superuser password' - exit 1 - } - if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { - Write-Host '::error::the refused run installed semibase.exe' - exit 1 - } + if (Test-Path Env:SEMIBASE_SUPER_PASSWORD) { Fail 'SEMIBASE_SUPER_PASSWORD is set, so this case tests nothing' } + Assert-Refused (Invoke-Setup) 'No superuser password' + if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { Fail 'the refused run installed semibase.exe' } + if (-not (Test-Path $ToolExe)) { Fail "the refused run left no recovery copy at $ToolExe" } + & $ToolExe version + if ($LASTEXITCODE -ne 0) { Fail 'the recovery copy does not run' } $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode - if ($startMode -ne 'Disabled') { - Write-Host "::error::the refused run changed $service to start type '$startMode' (want Disabled)" - exit 1 - } + if ($startMode -ne 'Disabled') { Fail "the refused run changed $service to start type '$startMode' (want Disabled)" } + + # Not GITHUB_ENV: an exported variable would reach every step. A case that + # hands a token through the environment sets it inside its own step, and + # RUNNER_TEMP is outside every directory the log search reads. The order is + # case 2's writer, then case 6's superuser and writer, then case 7's new writer. + - name: Generate the case passwords + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = 1..4 | ForEach-Object { New-Token } + $tokens | ForEach-Object { Write-Host "::add-mask::$_" } + Set-Content -Path $TokenFile -Value $tokens # The runner's 17 is Disabled and stopped here, and nothing has provisioned # it, so scada_writer does not exist and its password has to come too. The - # runner image sets a machine-level PGPASSWORD, which the psql check overrides. + # runner image sets a machine-level PGPASSWORD, which the login check overrides. - name: Provision the runner's existing PostgreSQL 17 env: SEMIBASE_SUPER_PASSWORD: root run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) $service = 'postgresql-x64-17' - $psql = "$env:PGBIN\psql.exe" - $env:SEMIBASE_WRITER_PASSWORD = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) - $started = Get-Date - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru - # Both logs are password-free, so they are printed whatever the result - Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } - if ($setup.ExitCode -ne 0) { - Write-Host "::error::want exit 0, got $($setup.ExitCode)" - exit 1 - } - if (-not (Get-ChildItem 'C:\DISTR\Logs\SemiBase\site-*.log' | Where-Object { $_.LastWriteTime -ge $started })) { - Write-Host '::error::the run wrote no site log' - exit 1 - } - if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { - Write-Host '::error::the run did not install semibase.exe' - exit 1 - } + $env:SEMIBASE_WRITER_PASSWORD = $tokens[0] + $run = Invoke-Setup + Write-Logs $run $tokens + if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } + Get-SiteLog $run | Out-Null + if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { Fail 'the run did not install semibase.exe' } $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode - if ($startMode -ne 'Auto') { - Write-Host "::error::$service has start type '$startMode' (want Auto)" - exit 1 - } - $env:PGPASSWORD = 'semiplot' - $tags = & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' - if ($LASTEXITCODE -ne 0) { - Write-Host '::error::semiplot with password semiplot cannot read semiplot_tags' - exit 1 - } - Write-Host "semiplot_tags rows: $tags" - - # Not GITHUB_ENV: an exported variable reaches Setup, EDB and initcluster.ps1 by - # inheritance, and the log search would find it there. RUNNER_TEMP is outside - # every directory that search reads. - - name: Generate the case passwords - run: | - $tokens = 1..2 | ForEach-Object { - -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) - } - $tokens | ForEach-Object { Write-Host "::add-mask::$_" } - Set-Content -Path "$env:RUNNER_TEMP\case-passwords.txt" -Value $tokens + if ($startMode -ne 'Auto') { Fail "$service has start type '$startMode' (want Auto)" } + Assert-Login semiplot semiplot + Assert-Login scada_writer $tokens[0] # The remaining cases need a machine with no PostgreSQL. The uninstaller exits # before it removes anything, and a copy of it in %TEMP% does the work - # (docs/architecture/installer.md#the-uninstaller). + # (docs/architecture/installer.md#the-uninstaller). The empty Installations and + # Services keys it leaves stay, as on an installation machine. - name: Remove the runner's PostgreSQL timeout-minutes: 10 run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $base = (Get-ItemProperty 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17').'Base Directory' $uninstaller = Start-Process -FilePath "$base\uninstall-postgresql.exe" ` -ArgumentList '--mode', 'unattended', '--unattendedmodeui', 'none' -Wait -PassThru - if ($uninstaller.ExitCode -ne 0) { - Write-Host "::error::uninstall-postgresql.exe exited $($uninstaller.ExitCode)" - exit 1 - } + if ($uninstaller.ExitCode -ne 0) { Fail "uninstall-postgresql.exe exited $($uninstaller.ExitCode)" } $deadline = (Get-Date).AddMinutes(5) while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall*.000' }) { - if ((Get-Date) -gt $deadline) { - Write-Host '::error::the uninstaller copy still runs after 5 minutes' - exit 1 - } + if ((Get-Date) -gt $deadline) { Fail 'the uninstaller copy still runs after 5 minutes' } Start-Sleep -Seconds 1 } $last = Get-Content "$env:TEMP\uninstall-postgresql.log" | Where-Object { $_.Trim() } | Select-Object -Last 1 - if ("$last" -notlike '*Exiting with code 0*') { - Write-Host "::error::the uninstall log ends with '$last' (want Exiting with code 0)" - exit 1 - } - foreach ($path in 'C:\PostgreSQL\17\data', 'C:\Program Files\PostgreSQL', 'HKLM:\SOFTWARE\PostgreSQL') { + if ("$last" -notlike '*Exiting with code 0*') { Fail "the uninstall log ends with '$last' (want Exiting with code 0)" } + foreach ($path in 'C:\PostgreSQL\17\data', 'C:\Program Files\PostgreSQL') { if (Test-Path $path) { Remove-Item -Recurse -Force $path } } + reg.exe query HKLM\SOFTWARE\PostgreSQL /s + Assert-NothingRegistered $services = @(Get-Service postgresql* -ErrorAction SilentlyContinue) - $registered = Test-Path 'HKLM:\SOFTWARE\PostgreSQL' - if ($services.Count -ne 0 -or $registered) { - Write-Host "::error::left behind: $($services.Count) postgresql* services, HKLM\SOFTWARE\PostgreSQL present: $registered" - exit 1 - } + if ($services.Count -ne 0) { Fail "left behind: $($services.Count) postgresql* services" } $client = [Net.Sockets.TcpClient]::new() try { $open = $client.ConnectAsync('127.0.0.1', 5432).Wait(2000) } catch { $open = $false } finally { $client.Close() } - if ($open) { - Write-Host '::error::127.0.0.1:5432 still accepts TCP' - exit 1 - } + if ($open) { Fail '127.0.0.1:5432 still accepts TCP' } + exit 0 - name: Refuse a registered major other than 17 run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + Assert-NothingRegistered $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-14' -Force New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '14.20-1' | Out-Null New-ItemProperty -Path $fake.PSPath -Name 'Service ID' -Value 'postgresql-x64-14' | Out-Null $fakeService = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-14' -Force New-ItemProperty -Path $fakeService.PSPath -Name 'Port' -PropertyType DWord -Value 15432 | Out-Null - - $token = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char] $_ }) + $token = New-Token $env:SEMIBASE_SUPER_PASSWORD = $token - $started = Get-Date - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru - # The machine had no PostgreSQL key before the fake one - Remove-Item -Recurse -Force 'HKLM:\SOFTWARE\PostgreSQL' - if ($setup.ExitCode -ne 7) { - Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" - exit 1 - } - $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime | Select-Object -Last 1 - if (-not $log) { - Write-Host '::error::the refused run wrote no setup log' - exit 1 - } - $text = Get-Content -Raw $log.FullName - Write-Host $text - $want = 'Installations\postgresql-x64-14 (version 14.20-1, service postgresql-x64-14, port 15432)' - if (-not $text.Contains($want)) { - Write-Host "::error::the setup log does not name '$want'" - exit 1 - } - if ($text.Contains($token)) { - Write-Host '::error::the setup log contains the superuser password' - exit 1 - } - - # Start-Process would rebuild the argument string; ProcessStartInfo passes it as - # written, so the " reaches Setup. The password parameter comes last, so an - # unbalanced " swallows no other parameter. LogRefusal appends to a log of the - # same second, so each run reads the last line. + $run = Invoke-Setup + Remove-Item -Recurse -Force $fake.PSPath, $fakeService.PSPath + Assert-Refused $run 'Installations\postgresql-x64-14 (version 14.20-1, service postgresql-x64-14, port 15432)' + $text = ($run.Logs | ForEach-Object { Get-Content -Raw $_.FullName }) -join "`n" + if ($text.Contains($token)) { Fail 'the setup log contains the superuser password' } + + # The superuser rules hold on the fresh-install row, which a clean machine takes. The + # password parameter comes last, so an unbalanced " swallows no other parameter. - name: Refuse a non-ASCII password and a password with a quote run: | - function Invoke-Refused([string] $arguments, [string] $want) { - $start = [Diagnostics.ProcessStartInfo]::new( - "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe", - "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART $arguments") - $start.UseShellExecute = $false - $started = Get-Date - $setup = [Diagnostics.Process]::Start($start) - $setup.WaitForExit() - if ($setup.ExitCode -ne 7) { - Write-Host "::error::want exit 7 with '$want', got $($setup.ExitCode)" - exit 1 - } - $log = Get-ChildItem 'C:\DISTR\Logs\SemiBase\setup-*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime | Select-Object -Last 1 - $last = if ($log) { Get-Content $log.FullName | Select-Object -Last 1 } - Write-Host "refusal: $last" - if (-not "$last".Contains($want)) { - Write-Host "::error::the setup log does not end with '$want'" - exit 1 - } - } - - Invoke-Refused "/SUPERPASSWORD=Aa1$([char] 0x0436)z" 'The superuser password must be printable ASCII' - Invoke-Refused '/SUPERPASSWORD=Aa1"z' 'does not reach Setup as typed' + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $nonAscii = [char] 0x0436 + Assert-Refused (Invoke-Setup "/SUPERPASSWORD=Aa1${nonAscii}z") 'The superuser password must be printable ASCII' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1"z') 'The /SUPERPASSWORD value does not reach Setup as typed' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z\') 'must not contain " or end in \' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=-Aa1z') 'must not start with -, or start or end with a space' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z Aa2z') 'does not start with /' + Assert-Refused (Invoke-Setup "/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1${nonAscii}z") 'The scada_writer password (/WRITERPASSWORD or SEMIBASE_WRITER_PASSWORD) must be printable ASCII' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1"z') 'The /WRITERPASSWORD value does not reach Setup as typed' $env:SEMIBASE_SUPER_PASSWORD = 'Aa1"z' - Invoke-Refused '' 'The superuser password must not contain "' - Remove-Item Env:SEMIBASE_SUPER_PASSWORD - - if (Get-Service postgresql* -ErrorAction SilentlyContinue) { - Write-Host '::error::a refused run left a postgresql* service' - exit 1 - } + Assert-Refused (Invoke-Setup) 'The superuser password must not contain "' + $env:SEMIBASE_SUPER_PASSWORD = ' Aa1z' + Assert-Refused (Invoke-Setup) 'must not start with -, or start or end with a space' + Assert-NoPostgresService - # The first run of the install path: Setup extracts EDB, installs it through the - # option file, then provisions the new service. The runner image sets a - # machine-level PGPASSWORD, which every psql call here overrides. + - name: Refuse an incomplete PostgreSQL 17 registration + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' -Force + New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '17.11-5' | Out-Null + New-ItemProperty -Path $fake.PSPath -Name 'Base Directory' -Value 'C:\Program Files\PostgreSQL\17' | Out-Null + $run = Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w' + Remove-Item -Recurse -Force $fake.PSPath + Assert-Refused $run 'is not a complete PostgreSQL 17 installation: it has no Super User. A failed PostgreSQL install leaves this; remove it with C:\Program Files\PostgreSQL\17\uninstall-postgresql.exe' + Assert-NoPostgresService + + - name: Refuse an orphaned PostgreSQL 17 registration + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $data = New-Item -ItemType Directory -Path "$env:RUNNER_TEMP\orphaned-data" -Force + Set-Content -Path "$data\PG_VERSION" -Value 17 + $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' -Force + New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '17.11-5' | Out-Null + New-ItemProperty -Path $fake.PSPath -Name 'Super User' -Value 'postgres' | Out-Null + New-ItemProperty -Path $fake.PSPath -Name 'Data Directory' -Value $data.FullName | Out-Null + New-ItemProperty -Path $fake.PSPath -Name 'Service ID' -Value 'no-such-service' | Out-Null + $run = Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w' + Remove-Item -Recurse -Force $fake.PSPath, $data + Assert-Refused $run 'names service no-such-service, which does not exist' + Assert-NoPostgresService + + - name: Refuse a busy port 5432 with nothing registered + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 5432) + $listener.Start() + try { $run = Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w' } finally { $listener.Stop() } + Assert-Refused $run 'No PostgreSQL is registered, but localhost:5432 accepts TCP' + Assert-NoPostgresService + + - name: Refuse a non-empty default data directory + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + if (Test-Path 'C:\Program Files\PostgreSQL') { Fail 'C:\Program Files\PostgreSQL exists before the case' } + New-Item -ItemType Directory -Path 'C:\Program Files\PostgreSQL\17\data' -Force | Out-Null + Set-Content -Path 'C:\Program Files\PostgreSQL\17\data\left-behind.txt' -Value 'data' + $run = Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w' + Remove-Item -Recurse -Force 'C:\Program Files\PostgreSQL' + Assert-Refused $run 'No PostgreSQL is registered, but C:\Program Files\PostgreSQL\17\data exists and is not empty' + Assert-NoPostgresService + + - name: Refuse a fresh install without a scada_writer password + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z') 'No scada_writer password' + Assert-NoPostgresService + + # The first run of the install path, through the documented variable channel: EDB + # and initcluster.ps1 inherit SEMIBASE_* from Setup, so the log search has a real + # target. A job watches Setup's {tmp} while it runs: Inno removes {tmp} at exit, + # so only a watch during the run can see the option file come and go. - name: Install and provision PostgreSQL 17 on a clean machine timeout-minutes: 15 run: | - $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" - $psql = "$env:PGBIN\psql.exe" - $started = Get-Date - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/SUPERPASSWORD=$super", "/WRITERPASSWORD=$writer" ` - -Wait -PassThru - $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime) - $logs | ForEach-Object { - Write-Host "--- $($_.Name)" - "$(Get-Content -Raw $_.FullName)".Replace($super, '').Replace($writer, '') | Write-Host - } - if ($setup.ExitCode -ne 0) { - Write-Host "::error::want exit 0, got $($setup.ExitCode)" - exit 1 + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) + $super, $writer = $tokens[1], $tokens[2] + $env:SEMIBASE_SUPER_PASSWORD = $super + $env:SEMIBASE_WRITER_PASSWORD = $writer + $since = Get-Date + # The site log is read first, so an option file seen after it was really there then + $watch = Start-Job -ArgumentList $env:TEMP, $LogDir, $since -ScriptBlock { + param($temp, $logDir, $since) + $seen = $false + while ($true) { + $site = @(Get-ChildItem "$logDir\site-*.log" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -ge $since }) + $option = @(Get-ChildItem "$temp\is-*.tmp\postgresql-options.txt" -Force -ErrorAction SilentlyContinue) + if ($option.Count -ne 0) { $seen = $true } + if ($site.Count -ne 0) { return [pscustomobject]@{ Seen = $seen; PresentWithSiteLog = $option.Count -ne 0 } } + Start-Sleep -Milliseconds 200 + } } - $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 - if (-not $site -or -not "$(Get-Content -Raw $site.FullName)".Contains('restarting service postgresql-x64-17')) { - Write-Host '::error::the site log does not record restarting service postgresql-x64-17' - exit 1 + $run = Invoke-Setup + $watched = $watch | Wait-Job -Timeout 30 | Receive-Job + $watch | Remove-Job -Force + Write-Logs $run $tokens + if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } + if (-not $watched) { Fail 'the option-file watch saw no site log' } + if (-not $watched.Seen) { Fail 'the watch never saw the option file, so it watched the wrong place' } + if ($watched.PresentWithSiteLog) { Fail 'the option file was still there when the site log appeared' } + if (-not (Get-SiteLog $run).Contains('restarting service postgresql-x64-17')) { + Fail 'the site log does not record restarting service postgresql-x64-17' } $env:PGPASSWORD = $super function Invoke-Super([string] $sql) { - $out = & $psql -w -h 127.0.0.1 -U postgres -d postgres -tAc $sql - if ($LASTEXITCODE -ne 0) { - Write-Host "::error::psql as postgres failed on: $sql" - exit 1 - } + $out = & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -tAc $sql + if ($LASTEXITCODE -ne 0) { Fail "psql as postgres failed on: $sql" } "$out".Trim() } $pending = Invoke-Super 'SELECT count(*) FROM pg_settings WHERE pending_restart' @@ -606,125 +556,110 @@ jobs: "WHERE datname IN ('template1', 'scada_archive')") $wantDatabases = 'scada_archive|C|C|UTF8,template1|C|C|UTF8' if ($pending -ne '0' -or $listen -ne 'localhost' -or $databases -ne $wantDatabases) { - Write-Host "::error::pending_restart count '$pending' (want 0), listen_addresses '$listen' (want localhost), databases '$databases' (want '$wantDatabases')" - exit 1 - } - $env:PGPASSWORD = 'semiplot' - & $psql -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' - if ($LASTEXITCODE -ne 0) { - Write-Host '::error::semiplot with password semiplot cannot log in' - exit 1 + Fail "pending_restart count '$pending' (want 0), listen_addresses '$listen' (want localhost), databases '$databases' (want '$wantDatabases')" } + Assert-Login semiplot semiplot + # The production re-run: the superuser password alone, as a parameter this time. + # Statement logging goes on first, so the log search in the password case below + # reads a server log that would carry a leaked password statement. - name: Run the same setup again timeout-minutes: 15 run: | - $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" - $started = Get-Date - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/SUPERPASSWORD=$super", "/WRITERPASSWORD=$writer" ` - -Wait -PassThru - $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime) - $logs | ForEach-Object { - Write-Host "--- $($_.Name)" - "$(Get-Content -Raw $_.FullName)".Replace($super, '').Replace($writer, '') | Write-Host - } - if ($setup.ExitCode -ne 0) { - Write-Host "::error::want exit 0, got $($setup.ExitCode)" - exit 1 - } - $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 - if (-not $site) { - Write-Host '::error::the run wrote no site log' - exit 1 - } - if ("$(Get-Content -Raw $site.FullName)".Contains('restarting service')) { - Write-Host '::error::the second run restarted the service with nothing pending' - exit 1 - } + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) + $super, $writer = $tokens[1], $tokens[2] + $env:PGPASSWORD = $super + & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -qtAc "ALTER SYSTEM SET log_statement = 'all'" + if ($LASTEXITCODE -ne 0) { Fail 'could not set log_statement' } + & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -qtAc 'SELECT pg_reload_conf()' + if ($LASTEXITCODE -ne 0) { Fail 'could not reload the configuration' } + $run = Invoke-Setup "/SUPERPASSWORD=$super" + Write-Logs $run $tokens + if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } + if ((Get-SiteLog $run).Contains('restarting service')) { Fail 'the second run restarted the service with nothing pending' } + Assert-Login scada_writer $writer + + - name: Change the scada_writer password on a re-run + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) + $env:SEMIBASE_WRITER_PASSWORD = $tokens[3] + $run = Invoke-Setup "/SUPERPASSWORD=$($tokens[1])" + Write-Logs $run $tokens + if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } + Assert-Login scada_writer $tokens[3] + + # Every cluster in the other cases sits on 5432, so only a wrong registered + # port shows that Setup waits on the port the registry names + - name: Refuse a registered port nothing answers on + timeout-minutes: 5 + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) + $key = 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-17' + Set-ItemProperty -Path $key -Name Port -Value 15433 -Type DWord + try { $run = Invoke-Setup "/SUPERPASSWORD=$($tokens[1])" } finally { Set-ItemProperty -Path $key -Name Port -Value 5432 -Type DWord } + Assert-Refused $run 'Service postgresql-x64-17 is started, but localhost:15433 accepted no TCP connection within 60 s' - name: Refuse a wrong superuser password run: | - $started = Get-Date - $setup = Start-Process -FilePath "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SUPERPASSWORD=wrong' -Wait -PassThru - $logs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' | Where-Object { $_.LastWriteTime -ge $started } | - Sort-Object LastWriteTime) - $logs | ForEach-Object { Write-Host "--- $($_.Name)"; Get-Content -Raw $_.FullName | Write-Host } - if ($setup.ExitCode -ne 7) { - Write-Host "::error::want exit 7, a refusal from PrepareToInstall, got $($setup.ExitCode)" - exit 1 - } - $site = $logs | Where-Object { $_.Name -like 'site-*' } | Select-Object -Last 1 - if (-not $site -or -not "$(Get-Content -Raw $site.FullName)".Contains('password authentication failed')) { - Write-Host '::error::the site log does not name the failed superuser connection' - exit 1 + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $run = Invoke-Setup '/SUPERPASSWORD=wrong' + Write-Logs $run + Assert-Refused $run 'semibase site exited with code' + if (-not (Get-SiteLog $run).Contains('password authentication failed')) { + Fail 'the site log does not name the failed superuser connection' } # No case passes /LOG: Inno's own log records Setup's command line, passwords # included. The search prints file names and hit counts, never a matched line. + # Setup's {tmp} is not searched: Inno removes it at exit, and case 6 watched it. - name: Find no password in the logs run: | - $super, $writer = Get-Content "$env:RUNNER_TEMP\case-passwords.txt" + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) $edbLog = "$env:TEMP\install-postgresql.log" $temp = Get-ChildItem -LiteralPath $env:TEMP -Force | Where-Object { - $_.Name -like '*postgres*' -or $_.Name -like 'installbuilder*' -or $_.Name -like 'bitrock*' -or $_.Name -like 'is-*.tmp' + $_.Name -like '*postgres*' -or $_.Name -like 'installbuilder*' -or $_.Name -like 'bitrock*' } - $semibaseLogs = @(Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log') + $semibaseLogs = @(Get-ChildItem "$LogDir\*.log") $serverLogs = @(Get-ChildItem 'C:\Program Files\PostgreSQL\17\data\log' -File -Force) if (-not (Test-Path $edbLog) -or $semibaseLogs.Count -eq 0 -or $serverLogs.Count -eq 0) { - Write-Host "::error::nothing to search: $edbLog present $(Test-Path $edbLog), $($semibaseLogs.Count) SemiBase logs, $($serverLogs.Count) server logs" - exit 1 + Fail "nothing to search: $edbLog present $(Test-Path $edbLog), $($semibaseLogs.Count) SemiBase logs, $($serverLogs.Count) server logs" + } + # check.go's plotProbe, which reaches the server log only under log_statement = 'all' + if (-not (Select-String -LiteralPath $serverLogs.FullName -SimpleMatch -Pattern 'FROM public.trends LIMIT 1) probe' -Quiet)) { + Fail 'the server log lacks the semiplot probe, so statement logging was off and the search has no teeth' } $files = @($temp | Where-Object { -not $_.PSIsContainer }) + @($temp | Where-Object { $_.PSIsContainer } | Get-ChildItem -Recurse -File -Force) + $semibaseLogs + $serverLogs $files | ForEach-Object { Write-Host "searched: $($_.FullName)" } - $hits = @(Select-String -LiteralPath $files.FullName -SimpleMatch -Pattern $super, $writer) + $hits = @(Select-String -LiteralPath $files.FullName -SimpleMatch -Pattern $tokens) if ($hits.Count -ne 0) { $hits | ForEach-Object { Write-Host "password found in $($_.Path):$($_.LineNumber)" } - Write-Host "::error::$($hits.Count) password hits" - exit 1 + Fail "$($hits.Count) password hits" } - $optionFiles = @(Get-ChildItem -LiteralPath $env:TEMP -Recurse -Force -Filter 'postgresql-options.txt' -ErrorAction SilentlyContinue) - if ($optionFiles.Count -ne 0) { - Write-Host "::error::option file left: $($optionFiles.FullName -join ', ')" - exit 1 - } - # Case 6 passes parameters, so this tells whether EDB's log would carry an - # inherited SEMIBASE_* variable at all - Write-Host "install-postgresql.log records the environment: $(Select-String -LiteralPath $edbLog -SimpleMatch -Pattern 'RUNNER_TEMP' -Quiet)" # Inno's uninstaller re-runs itself from %TEMP%, so semibase.exe may outlive # the process Start-Process waited for. - name: Uninstall SemiBase and keep PostgreSQL run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $app = "$env:ProgramFiles\SemiBase" $uninstall = Start-Process -FilePath "$app\unins000.exe" ` -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru - if ($uninstall.ExitCode -ne 0) { - Write-Host "::error::want exit 0, got $($uninstall.ExitCode)" - exit 1 - } + if ($uninstall.ExitCode -ne 0) { Fail "want exit 0, got $($uninstall.ExitCode)" } $deadline = (Get-Date).AddSeconds(60) while (Test-Path "$app\semibase.exe") { - if ((Get-Date) -gt $deadline) { - Write-Host '::error::semibase.exe is still there 60 s after the uninstaller exited' - exit 1 - } + if ((Get-Date) -gt $deadline) { Fail 'semibase.exe is still there 60 s after the uninstaller exited' } Start-Sleep -Seconds 1 } - if (-not (Get-Service postgresql-x64-17 -ErrorAction SilentlyContinue)) { - Write-Host '::error::the uninstall removed postgresql-x64-17' - exit 1 - } - $env:PGPASSWORD = 'semiplot' - & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U semiplot -d scada_archive -tAc 'SELECT count(*) FROM semiplot_tags' - if ($LASTEXITCODE -ne 0) { - Write-Host '::error::semiplot with password semiplot cannot log in after the uninstall' - exit 1 - } + if (-not (Get-Service postgresql-x64-17 -ErrorAction SilentlyContinue)) { Fail 'the uninstall removed postgresql-x64-17' } + if (@(Get-ChildItem "$LogDir\*.log" -ErrorAction SilentlyContinue).Count -eq 0) { Fail "the uninstall removed the logs in $LogDir" } + if (-not (Test-Path $ToolExe)) { Fail "the uninstall removed the recovery copy $ToolExe" } + Assert-Login semiplot semiplot # Everything printed here passes the redaction first: the EDB log masks the # superuser password itself, and the setup and site logs carry none by design. diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index 4d8d35f..08a7671 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -16,10 +16,12 @@ #define AppPublisher "Inc Semiteq" #define AppId "{{5AFFFB1F-3C84-45EB-800A-67812EBAE460}" -; The file name carries the version pinned in fetch-postgresql.sh; a new pin changes both -#define PgInstaller "postgresql-17.11-5-windows-x64.exe" +; fetch-postgresql.sh saves the pinned build under this name and refuses a file that does not match the pin +#define PgInstaller "postgresql-17-windows-x64.exe" #define LogRoot "C:\DISTR\Logs\SemiBase" +; Every run copies semibase.exe here, so a machine where Setup never succeeded still has the recovery tool +#define ToolRoot "C:\DISTR\Tools\SemiBase" ; The PostgreSQL 17 install this setup creates: its registry key, service, port and data directory #define PgInstallation "postgresql-x64-17" @@ -57,9 +59,6 @@ PrivilegesRequired=admin Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" Name: "english"; MessagesFile: "compiler:Default.isl" -[Dirs] -Name: "{#LogRoot}"; Flags: uninsneveruninstall - [Files] Source: "{#SemibaseExe}"; DestDir: "{app}"; DestName: "semibase.exe"; Flags: ignoreversion @@ -72,6 +71,21 @@ function SetEnvironmentVariable(lpName, lpValue: String): BOOL; function GetTickCount: Cardinal; external 'GetTickCount@kernel32.dll stdcall'; +const + InstallationsKey = 'SOFTWARE\PostgreSQL\Installations'; + ServicesKey = 'SOFTWARE\PostgreSQL\Services'; + RecoveryHint = ' See the recovery section of the SemiBase deployment guide, docs/deployment.md ' + + 'on GitHub.'; + PortWaitMilliseconds = 60000; + ErrorServiceAlreadyRunning = 1056; + +type + TEngine = record + ServiceName: String; + Port: Cardinal; + SuperUser: String; + end; + function CommandLineParameter(const Name: String): String; var Prefix: String; @@ -102,8 +116,24 @@ begin ValueStart := Pos(Uppercase(Prefix), Uppercase(Tail)) + Length(Prefix); if Copy(Tail, ValueStart, Length(Value)) <> Value then Result := 'The /' + Name + ' value does not reach Setup as typed: Setup drops every " from ' + - 'its command line. Put the quotes around the whole parameter, or pass the password ' + - 'through ' + VariableName + '.'; + 'its command line. Put the quotes around the whole parameter; a password that contains " ' + + 'reaches Setup only through ' + VariableName + '.'; +end; + +// An unquoted space splits a password parameter, and Setup would keep the part before it +function StrayArgumentRefusal: String; +var + I: Integer; +begin + Result := ''; + for I := 1 to ParamCount do + if Copy(ParamStr(I), 1, 1) <> '/' then + begin + Result := 'Setup argument ' + IntToStr(I) + ' does not start with /. A space in an ' + + 'unquoted password parameter splits it there: put the quotes around the whole ' + + 'parameter, or pass the password through its SEMIBASE_* variable.'; + Exit; + end; end; function ResolvePassword(const ParameterName, VariableName: String): String; @@ -126,37 +156,35 @@ begin end; end; +// The scada_writer rule holds on every run: the SCADA logs in with that password from its own +// project settings, whose encoding of anything else nobody has measured function PasswordRefusal(const SuperPassword, WriterPassword: String): String; begin Result := ''; if SuperPassword = '' then Result := 'No superuser password: pass /SUPERPASSWORD= or set SEMIBASE_SUPER_PASSWORD.' - else if not IsPrintableAscii(SuperPassword) then + else if not IsPrintableAscii(WriterPassword) then + Result := 'The scada_writer password (/WRITERPASSWORD or SEMIBASE_WRITER_PASSWORD) must be ' + + 'printable ASCII.'; +end; + +// The PostgreSQL installer alone needs these rules, so they hold on the fresh-install row only +function FreshPasswordRefusal(const SuperPassword: String): String; +begin + Result := ''; + if not IsPrintableAscii(SuperPassword) then Result := 'The superuser password must be printable ASCII: the PostgreSQL installer stores it ' + 'in the ANSI code page, and semibase sends it in UTF-8.' else if (Pos('"', SuperPassword) > 0) or (SuperPassword[Length(SuperPassword)] = '\') then Result := 'The superuser password must not contain " or end in \: either one breaks the ' + 'PostgreSQL installer''s command line.' - else if not IsPrintableAscii(WriterPassword) then - Result := 'The scada_writer password (/WRITERPASSWORD or SEMIBASE_WRITER_PASSWORD) must be ' + - 'printable ASCII.'; + else if (SuperPassword[1] = '-') or (SuperPassword[1] = ' ') or + (SuperPassword[Length(SuperPassword)] = ' ') then + Result := 'The superuser password must not start with -, or start or end with a space: the ' + + 'PostgreSQL installer passes it as a command-line argument and reads it from a key=value ' + + 'file, and neither is known to keep such a password.'; end; -const - InstallationsKey = 'SOFTWARE\PostgreSQL\Installations'; - ServicesKey = 'SOFTWARE\PostgreSQL\Services'; - RecoveryHint = ' See the recovery section of docs/deployment.md.'; - PortWaitMilliseconds = 60000; - ErrorServiceAlreadyRunning = 1056; - -type - TEngine = record - Fresh: Boolean; - ServiceName: String; - Port: Cardinal; - SuperUser: String; - end; - function LogPath(const Prefix: String): String; begin Result := '{#LogRoot}\' + Prefix + '-' + GetDateTimeString('yyyymmdd-hhnnss', #0, #0) + '.log'; @@ -173,6 +201,15 @@ begin SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); end; +function KeepToolRefusal: String; +begin + Result := ''; + ExtractTemporaryFile('semibase.exe'); + if not ForceDirectories('{#ToolRoot}') or + not CopyFile(ExpandConstant('{tmp}\semibase.exe'), '{#ToolRoot}\semibase.exe', False) then + Result := 'Setup could not copy semibase.exe into {#ToolRoot}.' + RecoveryHint; +end; + // -1 when sc.exe did not run function ScExitCode(const Arguments: String): Integer; var @@ -190,15 +227,16 @@ begin Result := ScExitCode('query "' + Name + '"') = 0; end; -// Pascal Script has no sockets. Returns False when the probe itself did not run. +// Pascal Script has no sockets. The probe dials localhost, as semibase does, so ::1 and 127.0.0.1 +// both count. Returns False when the probe itself failed, a constructor PowerShell refuses included. function ProbePort(Port: Cardinal; var Accepts: Boolean): Boolean; var ExitCode: Integer; begin Result := Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), - '-NoProfile -NonInteractive -Command "$c = New-Object Net.Sockets.TcpClient; ' + - 'try { $open = $c.ConnectAsync(''127.0.0.1'', ' + IntToStr(Port) + ').Wait(2000) } ' + - 'catch { $open = $false }; $c.Close(); if ($open) { exit 0 } else { exit 2 }"', + '-NoProfile -NonInteractive -Command "try { ([Net.Sockets.TcpClient]::new(''localhost'', ' + + IntToStr(Port) + ')).Close(); exit 0 } catch { if ($_.Exception.InnerException -is ' + + '[Net.Sockets.SocketException]) { exit 2 }; exit 3 }"', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) and ((ExitCode = 0) or (ExitCode = 2)); Accepts := ExitCode = 0; end; @@ -218,6 +256,12 @@ begin end; end; +// The cmd.exe line that runs semibase site expands % and ends its quoting at " +function IsCmdSafe(const Value: String): Boolean; +begin + Result := (Pos('"', Value) = 0) and (Pos('%', Value) = 0); +end; + // The 17 key exists: it is used only when it is a complete install with a service function RegisteredEngineRefusal(var Engine: TEngine): String; var @@ -252,6 +296,9 @@ begin if ServiceName = '' then Result := 'HKLM\' + Key + ' names no service. Setup does not repair an orphaned ' + 'registration.' + RecoveryHint + else if not (IsCmdSafe(SuperUser) and IsCmdSafe(ServiceName)) then + Result := 'HKLM\' + Key + ' has a Super User or Service ID that contains " or %, which ' + + 'the command line that runs semibase site would change.' + RecoveryHint else if not ServiceExists(ServiceName) then Result := 'HKLM\' + Key + ' names service ' + ServiceName + ', which does not exist. Setup ' + 'does not repair an orphaned registration.' + RecoveryHint @@ -298,15 +345,17 @@ begin end; // Nothing is registered: a fresh install must not meet a server or data it does not know -function FreshInstallRefusal(const WriterPassword: String): String; +function FreshInstallRefusal(const SuperPassword, WriterPassword: String): String; var Accepts: Boolean; begin - Result := ''; + Result := FreshPasswordRefusal(SuperPassword); + if Result <> '' then + Exit; if not ProbePort({#PgPort}, Accepts) then - Result := 'Setup could not probe 127.0.0.1:{#PgPort} through powershell.exe.' + RecoveryHint + Result := 'Setup could not probe localhost:{#PgPort} through powershell.exe.' + RecoveryHint else if Accepts then - Result := 'No PostgreSQL is registered, but 127.0.0.1:{#PgPort} accepts TCP. A server the ' + + Result := 'No PostgreSQL is registered, but localhost:{#PgPort} accepts TCP. A server the ' + 'registry does not know is not Setup''s to provision.' + RecoveryHint else if IsNonEmptyDirectory('{#PgDataDir}') then Result := 'No PostgreSQL is registered, but {#PgDataDir} exists and is not empty. Setup never ' + @@ -322,17 +371,17 @@ begin Result := not RegKeyExists(HKLM64, InstallationsKey + '\{#PgInstallation}'); end; -// The first matching state wins. An empty result fills Engine. -function EngineRefusal(const WriterPassword: String; var Engine: TEngine): String; +// The first matching state wins. An empty result fills Engine, except on the fresh row. +function EngineRefusal(Fresh: Boolean; const SuperPassword, WriterPassword: String; + var Engine: TEngine): String; begin - Engine.Fresh := EngineIsFresh; - if not Engine.Fresh then + if not Fresh then Result := RegisteredEngineRefusal(Engine) else begin Result := OtherMajorRefusal; if Result = '' then - Result := FreshInstallRefusal(WriterPassword); + Result := FreshInstallRefusal(SuperPassword, WriterPassword); end; end; @@ -404,7 +453,7 @@ begin repeat if not ProbePort(Port, Accepts) then begin - Result := 'Setup could not probe 127.0.0.1:' + IntToStr(Port) + ' through powershell.exe.' + + Result := 'Setup could not probe localhost:' + IntToStr(Port) + ' through powershell.exe.' + RecoveryHint; Exit; end; @@ -412,32 +461,34 @@ begin Exit; Sleep(1000); until GetTickCount - Started >= PortWaitMilliseconds; - Result := 'Service ' + ServiceName + ' is started, but 127.0.0.1:' + IntToStr(Port) + + Result := 'Service ' + ServiceName + ' is started, but localhost:' + IntToStr(Port) + ' accepted no TCP connection within 60 s.' + RecoveryHint; end; -// semibase inherits the passwords from Setup's environment, so none is on a command line +// Read back rather than trusted: an empty value may delete the variable or store it empty, and +// semibase reads either as unset +function SetPasswordVariable(const Name, Value: String): Boolean; +begin + SetEnvironmentVariable(Name, Value); + Result := GetEnv(Name) = Value; +end; + +// semibase inherits the passwords from Setup's environment, so none is on a command line. The +// writer variable is set even when empty, or a value inherited from the caller would reach semibase. function SiteRefusal(const Engine: TEngine; const SuperPassword, WriterPassword: String): String; var SiteLog: String; ExitCode: Integer; - Failed: Boolean; begin Result := ''; - Failed := False; - if not SetEnvironmentVariable('SEMIBASE_SUPER_PASSWORD', SuperPassword) then - Failed := True; - if not SetEnvironmentVariable('SEMIBASE_PLOT_PASSWORD', 'semiplot') then - Failed := True; - if WriterPassword <> '' then - if not SetEnvironmentVariable('SEMIBASE_WRITER_PASSWORD', WriterPassword) then - Failed := True; - if Failed then + if not (SetPasswordVariable('SEMIBASE_SUPER_PASSWORD', SuperPassword) and + SetPasswordVariable('SEMIBASE_WRITER_PASSWORD', WriterPassword) and + SetPasswordVariable('SEMIBASE_PLOT_PASSWORD', 'semiplot')) then begin - Result := 'Setup could not set the SEMIBASE_* password variables for semibase site.'; + Result := 'Setup could not set the SEMIBASE_* password variables for semibase site.' + + RecoveryHint; Exit; end; - ExtractTemporaryFile('semibase.exe'); ForceDirectories('{#LogRoot}'); SiteLog := LogPath('site'); if not Exec(ExpandConstant('{cmd}'), '/s /c ""' + ExpandConstant('{tmp}\semibase.exe') + @@ -445,10 +496,10 @@ begin '" --expected-major 17 --restart-service "' + Engine.ServiceName + '" > "' + SiteLog + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then Result := 'Setup could not start cmd.exe to run semibase site: ' + SysErrorMessage(ExitCode) + - '.' + '.' + RecoveryHint else if ExitCode <> 0 then Result := 'semibase site exited with code ' + IntToStr(ExitCode) + '; its log is ' + SiteLog + - '.'; + '.' + RecoveryHint; end; var @@ -481,10 +532,13 @@ begin Refusal := 'Enter the superuser password.' else Refusal := PasswordRefusal(PasswordPage.Values[0], PasswordPage.Values[1]); - if (Refusal = '') and (PasswordPage.Values[1] = '') and EngineIsFresh and - (OtherMajorRefusal = '') then - Refusal := 'Enter the scada_writer password: no PostgreSQL 17 is registered, so Setup ' + - 'installs it and creates the scada_writer role.'; + if (Refusal = '') and EngineIsFresh and (OtherMajorRefusal = '') then + begin + Refusal := FreshPasswordRefusal(PasswordPage.Values[0]); + if (Refusal = '') and (PasswordPage.Values[1] = '') then + Refusal := 'Enter the scada_writer password: no PostgreSQL 17 is registered, so Setup ' + + 'installs it and creates the scada_writer role.'; + end; if Refusal <> '' then begin MsgBox(Refusal, mbError, MB_OK); @@ -493,34 +547,45 @@ begin end; // A refusal returned here comes before Inno copies a file and exits 7 under /VERYSILENT; one -// raised later comes after the uninstaller is registered and may still exit 0. +// raised later comes after the uninstaller is registered and may still exit 0. An exception is +// turned into a refusal, so it reaches the setup log too. function PrepareToInstall(var NeedsRestart: Boolean): String; var SuperPassword, WriterPassword: String; + Fresh: Boolean; Engine: TEngine; begin SuperPassword := PasswordPage.Values[0]; WriterPassword := PasswordPage.Values[1]; - Result := ParameterRefusal('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); - if Result = '' then - Result := ParameterRefusal('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); - if Result = '' then - Result := PasswordRefusal(SuperPassword, WriterPassword); - if Result = '' then - Result := EngineRefusal(WriterPassword, Engine); - // The install's own registration, read like any other, fills Engine - if (Result = '') and Engine.Fresh then - begin - Result := InstallEngineRefusal(SuperPassword); + try + Fresh := EngineIsFresh; + Result := KeepToolRefusal; + if Result = '' then + Result := ParameterRefusal('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); + if Result = '' then + Result := ParameterRefusal('WRITERPASSWORD', 'SEMIBASE_WRITER_PASSWORD'); if Result = '' then - Result := RegisteredEngineRefusal(Engine); + Result := StrayArgumentRefusal; + if Result = '' then + Result := PasswordRefusal(SuperPassword, WriterPassword); + if Result = '' then + Result := EngineRefusal(Fresh, SuperPassword, WriterPassword, Engine); + // The install's own registration, read like any other, fills Engine + if (Result = '') and Fresh then + begin + Result := InstallEngineRefusal(SuperPassword); + if Result = '' then + Result := RegisteredEngineRefusal(Engine); + end; + if Result = '' then + Result := StartServiceRefusal(Engine.ServiceName); + if Result = '' then + Result := PortWaitRefusal(Engine.ServiceName, Engine.Port); + if Result = '' then + Result := SiteRefusal(Engine, SuperPassword, WriterPassword); + except + Result := 'Setup stopped on an error: ' + GetExceptionMessage + '.' + RecoveryHint; end; - if Result = '' then - Result := StartServiceRefusal(Engine.ServiceName); - if Result = '' then - Result := PortWaitRefusal(Engine.ServiceName, Engine.Port); - if Result = '' then - Result := SiteRefusal(Engine, SuperPassword, WriterPassword); if Result <> '' then LogRefusal(Result); end; diff --git a/Installer/fetch-postgresql.sh b/Installer/fetch-postgresql.sh index 8e43c93..f8b4717 100644 --- a/Installer/fetch-postgresql.sh +++ b/Installer/fetch-postgresql.sh @@ -7,7 +7,8 @@ readonly VERSION=17.11-5 readonly URL="https://get.enterprisedb.com/postgresql/postgresql-${VERSION}-windows-x64.exe" readonly SHA256=612c7f5400a003aaffa0372cb273eed52278f93c680ab911eb9cb2229e329072 -readonly FILE_NAME="${URL##*/}" +# A fixed name, so the pin lives here alone; Installer/SemiBase.iss names the same file +readonly FILE_NAME=postgresql-17-windows-x64.exe PAYLOAD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/payload" readonly PAYLOAD_DIR readonly PART_NAME="${FILE_NAME}.part" From 916dccbe024a47f219029ba0aa991861d8242536 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:36:21 +0300 Subject: [PATCH 28/35] fix(release): smoke-run the setup before publishing The release installer job compiled SemiBase-Setup.exe and attached it without ever running it. It now runs the shipped setup once with no password, requires the refusal, and requires the semibase.exe the setup copied out to print the tag. The build steps come from the shared build-setup action, so release and CI compile the same way. --- .github/workflows/release.yml | 45 +++++++++++++---------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 828ff00..5c3cd17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,38 +176,25 @@ jobs: name: binaries path: dist - - name: Cache the EDB payload - uses: actions/cache@v6 - with: - path: Installer/payload - key: edb-payload-${{ hashFiles('Installer/fetch-postgresql.sh') }} - - # shell: bash is Git Bash; a bare bash on the PATH may be WSL's - - name: Fetch the EDB payload - shell: bash - run: bash Installer/fetch-postgresql.sh - - # The jrsoftware.org direct download intermittently serves a corrupted file, so winget - # (preinstalled on windows runners) installs it instead. Machine scope keeps the - # Program Files (x86) path the next step expects. - - name: Install Inno Setup - run: | - winget install --id JRSoftware.InnoSetup --exact --scope machine --silent ` - --accept-source-agreements --accept-package-agreements --disable-interactivity - if ($LASTEXITCODE -ne 0) { exit 1 } - "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - # VersionInfoVersion takes digits and dots only, so a prerelease suffix - # reaches AppVersion alone - name: Build the setup + uses: ./.github/actions/build-setup + with: + app-version: ${{ needs.build.outputs.version }} + semibase-exe: ${{ github.workspace }}\dist\semibase_${{ needs.build.outputs.version }}_windows_amd64.exe + + # The only run of the shipped setup before it is published. It has to refuse, + # since the runner's PostgreSQL is not this job's to change, and every refusal + # first copies the carried semibase.exe out, so that copy's version is the + # shipped binary's. + - name: Smoke-run the setup env: - VERSION: ${{ needs.build.outputs.version }} + TAG: ${{ needs.build.outputs.tag }} run: | - $fileVersion = $env:VERSION.Split('-')[0] - iscc "/DAppVersion=$env:VERSION" "/DFileVersion=$fileVersion" ` - "/DSemibaseExe=$env:GITHUB_WORKSPACE\dist\semibase_$($env:VERSION)_windows_amd64.exe" ` - Installer\SemiBase.iss - if ($LASTEXITCODE -ne 0) { exit 1 } + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + if (Test-Path Env:SEMIBASE_SUPER_PASSWORD) { Fail 'SEMIBASE_SUPER_PASSWORD is set, so the setup would not refuse' } + Assert-Refused (Invoke-Setup) 'No superuser password' + $version = & $ToolExe version + if ($LASTEXITCODE -ne 0 -or "$version".Trim() -ne $env:TAG) { Fail "the carried semibase.exe prints '$version' (want $env:TAG)" } - name: Upload the setup uses: actions/upload-artifact@v7 From 9786cae50ef22b17e9b6d3d2acabe38df117c024 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:17:44 +0300 Subject: [PATCH 29/35] docs(installer): align the docs with the setup fixes The recovery section points at the copy every run leaves in C:\DISTR\Tools\SemiBase and gives per-state inputs for the manual run; the password rules, refusal rows, pens step, port and writer re-run notes match the setup; CLAUDE.md, installer.md and the plan describe the new CI cases, and the plan records skipped items as skipped. --- CLAUDE.md | 68 +++++++---- README.md | 9 +- docs/architecture/installer.md | 124 +++++++++++++++----- docs/architecture/overview.md | 8 +- docs/architecture/provisioning.md | 6 +- docs/deployment.md | 142 ++++++++++++++--------- docs/plans/20261019-windows-installer.md | 125 ++++++++++++-------- 7 files changed, 320 insertions(+), 162 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 061b068..a122346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,16 +23,20 @@ go build -ldflags "-X main.revision=" -o semibase.exe ./cmd/semibase # re Without ldflags, `version` falls back to the VCS revision recorded by the Go toolchain. -The setup needs Inno Setup 6 (`winget install --id JRSoftware.InnoSetup --exact`) and the pinned -EDB installer, which the fetch script downloads and checks against its SHA256: +The setup needs Inno Setup 6.7.3 (`winget install --id JRSoftware.InnoSetup --exact --version +6.7.3`, the version its command-line and silent-mode behaviour was measured on) and the pinned EDB +installer, which the fetch script downloads and checks against its SHA256: ```powershell -bash Installer/fetch-postgresql.sh # Git Bash's bash, not WSL's; into Installer/payload/ +& "$env:ProgramFiles\Git\bin\bash.exe" Installer/fetch-postgresql.sh # a bare bash may be WSL's iscc /DAppVersion=0.0.0-dev /DFileVersion=0.0.0 "/DSemibaseExe=$PWD\semibase.exe" Installer\SemiBase.iss ``` The output is `Installer/Output/SemiBase-Setup.exe`. Under Git Bash, prefix `iscc` with -`MSYS2_ARG_CONV_EXCL='*'`, or MSYS rewrites the `/D` arguments as paths. +`MSYS2_ARG_CONV_EXCL='*'`, or MSYS rewrites the `/D` arguments as paths. The fetch script holds +the EDB pin alone and saves it as `Installer/payload/postgresql-17-windows-x64.exe`, the name +`#define PgInstaller` uses, so a pin bump edits the script only. CI and release build the setup +through one composite action, `.github/actions/build-setup`. ## Release @@ -44,7 +48,8 @@ no extension on the Linux artifact, an ELF is executable by its mode bit. `-X main.revision=` is embedded, so `semibase version` prints the tag. `installer` (`windows-latest`) fetches the EDB payload, installs Inno Setup through `winget` and compiles `SemiBase-Setup.exe` around the Windows binary (`/DAppVersion=`, `/DFileVersion=` the -part before the first `-`). `release` (`ubuntu-latest`) restores the Linux binary's exec bit, +part before the first `-`), then smoke-runs it once without a password: it must refuse with exit +7, and the `semibase.exe` it copied to `C:\DISTR\Tools\SemiBase` must print the tag. `release` (`ubuntu-latest`) restores the Linux binary's exec bit, which artifact transfer drops, and attaches two assets, the Linux binary and the setup; the Windows binary is not attached, the setup carries it. @@ -99,10 +104,11 @@ golangci-lint run ``` golangci-lint is pinned to 2.12.2 (`winget install GolangCI.golangci-lint --version 2.12.2`); -config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build`, `go test -race`, -and the same lint on `windows-latest` and `ubuntu-latest` for every push and pull request. +config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build` and `go test -race` +on `windows-latest` and `ubuntu-latest` for every push and pull request, and the lint in the +Windows `build` job only. The Windows job then runs the `Restart the runner's PostgreSQL service through site` step, the -only test of the service restart. It requires `site --restart-service postgresql-x64-17` to +only direct test of the service restart; the `installer` job runs it through the setup. It requires `site --restart-service postgresql-x64-17` to refuse the runner's shipped Disabled service with no `ALTER SYSTEM SET` line, sets it to Manual and starts it with `sc.exe start`, which returns while it is Start Pending. It then requires the next run, started at once, to print `restarting service postgresql-x64-17`, the line tying the @@ -135,20 +141,29 @@ requires the passwords absent. The `listen_addresses`, the pens and the 17 log s on the runner; to run them locally, point `psql` at a container. The Windows step uses the runner's own `$env:PGBIN\psql.exe`. -The `installer` job (`windows-latest`) builds `semibase.exe`, fetches the EDB payload (cached on -the fetch script's hash), installs Inno Setup and compiles the setup; the Linux job shellchecks -the fetch script. It then runs the setup silently through `Start-Process -Wait -PassThru`, since -the call operator does not wait for a GUI exe, and asserts each exit code, ten cases in order: -no superuser password refuses with 7, installs no `semibase.exe` and leaves the runner's service -Disabled; the runner's own 17 provisions with exit 0, Automatic, and `semiplot`/`semiplot` reads -`semiplot_tags`; the runner's PostgreSQL is uninstalled and its remains deleted; a fake -`postgresql-x64-14` key refuses, the setup log naming key, version, service and port and not the -password; a Cyrillic password and a `"` one refuse; a clean machine installs EDB 17 and -provisions it (`restarting service` in the site log, nothing pending, `listen_addresses` -`localhost`, `template1` and `scada_archive` `C`/`C`/`UTF8`); a second run restarts nothing; a -wrong superuser password refuses with `password authentication failed` in the site log; neither -generated password appears in any EDB, setup, site or server log; the uninstaller removes -`semibase.exe` and keeps PostgreSQL. No case passes `/LOG`, which records Setup's command line. +The `installer` job (`windows-latest`) builds `semibase.exe` and the setup through +`.github/actions/build-setup` (EDB payload cached on the fetch script's hash, Inno Setup 6.7.3); +the Linux job shellchecks the fetch script. Each case step dot-sources +`.github/scripts/installer-case.ps1`, which runs Setup through `ProcessStartInfo` (a GUI exe the +call operator does not wait for, with the argument string passed as written) and asserts a +refusal as exit 7 with its reason last in the newest setup log. In order: no superuser password +refuses, installs no `semibase.exe` in `{app}`, leaves the recovery copy in +`C:\DISTR\Tools\SemiBase` and the runner's service Disabled; the runner's own 17 provisions with +exit 0, Automatic, `semiplot`/`semiplot` and the generated `scada_writer` password log in; the +runner's PostgreSQL is uninstalled, leaving the empty `Installations` and `Services` keys; a fake +`postgresql-x64-14` key refuses without the password in the log; nine password and parameter +refusals (non-ASCII, `"`, trailing `\`, leading `-` and space, an unquoted split, the writer +rules); the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data +directory and a missing writer password refuse with no `postgresql*` service left; a clean +machine installs EDB 17 through the `SEMIBASE_*` variables while a job watches the option file +appear and vanish before the site log (`restarting service`, nothing pending, `localhost`, +`C`/`C`/`UTF8`); a superuser-only parameter re-run under `log_statement = 'all'` restarts +nothing and keeps the writer password; a re-run with a new writer password applies it; a wrong +registered port refuses after the 60 s wait; a wrong superuser password refuses with +`password authentication failed`; none of the four generated passwords appears in any EDB, setup, +site or server log, and the server log holds the `semiplot` probe; the uninstaller removes +`semibase.exe` and keeps PostgreSQL, the logs and the recovery copy. No case passes `/LOG`, which +records Setup's command line. Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), table-driven. There are no database-touching tests in the repository; the integration checks are @@ -169,6 +184,9 @@ gofmt -w . # run before presenting changes; gofmt is authoritative .\semibase.exe site --restart-service postgresql-x64-17 # also restart the service when settings wait .\semibase.exe bench --port 15432 --database semiplot_dev --expected-major 17 .\semibase.exe version # print the build revision +# the setup, in an elevated PowerShell with SEMIBASE_SUPER_PASSWORD (and on a first install +# SEMIBASE_WRITER_PASSWORD) set: exit 0 provisioned, 7 refused; logs in C:\DISTR\Logs\SemiBase +(Start-Process .\Installer\Output\SemiBase-Setup.exe '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru).ExitCode ``` `site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` server settings, @@ -221,7 +239,9 @@ may not and issues no password statement (`docs/architecture/provisioning.md#invariants-the-provision-package-owns`). The writer and `semiplot` passwords are needed on a first run, which creates those roles. Otherwise the superuser password alone carries a run, and the `semiplot` password only decides whether the run -also tests that role's TCP login. `public.trends` is created under `SET ROLE scada_writer` on the +also tests that role's TCP login. The setup takes no `semiplot` password: every run sets +`SEMIBASE_PLOT_PASSWORD=semiplot`, so it resets the role to that convention each time +(`docs/architecture/installer.md#the-setup`). `public.trends` is created under `SET ROLE scada_writer` on the superuser connection, so the table's owner is the role the SCADA writes with and no `pg_hba.conf` line has to admit a `scada_writer` login. @@ -242,6 +262,8 @@ sql/ embedded DDL: the five semiplot_* files (ours: the tables, Installer/ SemiBase.iss (the Inno Setup script: engine states, EDB install, the site run, all in PrepareToInstall) and fetch-postgresql.sh (the pinned EDB payload into the git-ignored payload/; ISCC writes Output/) +.github/ workflows/ (ci, release), actions/build-setup (fetch, Inno Setup, iscc for + both), scripts/ (CI helpers; installer-case.ps1 for the setup's cases) docs/ human docs in Russian (enter at docs/readme.md) docs/architecture/ agent-facing design docs in English docs/plans/ dated implementation plans diff --git a/README.md b/README.md index d5c4922..3278468 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус | Компонент | Требование | | ---------- | ------------------------------------------------ | -| DB | PostgreSQL 17; минимально поддерживаемая — 14 | +| DB | PostgreSQL 17. Установщик работает только с 17; утилита на тестовых стендах принимает 14 и новее | | Writer | Simple-Scada 2 с архивацией в PostgreSQL | Установщик `SemiBase-Setup.exe` — один файл на странице -[Releases](https://github.com/Semiteq/SemiBase/releases/latest). Он несёт PostgreSQL 17 и ничего не -скачивает. +[Releases](https://github.com/Semiteq/SemiBase/releases/latest). Он несёт PostgreSQL 17 и утилиту +`semibase.exe` и ничего не скачивает. Отдельного файла `semibase_<версия>_windows_amd64.exe` в +выпусках больше нет. ## Быстрое развёртывание @@ -31,6 +32,8 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус # всё в PowerShell от имени администратора: установщик ставит PostgreSQL и управляет его службой # пароли ролей: каждая строка запрашивает пароль, в историю PowerShell он не попадает $env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password +# пароль scada_writer нужен при первой установке; при повторных запусках оставьте его пустым: +# непустой пароль заменяет пароль существующей роли, и SCADA дальше входит только с ним $env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password # PostgreSQL 17, если его нет; конфигурация сервера, архивная база, роли `scada_writer`, diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index fb8d9fd..9d83d84 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -20,17 +20,42 @@ means provisioned. All of the work runs in `PrepareToInstall`, before Inno copies a file. A string it returns ends Setup with exit code 7 under `/VERYSILENT`; a failure raised later comes after the uninstaller is -registered and may still exit 0. Under `/VERYSILENT` the returned text reaches no one, and `/LOG` -would record the command line, so every refusal is also appended, password-free, to -`C:\DISTR\Logs\SemiBase\setup-.log`. The refusals about the engine end with -`See the recovery section of docs/deployment.md.` In order, `PrepareToInstall` checks that each -password parameter arrived as typed (`GetCmdTail`), checks the password characters, decides the -engine state, installs EDB on the fresh row, runs `sc.exe config start= auto` and -`sc.exe start`, waits up to 60 s for `127.0.0.1:` to accept TCP (a PowerShell `TcpClient` -probe through `Exec`, since Pascal Script has no sockets), and runs `semibase.exe site --port - --superuser --expected-major 17 --restart-service ` through -`cmd.exe /c` into `site-.log` beside the setup log. A non-zero exit refuses, -naming that log. +registered and may still exit 0. An exception inside `PrepareToInstall` becomes a refusal too. +Under `/VERYSILENT` the returned text reaches no one, and `/LOG` would record the command line, +so every refusal is also appended, password-free, to +`C:\DISTR\Logs\SemiBase\setup-.log`. Every refusal but the password and +parameter ones, which name their own fix, ends with `See the recovery section of the SemiBase +deployment guide, docs/deployment.md on GitHub.` + +In order, `PrepareToInstall`: + +1. copies the carried `semibase.exe` to `C:\DISTR\Tools\SemiBase\semibase.exe`, the recovery tool + for the manual run, so a machine where Setup never succeeded has one, offline included; +2. checks that each password parameter arrived as typed (`GetCmdTail`) and that every argument + starts with `/`, since an unquoted space splits a password parameter into two arguments; +3. requires a superuser password and a printable ASCII `scada_writer` password; +4. decides the engine state, applying the [superuser password rules](#password-characters) on the + fresh row only, and installs EDB there; +5. runs `sc.exe config start= auto` and `sc.exe start`, then waits up to 60 s for + `localhost:` to accept TCP; +6. runs `semibase.exe site --port --superuser --expected-major 17 + --restart-service ` through `cmd.exe /c` into `site-.log` beside + the setup log. A non-zero exit refuses, naming that log. + +The port probe is PowerShell through `Exec`, since Pascal Script has no sockets: +`[Net.Sockets.TcpClient]::new('localhost', )`, the name `semibase` dials too, so a server +on `::1` or on `127.0.0.1` counts. Exit 0 means accepted and 2 refused, the constructor's +`SocketException`; any other code, a constructor Constrained Language Mode forbids among them +(exit 3, measured), refuses. A TCP accept is enough to start `site`: with `--restart-service` it +waits for the service's Running state before its first connection, and `pg_ctl runservice -w` +reports Running only once the postmaster accepts sessions, crash recovery included +(`pgwin32_ServiceMain` in `src/bin/pg_ctl/pg_ctl.c`, REL_17_STABLE). A wrong superuser password +on a registered 17 is found by `site`, after the service was set to Automatic and started: only +a running server can check it. + +The registered `Super User` and `Service ID` go into the `cmd.exe /s /c` line between `"`, where +`cmd.exe` still expands `%...%` and a `"` ends the quoting, so a value with either is refused as +an orphaned registration is. The engine state comes from the `HKLM64` EDB keys, never from the service list alone; the first matching row wins: @@ -39,9 +64,10 @@ matching row wins: | --- | --- | | `Installations\postgresql-x64-17` with a `Version` starting `17.`, a `Super User`, `\PG_VERSION`, and a `Service ID` naming an existing service with a `Services\\Port` | Use it, even when other majors are registered too | | `Installations\postgresql-x64-17` with another `Version`, no `Super User` or no `PG_VERSION` | Refuse, naming `\uninstall-postgresql.exe`: a [failed install](#a-failed-install) leaves exactly this | -| `Installations\postgresql-x64-17` whose service or `Port` does not exist | Refuse: an orphaned registration is not the setup's to repair | +| `Installations\postgresql-x64-17` whose service or `Port` does not exist, or whose `Super User` or `Service ID` holds `"` or `%` | Refuse: an orphaned registration is not the setup's to repair | | Another major registered, 17 not | Refuse, naming each key's version, service and port | -| Nothing registered, `127.0.0.1:5432` accepts TCP | Refuse: a server the registry does not know is not the setup's to provision | +| Nothing registered, a superuser password that breaks the [password rules](#password-characters) | Refuse | +| Nothing registered, `localhost:5432` accepts TCP | Refuse: a server the registry does not know is not the setup's to provision | | Nothing registered, `C:\Program Files\PostgreSQL\17\data` not empty | Refuse: existing data is never overwritten | | Nothing registered, port free, no data directory | Refuse without a writer password; otherwise install EDB with the option file under [command line](#command-line), then re-read the new registration as in the first row | @@ -50,26 +76,38 @@ since no reboot code was reproduced ([exit codes](#exit-codes)). Every database- belongs to `semibase site`, and the setup duplicates none of them. Passwords stay off every child command line the setup controls. `[Code]` sets -`SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_PLOT_PASSWORD=semiplot` and, when given, -`SEMIBASE_WRITER_PASSWORD` in Setup's own environment through `SetEnvironmentVariableW`, after EDB -has run, and `semibase` inherits them. EDB reads its password from `{tmp}\postgresql-options.txt`, -deleted as soon as the EDB process returns. What stays exposed: Setup's own command line when the -caller passes the parameters, which Inno writes into its log under `/LOG` (`SetupLogging` stays -off); Setup's environment block, readable by another administrator during the run; and EDB's -`initcluster.ps1` command line and pwfile ([where the superuser password -goes](#where-the-superuser-password-goes)). Both passwords must be printable ASCII, and the -superuser password must not contain `"` or end in `\` ([password -characters](#password-characters)); a password with `"` reaches Setup only through its variable -([Inno Setup](#inno-setup)). Every run sets the `semiplot` password to `semiplot`, the convention +`SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD` and `SEMIBASE_PLOT_PASSWORD=semiplot` in +Setup's own environment through `SetEnvironmentVariableW`, after EDB has run, and `semibase` +inherits them. The writer variable is set even when empty, so a value the caller's environment +holds does not reach `semibase` after the user cleared the field; each variable is read back with +`GetEnv`, and `semibase` reads an empty value as unset. Setting them after EDB keeps them from EDB +for a caller who passes parameters only. A caller who uses the variables hands them to Setup's +environment, which EDB, `initcluster.ps1` and `initdb` inherit; the CI fresh install runs that +channel, and its log search covers the EDB logs. EDB reads its password from +`{tmp}\postgresql-options.txt`, deleted as soon as the EDB process returns. What stays exposed: +Setup's own command line when the caller passes the parameters, which Inno writes into its log +under `/LOG` (`SetupLogging` stays off); Setup's environment block, readable by another +administrator during the run; and EDB's `initcluster.ps1` command line and pwfile ([where the +superuser password goes](#where-the-superuser-password-goes)). + +The `scada_writer` password must be printable ASCII on every run: the SCADA logs in with it from +its own project settings, whose encoding of anything else nobody has measured. The superuser +rules exist for EDB alone and hold on the fresh row only ([password +characters](#password-characters)); on a registered 17 the password reaches `semibase` byte for +byte through the variable. A password with `"` reaches Setup only through its variable ([Inno +Setup](#inno-setup)). Every run sets the `semiplot` password to `semiplot`, the convention SemiPlot's connection file relies on, so the role has one writer. Without `/SILENT` or `/VERYSILENT`, a password page after the folder page holds both fields, prefilled the same way; its Next check only lets the user correct a field, and `PrepareToInstall` stays the enforcement. `semibase.exe` has one `[Files]` entry into `{app}`, and -`ExtractTemporaryFile` takes it to `{tmp}` for the run. So the `{app}` copy, the recovery tool, -exists only after a successful run. The uninstaller removes that copy alone; the engine, its -data, the roles and `C:\DISTR\Logs\SemiBase` stay. The `installer` job in -`.github/workflows/ci.yml` runs the setup end to end on `windows-latest`. +`ExtractTemporaryFile` takes it to `{tmp}` for the run and for the copy in +`C:\DISTR\Tools\SemiBase`. The `{app}` copy exists only after a successful run; the +`C:\DISTR\Tools\SemiBase` copy after any run, so the recovery section of `docs/deployment.md` +points there. The uninstaller removes the `{app}` copy alone; the engine, its data, the roles, +`C:\DISTR\Logs\SemiBase` and `C:\DISTR\Tools\SemiBase` stay, since no `[Dirs]` or `[Files]` entry +names them. The `installer` job in `.github/workflows/ci.yml` runs the setup end to end on +`windows-latest`, one case per reachable row of the engine table. ## The pinned payload @@ -81,6 +119,14 @@ data, the roles and `C:\DISTR\Logs\SemiBase` stay. The `installer` job in | Size | 413001200 bytes | | Signature | Authenticode, Valid, `CN=EnterpriseDB Corporation, O=EnterpriseDB Corporation, L=Wilmington, S=Delaware, C=US` | +`Installer/fetch-postgresql.sh` holds these constants alone and saves the file as +`Installer/payload/postgresql-17-windows-x64.exe`, the name `#define PgInstaller` in the `.iss` +uses, so a pin bump edits the script and nothing else. A file already there that does not match +the pin fails the script, so a stale payload never reaches `iscc` through a run that fetched +first. CI and release cache `Installer/payload/` on the script's hash: an edit to the script, +a comment included, costs one download of 413 MB (about 25 s), and a cached run never asks the +URL, so a retired URL surfaces on the first uncached run, not on the pull request that follows it. + On 2026-10-19 the EDB downloads page linked this build for Windows x86-64 (`fileid=1260614`). `get.enterprisedb.com` also serves builds 17.11-1 to 17.11-4. The installer is built with InstallBuilder: it writes `installbuilder_installer_.log` next to `install-postgresql.log` @@ -202,6 +248,12 @@ points at a `cmd.exe` layer between the installer and PowerShell; that mechanism read in EDB's source. A printable ASCII password without `"` that does not end in `\` passed in every combination tried. +Every password measured starts with `Aa1` and has spaces only inside it. A password starting with +`-` could bind as a parameter name of `initcluster.ps1`, and InstallBuilder could trim spaces at +either end of an option-file value; neither was measured, so the setup refuses both on the fresh +row along with non-ASCII, `"` and a trailing `\`. These rules guard EDB's command line and option +file alone, so a registered 17 never meets them. + ## A failed install A failed `initcluster.ps1` leaves the installed files, the uninstaller and the `Uninstall` entry, @@ -253,6 +305,9 @@ Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that h - `GetCmdTail` returns the command line as typed, quotes included, after a leading `/SL5="..."` that the bootstrapper adds. The setup compares the two and refuses a password parameter whose value does not appear in it verbatim. +- An unquoted `/SUPERPASSWORD=a b` arrives as two arguments, `/SUPERPASSWORD=a` and `b`, and the + first matches the tail verbatim. So the setup also refuses any argument that does not start + with `/`; the bootstrapper's `/SL5=...` is argument 1 and does. Measured 2026-10-19 the same way, with a scratch setup that extracts two text files and refuses: @@ -273,3 +328,16 @@ refusal right after the password checks: - A value written to `TInputQueryWizardPage.Values` and read back is unchanged, a tab, CR LF and Cyrillic included. So `PrepareToInstall` reads the passwords from the page in every mode, and under `/VERYSILENT` it sees exactly the parameter or `SEMIBASE_*` value. + +Measured 2026-10-19 the same way, ISCC 6.7.3, with a copy of the setup whose state checks run +against a registry key that does not exist and that refuses before the EDB install: + +- `SetEnvironmentVariableW` with an empty value returns TRUE whether the variable existed or not, + and `GetEnv` then reads `''`. +- An `ExtractTemporaryFile` of a missing entry raises; the `except` in `PrepareToInstall` turns + `GetExceptionMessage` into a refusal, exit 7, logged. +- `[Net.Sockets.TcpClient]::new('localhost', )` reached a listener on `127.0.0.1` and one + on `::1`, and a closed port exited 2 after about 7 s, Windows resending the SYN after the RST + on both addresses. Under Constrained Language Mode the constructor is refused and the probe + exits 3. +- `CopyFile` replaces `FileCopy`, which ISCC 6.7.3 reports as renamed. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8878fef..e6fbaa2 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -68,9 +68,11 @@ The order matters, because the default privileges have to be in place before any assumption, with the experiment that settles it, in `provisioning.md` ("Assumption: the SCADA meeting an existing `trends`"). Set `log_statement = 'all'` for that first start and read back the DDL the SCADA issued. -3. Start the viewer. Pens appear in `semiplot_tags` on their own, one per variable the SCADA has - written to `trends`, with default settings the viewer's editor then changes. A person never - inserts a pen, and only the superuser can delete one. +3. Start the viewer and press `Refresh pen list` in its pen editor. The button calls + `semiplot_register_new_pens()`, which adds one pen per variable the SCADA has written to + `trends`, with default settings the editor then changes. The viewer registers nothing at + startup, so a variable that appears later waits for the next press. A person never inserts a + pen by hand, and only the superuser can delete one. 4. Write the SemiPlot connection file: host `127.0.0.1`, the `semiplot` role and its password, `semiplot` by the setup's convention. The file names no time zone: the viewer runs on the SCADA machine and reads the archive in that machine's zone. diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 0f7266f..2314d7d 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -310,8 +310,10 @@ CI's Linux job runs `site` without the flag: its server is a GitHub Actions serv a Windows service, and nothing in the job restarts it. The `Require the listen_addresses delta` step then reads `listen_addresses` still pending and `localhost` in `postgresql.auto.conf` through `pg_file_settings`, which proves `site` wrote the delta. The Windows job's -`Restart the runner's PostgreSQL service through site` step is the one test of the restart -itself. The runner image ships PostgreSQL 17 as `postgresql-x64-17`, Stopped and Disabled. The +`Restart the runner's PostgreSQL service through site` step is the only direct test of the +restart; the `installer` job's cases run it through the setup, which passes `--restart-service` +on every run (`restarting service` in the site logs of the runner's 17 and of the fresh +install). The runner image ships PostgreSQL 17 as `postgresql-x64-17`, Stopped and Disabled. The step first requires `site --restart-service postgresql-x64-17` to refuse the Disabled service with no `ALTER SYSTEM SET` line in its output. It then sets the service to Manual, starts it with `sc.exe start`, and runs `site` at once, while the service is Start Pending. That run must print diff --git a/docs/deployment.md b/docs/deployment.md index 056dde4..9be28d0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -7,25 +7,29 @@ SemiBase разворачивается установщиком `SemiBase-Setup недостающее. Работу с базой установщик поручает утилите `semibase.exe` и кладёт её копию в -`C:\Program Files\SemiBase`. Вручную утилиту запускают для -[восстановления](#восстановление-после-отказа-установщика), когда установщик отказывается -работать, и на тестовых стендах. +`C:\Program Files\SemiBase` (или в папку, выбранную при установке). Каждый запуск установщика, +в том числе отказавший, кладёт ещё одну копию в `C:\DISTR\Tools\SemiBase`. Вручную утилиту +запускают для [восстановления](#восстановление-после-отказа-установщика), когда установщик +отказывается работать, и на тестовых стендах. ## Требования -| Компонент | Требование | -| --------- | --------------------------------------------- | -| СУБД | PostgreSQL 17; минимально поддерживаемая — 14 | -| Писатель | Simple-Scada 2 с архивацией в PostgreSQL | +| Компонент | Требование | +| --------- | ---------------------------------------------------------------------------------------------- | +| СУБД | PostgreSQL 17. Установщик работает только с 17; утилита на тестовых стендах принимает 14 и новее | +| Писатель | Simple-Scada 2 с архивацией в PostgreSQL | На странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest) у каждого выпуска два файла: | Файл | Назначение | | ------------------------------- | -------------------------------------------------------------------------------------------- | -| `SemiBase-Setup.exe` | Установщик для Windows x86-64. Несёт установщик PostgreSQL 17.11 от EDB и ничего не скачивает | +| `SemiBase-Setup.exe` | Установщик для Windows x86-64. Несёт утилиту и установщик PostgreSQL 17 от EDB, ничего не скачивает | | `semibase_<версия>_linux_amd64` | Утилита для Linux, для тестовых стендов смежных проектов | +Отдельного файла `semibase_<версия>_windows_amd64.exe` в выпусках больше нет: утилиту для +Windows несёт установщик. + Каждый выпуск также публикует образ контейнера `ghcr.io/semiteq/semibase` с той же Linux-сборкой. Он нужен только тестовым стендам смежных проектов. При пусконаладке ни образ, ни Linux-сборка не используются. @@ -55,7 +59,7 @@ $setup.ExitCode | Параметр | Переменная окружения | Назначение | | -------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | `/SUPERPASSWORD=<пароль>` | `SEMIBASE_SUPER_PASSWORD` | Пароль суперпользователя PostgreSQL. Нужен при каждом запуске, установщик его нигде не хранит. При новой установке PostgreSQL он становится паролем `postgres` | -| `/WRITERPASSWORD=<пароль>` | `SEMIBASE_WRITER_PASSWORD` | Пароль `scada_writer`. Нужен, когда роли ещё нет, то есть при первой установке. Если роль есть, пустое значение оставляет ей прежний пароль | +| `/WRITERPASSWORD=<пароль>` | `SEMIBASE_WRITER_PASSWORD` | Пароль `scada_writer`. Нужен, когда роли ещё нет, то есть при первой установке. Если роль есть, пустое значение оставляет ей прежний пароль, а непустое заменяет его, и SCADA дальше входит только с новым. При повторных запусках значение оставляют пустым | | `/VERYSILENT` | | Запуск без окон | | `/SUPPRESSMSGBOXES` | | Без диалоговых окон | | `/NORESTART` | | Без перезагрузки машины по завершении | @@ -65,13 +69,22 @@ $setup.ExitCode Роли `semiplot` установщик на каждом запуске задаёт пароль `semiplot`. На этот пароль рассчитан файл подключения SemiPlot, поэтому параметра для него нет. -Оба пароля состоят только из печатных символов ASCII: латиница, цифры, пробел и знаки препинания. -Пароль суперпользователя к тому же не содержит `"` и не заканчивается на `\`. Причина в установщике -PostgreSQL: он записывает пароль в кодировке ANSI, а утилита отправляет его в UTF-8, и кавычка или -конечная обратная косая черта ломают его командную строку. Эти правила установщик проверяет на -каждом запуске, в том числе на машине, где PostgreSQL 17 уже стоит. Все `"` из своей командной -строки установщик выбрасывает, поэтому пароль `scada_writer` с кавычкой передают только -переменной. Параметр, значение которого дошло не таким, каким его набрали, установщик отвергает. +Пароль `scada_writer` состоит только из печатных символов ASCII: латиница, цифры, пробел и знаки +препинания. SCADA входит с ним из настроек своего проекта, и как она кодирует другие символы, никто +не проверял. + +Пароль суперпользователя ограничен, только когда установщик ставит PostgreSQL 17, то есть когда на +машине не зарегистрирован ни один PostgreSQL. Тогда он состоит из печатных символов ASCII, не +содержит `"`, не заканчивается на `\`, не начинается с `-` и не начинается и не заканчивается +пробелом. Причина в установщике PostgreSQL: он записывает пароль в кодировке ANSI, а утилита +отправляет его в UTF-8; кавычка или конечная обратная косая черта ломают его командную строку, а +пароли с `-` или пробелом по краям на нём не проверялись. С уже установленным PostgreSQL 17 +пароль суперпользователя может быть любым: установщик передаёт его утилите без изменений. + +Все `"` из своей командной строки установщик выбрасывает, поэтому пароль с кавычкой передают +только переменной. Пароль с пробелом передают параметром, взятым в кавычки целиком: +`"/SUPERPASSWORD=два слова"`. Параметр, значение которого дошло не таким, каким его набрали, и +слово командной строки, которое не начинается с `/`, установщик отвергает. Пароль, переданный параметром, виден в списке процессов, пока установщик работает, и попадает в историю команд PowerShell, если его набрали в строке. С параметром `/LOG` установщик пишет в свой @@ -119,8 +132,8 @@ PostgreSQL: он записывает пароль в кодировке ANSI, Новая установка PostgreSQL 17: порт 5432, служба `postgresql-x64-17`, суперпользователь `postgres`, каталог данных `C:\Program Files\PostgreSQL\17\data`, локаль `C`, кодировка UTF8, без pgAdmin и -Stack Builder. Когда служба запущена, установщик до 60 с ждёт, пока порт начнёт принимать -подключения, и запускает утилиту: +Stack Builder. Когда служба запущена, установщик до 60 с ждёт, пока порт на `localhost` начнёт +принимать подключения, и запускает утилиту: ```powershell semibase.exe site --port <порт> --superuser <суперпользователь> --expected-major 17 --restart-service <служба> @@ -144,7 +157,7 @@ semibase.exe site --port <порт> --superuser <суперпользовате | Повторный запуск установщика | Работает с установленным PostgreSQL 17 и перезапускает службу, только если настройки ждут перезапуска | | Установщик PostgreSQL завершился с ошибкой | Отказ с путём к журналу установщика PostgreSQL. Следующий запуск видит неполную установку и отказывает | | Утилита отказала после установки PostgreSQL | PostgreSQL остаётся. Повторный запуск с тем же паролем суперпользователя работает с ним | -| Удаление SemiBase | Удаляется только `semibase.exe` | +| Удаление SemiBase | Удаляется только `semibase.exe` из папки установки | Перезапуск службы разрывает подключения SCADA. Список настроек, которые ждут перезапуска, выводит запрос `SELECT name FROM pg_settings WHERE pending_restart`. Если он не пуст, перед запуском @@ -152,8 +165,9 @@ semibase.exe site --port <порт> --superuser <суперпользовате ### Удаление -`C:\Program Files\SemiBase\unins000.exe` (или «Установка и удаление программ») удаляет только -`semibase.exe`. PostgreSQL, его данные, роли и журналы в `C:\DISTR\Logs\SemiBase` остаются. +`unins000.exe` в папке установки, по умолчанию `C:\Program Files\SemiBase` (или «Установка и +удаление программ»), удаляет только `semibase.exe` из этой папки. PostgreSQL, его данные, роли, +журналы в `C:\DISTR\Logs\SemiBase` и копия утилиты в `C:\DISTR\Tools\SemiBase` остаются. ## Порядок ввода в эксплуатацию @@ -178,15 +192,18 @@ semibase.exe site --port <порт> --superuser <суперпользовате `log_statement = 'all'` и прочитайте в журнале PostgreSQL, какие DDL-команды SCADA выдала: если она создаёт или изменяет `trends`, порядок ввода в эксплуатацию нужно пересмотреть. -3. Запустить SemiPlot. Перья появляются в `semiplot_tags` сами: по одному на каждую переменную, - которую SCADA уже записала в `trends`, с настройками по умолчанию. Переменные, появившиеся - позже, добавляет следующий запуск SemiPlot или кнопка обновления в редакторе перьев. Дальше их - меняют в редакторе SemiPlot. Вручную перья не добавляют, а удалить перо может только - суперпользователь. +3. Запустить SemiPlot и в редакторе перьев нажать «Обновить список перьев». Кнопка добавляет в + `semiplot_tags` по перу на каждую переменную, которую SCADA уже записала в `trends`, с + настройками по умолчанию. При запуске SemiPlot перья не добавляет: переменные, появившиеся + позже, добавляет следующее нажатие кнопки. Дальше перья меняют в том же редакторе. Вручную их + не добавляют, а удалить перо может только суперпользователь. -4. Записать файл подключения SemiPlot `connection.yaml`: хост `127.0.0.1`, роль `semiplot`, пароль - `semiplot`. Часовой пояс в файле не указывают: SemiPlot работает на машине SCADA и читает архив - в её часовом поясе. +4. Записать файл подключения SemiPlot `connection.yaml`: хост `127.0.0.1`, порт PostgreSQL 17, + роль `semiplot`, пароль `semiplot`. Порт при новой установке `5432`; у уже стоявшего + PostgreSQL 17 он записан в значении `Port` ключа + `HKLM\SOFTWARE\PostgreSQL\Services\postgresql-x64-17`, и журнал `site` называет его в последней + строке, `Done: site completed against localhost:<порт>/scada_archive`. Часовой пояс в файле + не указывают: SemiPlot работает на машине SCADA и читает архив в её часовом поясе. Если проверка в конце работы утилиты не прошла, установщик возвращает `7`, а журнал `site` называет причину. Если `semiplot` не имеет доступа к `trends`, значит таблица была создана раньше, @@ -204,25 +221,28 @@ semibase.exe site --port <порт> --superuser <суперпользовате 2. Найти её начало в таблице ниже и сделать то, что там сказано. 3. Запустить установщик снова. -Отказы из-за паролей и из-за состояния PostgreSQL установщик выдаёт до любых изменений. Отказ -установщика PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже -запущена. +Отказы из-за паролей и из-за состояния PostgreSQL установщик выдаёт до любых изменений в +PostgreSQL; до них он только кладёт копию утилиты в `C:\DISTR\Tools\SemiBase`. Отказ установщика +PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже запущена. +Неверный пароль суперпользователя уже стоящего PostgreSQL 17 обнаруживает только утилита, то есть +после того, как служба переведена в автоматический запуск и запущена. | Строка в журнале | Что делать | | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `No superuser password` | Передать пароль суперпользователя параметром или переменной | | `No scada_writer password` | Установщик ставит PostgreSQL и создаёт `scada_writer`: передать пароль этой роли | -| `The superuser password must be printable ASCII`, `The superuser password must not contain "`, `The scada_writer password ... must be printable ASCII` | Для новой установки выбрать пароль по [правилам](#параметры-и-пароли). Если такой пароль у суперпользователя уже стоящего PostgreSQL 17, установщик с ним работать не будет: подготовить сервер [утилитой вручную](#ручной-запуск-утилиты) | -| `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Передать пароль переменной окружения | +| `The superuser password must ...`, `The scada_writer password ... must be printable ASCII` | Выбрать пароль по [правилам](#параметры-и-пароли). Правила для пароля суперпользователя действуют только при новой установке PostgreSQL | +| `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Взять параметр в кавычки целиком; пароль с `"` передать переменной окружения | +| `Setup argument ... does not start with /` | Пароль с пробелом разделился на два слова: взять параметр в кавычки целиком или передать пароль переменной окружения | | `... is not a complete PostgreSQL 17 installation` | Так выглядит неудачная установка PostgreSQL. Удалить её программой `uninstall-postgresql.exe`, путь к которой назван в строке, и [дождаться конца удаления](#удаление-postgresql) | -| `... names no service`, `... names service ..., which does not exist`, `... has no Port value` | Регистрация PostgreSQL 17 в реестре не совпадает со службами. Если сервер работает, подготовить его [утилитой вручную](#ручной-запуск-утилиты). Иначе удалить остатки программой `uninstall-postgresql.exe` из папки установки PostgreSQL и [дождаться конца удаления](#удаление-postgresql) | -| `PostgreSQL 17 is not registered, and another major is` | Установщик работает только с PostgreSQL 17 и не ставит его рядом с другой версией. Либо подготовить существующий сервер [утилитой вручную](#ручной-запуск-утилиты) с его версией в `--expected-major` (не ниже 14), либо перенести его данные на 17 через `pg_upgrade` в плановый простой, либо удалить старую версию, если её данные не нужны | -| `No PostgreSQL is registered, but 127.0.0.1:5432 accepts TCP` | На порту работает сервер, которого нет в реестре: служба, поставленная не установщиком EDB, или контейнер. Найти процесс: `Get-Process -Id (Get-NetTCPConnection -LocalPort 5432 -State Listen).OwningProcess`. Остановить его или подготовить [утилитой вручную](#ручной-запуск-утилиты) | +| `... names no service`, `... names service ..., which does not exist`, `... has no Port value`, `... has a Super User or Service ID that contains " or %` | Регистрация PostgreSQL 17 в реестре не совпадает со службами или её значения нельзя передать утилите. Если сервер работает, подготовить его [утилитой вручную](#ручной-запуск-утилиты). Иначе удалить остатки программой `uninstall-postgresql.exe` из папки установки PostgreSQL и [дождаться конца удаления](#удаление-postgresql) | +| `PostgreSQL 17 is not registered, and another major is` | Установщик работает только с PostgreSQL 17 и не ставит его рядом с другой версией. Либо перенести данные старой версии на 17 через `pg_upgrade` в плановый простой, либо удалить старую версию, если её данные не нужны. Готовить старую версию утилитой вручную бесполезно: каждый следующий запуск установщика всё равно откажет | +| `No PostgreSQL is registered, but localhost:5432 accepts TCP` | На порту работает сервер, которого нет в реестре: служба, поставленная не установщиком EDB, сервер, запущенный вручную, или контейнер. Найти процесс: `Get-Process -Id (Get-NetTCPConnection -LocalPort 5432 -State Listen).OwningProcess`. Контейнер остановить: утилита закрыла бы его для подключений извне. Сервер PostgreSQL 17 на этой машине можно подготовить [утилитой вручную](#ручной-запуск-утилиты), иначе остановить его | | `No PostgreSQL is registered, but C:\Program Files\PostgreSQL\17\data exists and is not empty` | Данные остались после прерванной установки или после удаления PostgreSQL: удаление каталог данных не трогает. Если данные не нужны, удалить каталог. Установщик их никогда не перезаписывает | | `The PostgreSQL installer exited with code` | Причина записана в журнале установщика PostgreSQL, путь к нему назван в строке. Следующий запуск увидит неполную установку: удалить её, как в строке про неполную установку | | `sc.exe config ...`, `sc.exe start ...`, `... accepted no TCP connection within 60 s` | Служба не запустилась или сервер не принял подключение. Причина записана в журнале сервера, в папке `log` каталога данных, и в журнале событий Windows. Устранить её | -| `semibase site exited with code` | Причина записана в журнале `site`, путь к нему назван в строке. `password authentication failed` значит неверный пароль суперпользователя. Если служба осталась остановленной, см. [ошибки перезапуска службы](#ошибки-перезапуска-службы) | -| `Setup could not ...` | Не запустился `powershell.exe`, `cmd.exe` или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | +| `semibase site exited with code` | Причина записана в журнале `site`, путь к нему назван в строке. `password authentication failed` значит неверный пароль суперпользователя. `role scada_writer does not exist and no password was given to create it` значит, что на уже стоящем PostgreSQL 17 роли ещё нет: передать пароль `scada_writer`. Если служба осталась остановленной, см. [ошибки перезапуска службы](#ошибки-перезапуска-службы) | +| `Setup could not ...`, `Setup stopped on an error` | Не скопировалась утилита в `C:\DISTR\Tools\SemiBase`, не запустился `powershell.exe`, `cmd.exe` или установщик PostgreSQL (его может заблокировать антивирус), не задались переменные с паролями или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | ### Удаление PostgreSQL @@ -260,31 +280,41 @@ while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall* ### Ручной запуск утилиты -Для состояний, с которыми установщик не работает, утилиту запускают сами. Копия утилиты лежит в -`C:\Program Files\SemiBase\semibase.exe` после первого успешного запуска установщика. Отказ -приходит раньше, чем установщик копирует свои файлы, поэтому на машине, где установщик ни разу не -завершился успешно, копии нет. Тогда соберите утилиту из исходников того же выпуска на другой -машине (`go build -o semibase.exe ./cmd/semibase`, нужен Go) и перенесите её. +Для состояний, с которыми установщик не работает, утилиту запускают сами. Каждый запуск +установщика первым делом, ещё до проверки паролей, кладёт свою утилиту в +`C:\DISTR\Tools\SemiBase\semibase.exe`. Поэтому она есть и на машине, где установщик ни разу не +завершился успешно, и не нужно ни скачивать её, ни собирать. Удаление SemiBase эту копию не +трогает. + +Утилита готовит только PostgreSQL 17 и откажет серверу другой версии. Ей нужны порт сервера, +суперпользователь и, если сервер работает как служба Windows, имя службы: -Порт, суперпользователь и служба PostgreSQL 17, зарегистрированного установщиком EDB, читаются из -реестра. Для другой версии в имени ключа стоит она, например `postgresql-x64-14`: +| Значение | PostgreSQL 17 в реестре EDB | Сервер, которого нет в реестре | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| Порт | Значение `Port` ключа `HKLM\SOFTWARE\PostgreSQL\Services\<служба>`; если его нет, `port` в `postgresql.conf` каталога данных | `5432`: на нём установщик и нашёл сервер | +| Суперпользователь | Значение `Super User` ключа `HKLM\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17` | Тот, кто ставил сервер, обычно `postgres` | +| Служба | Значение `Service ID` того же ключа; если его нет или такой службы нет, поиск ниже | Поиск ниже | + +Службу PostgreSQL находят по `pg_ctl` в пути к её исполняемому файлу. Каталог данных службы +указан там после `-D`: ```powershell -$installation = Get-ItemProperty 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' -$service = $installation.'Service ID' -$superuser = $installation.'Super User' -$port = (Get-ItemProperty "HKLM:\SOFTWARE\PostgreSQL\Services\$service").Port +Get-CimInstance Win32_Service | Where-Object PathName -like '*pg_ctl*' | Format-List Name, State, StartMode, PathName ``` -Утилита не работает с отключённой или остановленной службой, поэтому служба переводится в -автоматический запуск и запускается. Дальше в том же окне PowerShell от имени администратора: +Утилита не работает с отключённой или остановленной службой, поэтому службу переводят в +автоматический запуск и запускают. Дальше в окне PowerShell от имени администратора, подставив +значения из таблицы: ```powershell +$port = 5432 +$superuser = 'postgres' +$service = 'postgresql-x64-17' Set-Service $service -StartupType Automatic; Start-Service $service $env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password $env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password $env:SEMIBASE_PLOT_PASSWORD = 'semiplot' -& 'C:\Program Files\SemiBase\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service +& 'C:\DISTR\Tools\SemiBase\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service $LASTEXITCODE ``` @@ -292,6 +322,12 @@ $LASTEXITCODE задан тем же, что задаёт установщик. Перед любыми изменениями утилита сверяет каталог после `-D` в пути к исполняемому файлу службы с каталогом данных сервера на порту. Если они не совпадают, `--restart-service` называет не ту службу, и утилита завершается с ошибкой, ничего не изменив. + +Если сервер работает не как служба Windows, например запущен вручную через `pg_ctl start`, строку +`Set-Service` и флаг `--restart-service $service` убирают. Тогда утилита сообщает, какие настройки +ждут перезапуска, и сервер перезапускают вручную. Контейнер так не готовят: после перезапуска +сервер слушает только локальный интерфейс, и контейнер закрывается для подключений извне. + Код `0` значит то же, что код `0` установщика. ## Утилита semibase.exe diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index d84017d..6335090 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -78,35 +78,49 @@ ## Acceptance Evidence All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting -`$LASTEXITCODE`. The superuser and writer passwords are random 24-character tokens generated per +Setup's exit code as `Process.ExitCode` reports it (Setup is a GUI exe, so `$LASTEXITCODE` is +never set). A refusal is exit 7 with its reason as the last line of the newest +`C:\DISTR\Logs\SemiBase\setup-*.log`. The passwords are random 24-character tokens generated per run; `$psql = "$env:PGBIN\psql.exe"`. -1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: non-zero exit; `{app}\semibase.exe` - does not exist; the runner's `postgresql-x64-17` is still Disabled; the newest - `C:\DISTR\Logs\SemiBase\setup-*.log` names the missing superuser password. +1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: a refusal naming the missing superuser + password; `{app}\semibase.exe` does not exist; `C:\DISTR\Tools\SemiBase\semibase.exe` exists + and runs; the runner's `postgresql-x64-17` is still Disabled. 2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` and a `SEMIBASE_WRITER_PASSWORD` token in the environment, no parameter; nothing has created `scada_writer` on that cluster - yet): exit 0; the service start type is Automatic; `semiplot` with password `semiplot` reads - `semiplot_tags`. -3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty and - `HKLM\SOFTWARE\PostgreSQL` does not exist. -4. A fake `Installations\postgresql-x64-14` key with a `Services` port: non-zero exit; the - newest `setup-*.log` names the key, version 14 and the port, and not the password; the key - is deleted afterwards. -5. A Cyrillic `/SUPERPASSWORD` on the clean machine, then one containing `"`: non-zero exit - each time; no `postgresql*` service exists afterwards. -6. Clean machine, `/SUPERPASSWORD` and `/WRITERPASSWORD` tokens: exit 0; the site log contains - `restarting service postgresql-x64-17`; `SELECT count(*) FROM pg_settings WHERE - pending_restart` prints `0`; `SHOW listen_addresses` prints `localhost`; `SELECT datname, - datcollate, datctype, pg_encoding_to_char(encoding) FROM pg_database WHERE datname IN - ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both rows; `semiplot` logs in - with password `semiplot`. -7. The same command again: exit 0; the new site log does not contain `restarting service`. -8. `/SUPERPASSWORD=wrong`: non-zero exit; the site log names the failed connection. -9. `Select-String` for both tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log`, - `C:\Program Files\PostgreSQL\17\data\log\*` and `%TEMP%\is-*.tmp` finds nothing. No case - passes `/LOG`: the Inno log carries Setup's command line by design. + yet): exit 0; the service start type is Automatic; `semiplot` with password `semiplot` and + `scada_writer` with the token log in. +3. The runner's PostgreSQL is removed (task 7); `Get-Service postgresql*` is empty, and + `HKLM\SOFTWARE\PostgreSQL` holds no subkey under `Installations` or `Services`, the state a + real EDB uninstall leaves. +4. A fake `Installations\postgresql-x64-14` key with a `Services` port: a refusal naming the + key, version 14 and the port, and not the password; the fake keys are deleted afterwards. +5. On the clean machine, refusals with no `postgresql*` service afterwards: a Cyrillic + `/SUPERPASSWORD`; one containing `"` as a parameter and through the variable; one ending in + `\`; one starting with `-`; one starting with a space; an unquoted `/SUPERPASSWORD=a b`; a + Cyrillic `/WRITERPASSWORD`; a `/WRITERPASSWORD` containing `"`. Then the engine rows, each + from a fake key or object deleted afterwards: an incomplete 17 key (no `Super User`), an + orphaned 17 key (`Service ID` naming no service), a listener on `127.0.0.1:5432`, a file in + `C:\Program Files\PostgreSQL\17\data`, and no writer password. +6. Clean machine, the superuser and writer tokens in `SEMIBASE_*` set inside the step: exit 0; a + watch during the run sees `%TEMP%\is-*.tmp\postgresql-options.txt` and sees it gone when the + site log appears; the site log contains `restarting service postgresql-x64-17`; + `SELECT count(*) FROM pg_settings WHERE pending_restart` prints `0`; `SHOW listen_addresses` + prints `localhost`; `SELECT datname, datcollate, datctype, pg_encoding_to_char(encoding) FROM + pg_database WHERE datname IN ('template1', 'scada_archive')` prints `C`, `C`, `UTF8` on both + rows; `semiplot` logs in with password `semiplot`. +7. `log_statement = 'all'` set, then `/SUPERPASSWORD` alone: exit 0; the new site log does not + contain `restarting service`; `scada_writer` logs in with the case-6 token. A further run + with a new writer token in `SEMIBASE_WRITER_PASSWORD`: exit 0, `scada_writer` logs in with it. + A run with the registered `Port` set to 15433: a refusal naming `localhost:15433` after the + 60 s wait; the port is restored. +8. `/SUPERPASSWORD=wrong`: a refusal; the site log names the failed connection. +9. `Select-String` for all four tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log` + and `C:\Program Files\PostgreSQL\17\data\log\*` finds nothing, and the server log holds the + `semiplot` probe statement, so statement logging was on. Setup's `{tmp}` is gone at exit; + case 6 watched it. No case passes `/LOG`: the Inno log carries Setup's command line by design. 10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is - still present and `semiplot` still logs in. + still present, `semiplot` still logs in, and `C:\DISTR\Logs\SemiBase\*.log` and + `C:\DISTR\Tools\SemiBase\semibase.exe` remain. ## Solution Overview - All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return @@ -117,8 +131,11 @@ run; `$psql = "$env:PGBIN\psql.exe"`. `C:\DISTR\Logs\SemiBase\setup-.log`, creating the directory itself because `[Dirs]` has not run yet. - `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to - `{tmp}` for the run. The copy in `{app}` is the recovery tool when Setup refuses a state, and - the source of `semibase version` for support (`docs/deployment.md:42`). The EDB installer is a + `{tmp}` for the run, and every run first copies it to `C:\DISTR\Tools\SemiBase\semibase.exe`. + That copy is the recovery tool when Setup refuses a state: every refusal precedes `[Files]`, + so `{app}` holds a copy only after a successful run, and the release attaches no Windows + binary, which an offline site could not download anyway. Either copy answers + `semibase version` for support. The EDB installer is a `dontcopy` entry with `nocompression`, so extracting it does not decompress 413 MB of solid archive first. The uninstaller removes only `semibase.exe`. - The installer decides the engine state from the EDB registry (`HKLM64`), never from the service @@ -134,14 +151,16 @@ run; `$psql = "$env:PGBIN\psql.exe"`. `powershell.exe -File initcluster.ps1` command line, which carries the superuser password as a positional argument, and its temporary pwfile during `initdb`. EDB's log masks the argument as `****` (`docs/architecture/installer.md#where-the-superuser-password-goes`). -- Both passwords must be printable ASCII; `PrepareToInstall` refuses anything else. EDB writes - the superuser password in the ANSI code page while `semibase` sends UTF-8, so a non-ASCII - superuser password produces a cluster nobody can log into. The superuser password also must - not contain `"` or end in `\`: either one breaks EDB's `initcluster.ps1` command line, and the - install fails half-done (`installer.md#password-characters`). Setup's own command-line parser +- The `scada_writer` password must be printable ASCII on every run. The superuser password rules + hold on the fresh-install row only, where EDB runs: printable ASCII, because EDB writes it in + the ANSI code page while `semibase` sends UTF-8; no `"` and no trailing `\`, either of which + breaks EDB's `initcluster.ps1` command line and leaves the install half-done; no leading `-` + and no leading or trailing space, both unmeasured (`installer.md#password-characters`). On a + registered 17 the password reaches `semibase` byte for byte. Setup's own command-line parser drops every `"`, so a password parameter whose value does not appear verbatim in - `GetCmdTail` is refused too; a password with `"` reaches Setup only through its variable - (`installer.md#inno-setup`). + `GetCmdTail` is refused too, and so is any argument that does not start with `/`, which an + unquoted space in a password parameter produces; a password with `"` reaches Setup only + through its variable (`installer.md#inno-setup`). - The superuser password is required on every run and stored nowhere. - Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the convention, so the role has one writer and no site-specific value to lose on an upgrade. @@ -160,16 +179,20 @@ run; `$psql = "$env:PGBIN\psql.exe"`. | Nothing registered, `{#PgDataDir}` exists and is not empty | Refuse: name the directory and say that removing it is the recovery, for example after an interrupted install; existing data is never overwritten | | Nothing registered, port free, no data directory | Without a writer password: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | -- Every refusal message points to the recovery section of `docs/deployment.md`. +- Every refusal but the password and parameter ones, which name their own fix, ends with + `See the recovery section of the SemiBase deployment guide, docs/deployment.md on GitHub.`: + an installation machine has no repository checkout, so the hint names the guide, not a path. - EDB exit codes (task 1, `installer.md#exit-codes`): 0 continues; any other code refuses, naming `%TEMP%\install-postgresql.log`. Every measured failure exits 1, and no reboot-required code was reproduced, so Setup has no reboot branch. A failed install leaves a partial registration, which the next run refuses through the second row. - EDB exits 0 without a `superpassword` and leaves a cluster with an unknown password, so Setup never starts EDB without one. -- After starting a service or installing EDB, the installer waits for `127.0.0.1:` to +- After starting a service or installing EDB, the installer waits for `localhost:` to accept TCP, bounded by 60 s. The probe is a PowerShell `System.Net.Sockets.TcpClient` call run - through `Exec`, because Pascal Script has no sockets; the port-busy row uses the same probe. + through `Exec`, because Pascal Script has no sockets; it dials `localhost` as `semibase` does, + and a probe that fails rather than finding the port closed refuses. The port-busy row uses the + same probe. ### Database states (decided by `semibase site`, unchanged) @@ -216,8 +239,10 @@ run; `$psql = "$env:PGBIN\psql.exe"`. ### Payload pinning - `Installer/fetch-postgresql.sh` holds the EDB version, URL and SHA256 as its only constants, - downloads into `Installer/payload/` (git-ignored), and fails on a hash mismatch. CI and release - both call it, so a retired URL fails on a pull request rather than at tag time. + downloads into `Installer/payload/postgresql-17-windows-x64.exe` (git-ignored, a fixed name the + `.iss` uses), and fails on a hash mismatch. CI and release both call it, but both restore the + payload from a cache keyed on the script's hash, so a retired URL surfaces on the first run + after a cache miss, which can be a tag; the payload is pinned by hash either way. ### Release workflow shape - `build` (ubuntu): checkout with `fetch-depth: 0`, test, read the tag, cross-compile both @@ -281,8 +306,9 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - [x] `[Setup]` after `SemiPlot.iss`: own `AppId`, `PrivilegesRequired=admin`, x64 only, `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required -- [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall`; - `[Files]` as in Solution Overview +- [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall` + (removed in review: `ForceDirectories` creates the folder before `[Dirs]` runs, and the + uninstaller never removes a folder it did not create); `[Files]` as in Solution Overview - [x] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` variable; refuse an empty superuser password in silent mode, any password that is not printable ASCII, and a superuser password that contains `"` or ends in `\`, from @@ -331,8 +357,8 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - [x] the superuser field is required; the writer field is required when the engine state is the fresh-install row (checked on Next from the registry alone: no 17 key and no other major; `PrepareToInstall` stays the enforcement) -- [x] build and click through once in a VM (skipped - no VM in this run; manual check listed in - Post-Completion) +- [x] skipped, not done: build and click through once in a VM (no VM in this run; the manual + check is listed in Post-Completion) ### Task 7: Full CI case list @@ -357,8 +383,8 @@ run; `$psql = "$env:PGBIN\psql.exe"`. - [x] the `release` job restores the exec bit, then attaches the Linux binary and `SemiBase-Setup.exe` in one call; the Windows binary is not attached - [x] rewrite the ordering rationale at `release.yml:198-200` to the reason in Technical Details -- [x] push a prerelease tag on a fork of the repository: the release page has two assets, - `:latest` did not move, and nothing is published to `ghcr.io/semiteq` (skipped - tags, +- [x] skipped, not done: push a prerelease tag on a fork of the repository: the release page has + two assets, `:latest` did not move, and nothing is published to `ghcr.io/semiteq` (tags, forks and releases are outside this run's git permission; moved to Post-Completion; actionlint 1.7.12 is clean on `release.yml`) @@ -389,7 +415,7 @@ run; `$psql = "$env:PGBIN\psql.exe"`. ordering rationale and "consumers ... still download the release assets" - [x] grep both repositories for `winget` next to `PostgreSQL`; every hit in this repository is updated, and SemiPlot's go to Post-Completion -- [x] move this plan to `docs/plans/completed/` (left for the delivery step) +- [x] skipped, not done: move this plan to `docs/plans/completed/` (left for the delivery step) ## Post-Completion - SemiPlot `ConfigFiles/connection/connection.yaml:3` names database `semiplot`, while @@ -403,10 +429,9 @@ run; `$psql = "$env:PGBIN\psql.exe"`. `Resources.ru.resx` (`:316`, `:322`, `:340`); each becomes "run the SemiBase setup again". - SemiPlot `docs/architecture/testing-strategy.md:418` names the `winget` install. Its other hit, `docs/plans/completed/20260810-archive-populator.md:1270`, is a completed plan and stays. -- No `semibase.exe` exists for the manual run in the recovery section of `docs/deployment.md` on - a machine where Setup has never succeeded: every refusal precedes the `[Files]` copy, and the - release no longer attaches the Windows binary. Decide whether the release attaches it again or - Setup keeps a copy before it refuses. +- Install once on a clean, offline Windows VM without the Visual C++ runtime: EDB then runs its + bundled `vcredist`, which the runners never did, and may ask for a reboot, a code Setup has no + branch for (`installer.md#exit-codes`); the runners also always had internet. - Click through `SemiBase-Setup.exe` once, interactively, in a clean Windows VM: the password page appears after the folder page; Next with an empty superuser field shows an error and stays; with no PostgreSQL registered, Next with an empty writer field shows an error and stays; with From 7ca0ab6b7f71233eabc968231e5e9b1dbd33a510 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:21:03 +0300 Subject: [PATCH 30/35] docs(deployment): route a password with a space right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example "/SUPERPASSWORD=два слова" lost its quotes in Start-Process and was non-ASCII, so Setup refused it. A password with a space now goes through the variable, or quoted inside the -ArgumentList string, the form measured to reach Setup intact. --- docs/deployment.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 9be28d0..09fbc4c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -82,9 +82,11 @@ $setup.ExitCode пароль суперпользователя может быть любым: установщик передаёт его утилите без изменений. Все `"` из своей командной строки установщик выбрасывает, поэтому пароль с кавычкой передают -только переменной. Пароль с пробелом передают параметром, взятым в кавычки целиком: -`"/SUPERPASSWORD=два слова"`. Параметр, значение которого дошло не таким, каким его набрали, и -слово командной строки, которое не начинается с `/`, установщик отвергает. +только переменной. Пароль с пробелом тоже проще передать переменной. Параметром он доходит, +только если кавычки стоят вокруг всего параметра в самой командной строке. В `Start-Process` +внешние кавычки снимает PowerShell, поэтому их пишут внутри строки: +`-ArgumentList '"/SUPERPASSWORD=two words"'`. Параметр, значение которого дошло не таким, +каким его набрали, и слово командной строки, которое не начинается с `/`, установщик отвергает. Пароль, переданный параметром, виден в списке процессов, пока установщик работает, и попадает в историю команд PowerShell, если его набрали в строке. С параметром `/LOG` установщик пишет в свой @@ -232,8 +234,8 @@ PostgreSQL или утилиты может прийти, когда PostgreSQL | `No superuser password` | Передать пароль суперпользователя параметром или переменной | | `No scada_writer password` | Установщик ставит PostgreSQL и создаёт `scada_writer`: передать пароль этой роли | | `The superuser password must ...`, `The scada_writer password ... must be printable ASCII` | Выбрать пароль по [правилам](#параметры-и-пароли). Правила для пароля суперпользователя действуют только при новой установке PostgreSQL | -| `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Взять параметр в кавычки целиком; пароль с `"` передать переменной окружения | -| `Setup argument ... does not start with /` | Пароль с пробелом разделился на два слова: взять параметр в кавычки целиком или передать пароль переменной окружения | +| `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Передать пароль переменной окружения; пароль с `"` доходит только так | +| `Setup argument ... does not start with /` | Пароль с пробелом разделился на два слова: передать его переменной окружения или взять параметр в кавычки, как в разделе [параметры и пароли](#параметры-и-пароли) | | `... is not a complete PostgreSQL 17 installation` | Так выглядит неудачная установка PostgreSQL. Удалить её программой `uninstall-postgresql.exe`, путь к которой назван в строке, и [дождаться конца удаления](#удаление-postgresql) | | `... names no service`, `... names service ..., which does not exist`, `... has no Port value`, `... has a Super User or Service ID that contains " or %` | Регистрация PostgreSQL 17 в реестре не совпадает со службами или её значения нельзя передать утилите. Если сервер работает, подготовить его [утилитой вручную](#ручной-запуск-утилиты). Иначе удалить остатки программой `uninstall-postgresql.exe` из папки установки PostgreSQL и [дождаться конца удаления](#удаление-postgresql) | | `PostgreSQL 17 is not registered, and another major is` | Установщик работает только с PostgreSQL 17 и не ставит его рядом с другой версией. Либо перенести данные старой версии на 17 через `pg_upgrade` в плановый простой, либо удалить старую версию, если её данные не нужны. Готовить старую версию утилитой вручную бесполезно: каждый следующий запуск установщика всё равно откажет | From de16426b6d19ed33a01c7773ed08c249be1f7e3e Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:57:01 +0300 Subject: [PATCH 31/35] fix(installer): move setup writes out of C:\DISTR Elevated Setup wrote its logs to C:\DISTR\Logs\SemiBase and the recovery semibase.exe to C:\DISTR\Tools\SemiBase. C:\ gives every authenticated user Modify on folders made under it and lets any user create one, so a user could pre-create either path as a junction and redirect Setup's log writes and CopyFile, or swap the recovery binary that the deployment guide runs elevated with the superuser password. A protected DACL on the leaf folder does not help: measured, a user who can modify the parent renames it and plants a junction in its place. Locking C:\DISTR and C:\DISTR\Logs would touch folders other products own, so both now live in {commonpf}\SemiBase Setup, where only administrators can create or change anything. ParameterRefusal compared only a prefix of the raw tail, so /SUPERPASSWORD=Aa1z" arrived as Aa1z and passed, and EDB would create the cluster with a password other than the one typed. The value must now end at the end of the tail, at a space, or at a quote closing one opened right before /NAME=; "/SUPERPASSWORD=two words" still passes. Files: Installer/SemiBase.iss, .github/scripts/installer-case.ps1, .github/workflows/ci.yml, docs/deployment.md, docs/architecture/installer.md, docs/plans/20261019-windows-installer.md, CLAUDE.md, README.md --- .github/scripts/installer-case.ps1 | 4 +- .github/workflows/ci.yml | 9 +++- CLAUDE.md | 15 +++--- Installer/SemiBase.iss | 58 ++++++++++++++++-------- README.md | 2 +- docs/architecture/installer.md | 49 ++++++++++++++------ docs/deployment.md | 33 ++++++++------ docs/plans/20261019-windows-installer.md | 41 ++++++++++------- 8 files changed, 139 insertions(+), 72 deletions(-) diff --git a/.github/scripts/installer-case.ps1 b/.github/scripts/installer-case.ps1 index a4277a2..e5bdc4b 100644 --- a/.github/scripts/installer-case.ps1 +++ b/.github/scripts/installer-case.ps1 @@ -1,8 +1,8 @@ # Helpers for the setup's end-to-end cases; each installer step dot-sources this file. $SetupExe = "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" -$LogDir = 'C:\DISTR\Logs\SemiBase' -$ToolExe = 'C:\DISTR\Tools\SemiBase\semibase.exe' +$LogDir = "$env:ProgramFiles\SemiBase Setup\Logs" +$ToolExe = "$env:ProgramFiles\SemiBase Setup\semibase.exe" $TokenFile = "$env:RUNNER_TEMP\case-passwords.txt" function Fail([string] $message) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd3cee..4552c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -436,13 +436,18 @@ jobs: if ($text.Contains($token)) { Fail 'the setup log contains the superuser password' } # The superuser rules hold on the fresh-install row, which a clean machine takes. The - # password parameter comes last, so an unbalanced " swallows no other parameter. + # password parameter comes last, so an unbalanced " swallows no other parameter. The + # quoted parameter with a space passes every parameter and password check and stops + # at the writer guard, so the trailing-quote rule does not refuse the documented form. - name: Refuse a non-ASCII password and a password with a quote run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $nonAscii = [char] 0x0436 Assert-Refused (Invoke-Setup "/SUPERPASSWORD=Aa1${nonAscii}z") 'The superuser password must be printable ASCII' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1"z') 'The /SUPERPASSWORD value does not reach Setup as typed' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z"') 'The /SUPERPASSWORD value does not reach Setup as typed' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w"') 'The /WRITERPASSWORD value does not reach Setup as typed' + Assert-Refused (Invoke-Setup '"/SUPERPASSWORD=two words"') 'No scada_writer password' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z\') 'must not contain " or end in \' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=-Aa1z') 'must not start with -, or start or end with a space' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z Aa2z') 'does not start with /' @@ -676,6 +681,6 @@ jobs: Write-Redacted ((reg.exe query HKLM\SOFTWARE\PostgreSQL /s 2>&1) -join "`n") Get-ChildItem "$env:TEMP\install-postgresql.log", "$env:TEMP\installbuilder_installer_*.log" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "--- $($_.Name), last 200 lines"; Write-Redacted ((Get-Content $_.FullName -Tail 200) -join "`n") } - Get-ChildItem 'C:\DISTR\Logs\SemiBase\*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | + Get-ChildItem "$env:ProgramFiles\SemiBase Setup\Logs\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | ForEach-Object { Write-Host "--- $($_.Name)"; Write-Redacted "$(Get-Content -Raw $_.FullName)" } exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index a122346..ac77476 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ main.revision=` is embedded, so `semibase version` prints the tag. `install (`windows-latest`) fetches the EDB payload, installs Inno Setup through `winget` and compiles `SemiBase-Setup.exe` around the Windows binary (`/DAppVersion=`, `/DFileVersion=` the part before the first `-`), then smoke-runs it once without a password: it must refuse with exit -7, and the `semibase.exe` it copied to `C:\DISTR\Tools\SemiBase` must print the tag. `release` (`ubuntu-latest`) restores the Linux binary's exec bit, +7, and the `semibase.exe` it copied to `C:\Program Files\SemiBase Setup` must print the tag. +`release` (`ubuntu-latest`) restores the Linux binary's exec bit, which artifact transfer drops, and attaches two assets, the Linux binary and the setup; the Windows binary is not attached, the setup carries it. @@ -148,12 +149,13 @@ the Linux job shellchecks the fetch script. Each case step dot-sources call operator does not wait for, with the argument string passed as written) and asserts a refusal as exit 7 with its reason last in the newest setup log. In order: no superuser password refuses, installs no `semibase.exe` in `{app}`, leaves the recovery copy in -`C:\DISTR\Tools\SemiBase` and the runner's service Disabled; the runner's own 17 provisions with +`C:\Program Files\SemiBase Setup` and the runner's service Disabled; the runner's own 17 provisions with exit 0, Automatic, `semiplot`/`semiplot` and the generated `scada_writer` password log in; the runner's PostgreSQL is uninstalled, leaving the empty `Installations` and `Services` keys; a fake -`postgresql-x64-14` key refuses without the password in the log; nine password and parameter -refusals (non-ASCII, `"`, trailing `\`, leading `-` and space, an unquoted split, the writer -rules); the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data +`postgresql-x64-14` key refuses without the password in the log; eleven password and parameter +refusals (non-ASCII, `"`, a trailing `"` on either parameter, trailing `\`, leading `-` and +space, an unquoted split, the writer rules) and a quoted `"/SUPERPASSWORD=two words"` that +passes them; the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data directory and a missing writer password refuse with no `postgresql*` service left; a clean machine installs EDB 17 through the `SEMIBASE_*` variables while a job watches the option file appear and vanish before the site log (`restarting service`, nothing pending, `localhost`, @@ -185,7 +187,8 @@ gofmt -w . # run before presenting changes; gofmt is authoritative .\semibase.exe bench --port 15432 --database semiplot_dev --expected-major 17 .\semibase.exe version # print the build revision # the setup, in an elevated PowerShell with SEMIBASE_SUPER_PASSWORD (and on a first install -# SEMIBASE_WRITER_PASSWORD) set: exit 0 provisioned, 7 refused; logs in C:\DISTR\Logs\SemiBase +# SEMIBASE_WRITER_PASSWORD) set: exit 0 provisioned, 7 refused; logs in +# C:\Program Files\SemiBase Setup\Logs (Start-Process .\Installer\Output\SemiBase-Setup.exe '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru).ExitCode ``` diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index 08a7671..582ba1b 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -19,9 +19,11 @@ ; fetch-postgresql.sh saves the pinned build under this name and refuses a file that does not match the pin #define PgInstaller "postgresql-17-windows-x64.exe" -#define LogRoot "C:\DISTR\Logs\SemiBase" -; Every run copies semibase.exe here, so a machine where Setup never succeeded still has the recovery tool -#define ToolRoot "C:\DISTR\Tools\SemiBase" +; Every run copies semibase.exe here, so a machine where Setup never succeeded still has the +; recovery tool. Outside {app}, so the uninstaller leaves it. Only administrators can create or +; change a folder in Program Files, so no user can plant a junction or a binary where Setup writes. +#define ToolRoot "{commonpf}\SemiBase Setup" +#define LogRoot ToolRoot + "\Logs" ; The PostgreSQL 17 install this setup creates: its registry key, service, port and data directory #define PgInstallation "postgresql-x64-17" @@ -52,7 +54,7 @@ LZMAUseSeparateProcess=yes WizardStyle=modern -; Program Files, C:\DISTR and the PostgreSQL service all need admin rights +; Program Files and the PostgreSQL service both need admin rights PrivilegesRequired=admin [Languages] @@ -101,20 +103,35 @@ begin end; end; -// Setup's command-line parser drops every ", so a value typed with one arrives changed +// Setup's command-line parser drops every ", so a value typed with one arrives changed. The value +// must stand verbatim in the tail and end there: at the end, at a space, or at the " that closes a +// quote opened right before /NAME=. A trailing " would otherwise pass as a prefix match. +function ParameterReachesSetup(const Name, Value: String): Boolean; +var + Prefix, Tail: String; + PrefixStart, ValueEnd: Integer; +begin + Prefix := '/' + Name + '='; + Tail := GetCmdTail; + PrefixStart := Pos(Uppercase(Prefix), Uppercase(Tail)); + ValueEnd := PrefixStart + Length(Prefix) + Length(Value); + Result := (PrefixStart > 0) and (Copy(Tail, PrefixStart + Length(Prefix), Length(Value)) = Value); + if Result and (ValueEnd <= Length(Tail)) and (Tail[ValueEnd] = '"') then + begin + Result := (PrefixStart > 1) and (Tail[PrefixStart - 1] = '"'); + ValueEnd := ValueEnd + 1; + end; + if Result then + Result := (ValueEnd > Length(Tail)) or (Tail[ValueEnd] <= ' '); +end; + function ParameterRefusal(const Name, VariableName: String): String; var - Value, Prefix, Tail: String; - ValueStart: Integer; + Value: String; begin Result := ''; Value := CommandLineParameter(Name); - if Value = '' then - Exit; - Prefix := '/' + Name + '='; - Tail := GetCmdTail; - ValueStart := Pos(Uppercase(Prefix), Uppercase(Tail)) + Length(Prefix); - if Copy(Tail, ValueStart, Length(Value)) <> Value then + if (Value <> '') and not ParameterReachesSetup(Name, Value) then Result := 'The /' + Name + ' value does not reach Setup as typed: Setup drops every " from ' + 'its command line. Put the quotes around the whole parameter; a password that contains " ' + 'reaches Setup only through ' + VariableName + '.'; @@ -187,7 +204,8 @@ end; function LogPath(const Prefix: String): String; begin - Result := '{#LogRoot}\' + Prefix + '-' + GetDateTimeString('yyyymmdd-hhnnss', #0, #0) + '.log'; + Result := ExpandConstant('{#LogRoot}\') + Prefix + '-' + + GetDateTimeString('yyyymmdd-hhnnss', #0, #0) + '.log'; end; // Under /VERYSILENT the returned refusal reaches no one, and /LOG would record the passwords @@ -197,7 +215,7 @@ var begin SetArrayLength(Lines, 1); Lines[0] := Message; - ForceDirectories('{#LogRoot}'); + ForceDirectories(ExpandConstant('{#LogRoot}')); SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); end; @@ -205,9 +223,11 @@ function KeepToolRefusal: String; begin Result := ''; ExtractTemporaryFile('semibase.exe'); - if not ForceDirectories('{#ToolRoot}') or - not CopyFile(ExpandConstant('{tmp}\semibase.exe'), '{#ToolRoot}\semibase.exe', False) then - Result := 'Setup could not copy semibase.exe into {#ToolRoot}.' + RecoveryHint; + if not ForceDirectories(ExpandConstant('{#ToolRoot}')) or + not CopyFile(ExpandConstant('{tmp}\semibase.exe'), ExpandConstant('{#ToolRoot}\semibase.exe'), + False) then + Result := 'Setup could not copy semibase.exe into ' + ExpandConstant('{#ToolRoot}') + '.' + + RecoveryHint; end; // -1 when sc.exe did not run @@ -489,7 +509,7 @@ begin RecoveryHint; Exit; end; - ForceDirectories('{#LogRoot}'); + ForceDirectories(ExpandConstant('{#LogRoot}')); SiteLog := LogPath('site'); if not Exec(ExpandConstant('{cmd}'), '/s /c ""' + ExpandConstant('{tmp}\semibase.exe') + '" site --port ' + IntToStr(Engine.Port) + ' --superuser "' + Engine.SuperUser + diff --git a/README.md b/README.md index 3278468..334a64c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ $env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'П # PostgreSQL 17, если его нет; конфигурация сервера, архивная база, роли `scada_writer`, # `semiplot` (пароль `semiplot`) и `semiplot_registrar`, права, таблица архива, пять таблиц # SemiPlot и функция регистрации новых перьев; перезапуск службы, если настройки его ждут. -# код 0: машина готова; код 7: отказ, причина в C:\DISTR\Logs\SemiBase\setup-*.log +# код 0: машина готова; код 7: отказ, причина в C:\Program Files\SemiBase Setup\Logs\setup-*.log $setup = Start-Process -FilePath .\SemiBase-Setup.exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru $setup.ExitCode ``` diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 9d83d84..a92fcae 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -23,16 +23,20 @@ Setup with exit code 7 under `/VERYSILENT`; a failure raised later comes after t registered and may still exit 0. An exception inside `PrepareToInstall` becomes a refusal too. Under `/VERYSILENT` the returned text reaches no one, and `/LOG` would record the command line, so every refusal is also appended, password-free, to -`C:\DISTR\Logs\SemiBase\setup-.log`. Every refusal but the password and -parameter ones, which name their own fix, ends with `See the recovery section of the SemiBase -deployment guide, docs/deployment.md on GitHub.` +`C:\Program Files\SemiBase Setup\Logs\setup-.log` (`{commonpf}`, whatever +folder the install takes). Every refusal but the password and parameter ones, which name their +own fix, ends with `See the recovery section of the SemiBase deployment guide, docs/deployment.md +on GitHub.` In order, `PrepareToInstall`: -1. copies the carried `semibase.exe` to `C:\DISTR\Tools\SemiBase\semibase.exe`, the recovery tool - for the manual run, so a machine where Setup never succeeded has one, offline included; -2. checks that each password parameter arrived as typed (`GetCmdTail`) and that every argument - starts with `/`, since an unquoted space splits a password parameter into two arguments; +1. copies the carried `semibase.exe` to `C:\Program Files\SemiBase Setup\semibase.exe`, the + recovery tool for the manual run, so a machine where Setup never succeeded has one, offline + included; +2. checks that each password parameter arrived as typed (`GetCmdTail`: the value stands verbatim + in the tail and ends at the end, at a space, or at a `"` closing a quote opened right before + `/NAME=`) and that every argument starts with `/`, since an unquoted space splits a password + parameter into two arguments; 3. requires a superuser password and a printable ASCII `scada_writer` password; 4. decides the engine state, applying the [superuser password rules](#password-characters) on the fresh row only, and installs EDB there; @@ -102,12 +106,24 @@ Without `/SILENT` or `/VERYSILENT`, a password page after the folder page holds prefilled the same way; its Next check only lets the user correct a field, and `PrepareToInstall` stays the enforcement. `semibase.exe` has one `[Files]` entry into `{app}`, and `ExtractTemporaryFile` takes it to `{tmp}` for the run and for the copy in -`C:\DISTR\Tools\SemiBase`. The `{app}` copy exists only after a successful run; the -`C:\DISTR\Tools\SemiBase` copy after any run, so the recovery section of `docs/deployment.md` -points there. The uninstaller removes the `{app}` copy alone; the engine, its data, the roles, -`C:\DISTR\Logs\SemiBase` and `C:\DISTR\Tools\SemiBase` stay, since no `[Dirs]` or `[Files]` entry -names them. The `installer` job in `.github/workflows/ci.yml` runs the setup end to end on -`windows-latest`, one case per reachable row of the engine table. +`{commonpf}\SemiBase Setup`. The `{app}` copy exists only after a successful run; the +`SemiBase Setup` copy after any run, so the recovery section of `docs/deployment.md` points +there. The uninstaller removes the `{app}` copy alone; the engine, its data, the roles and +`SemiBase Setup` with its `Logs` stay, since no `[Dirs]` or `[Files]` entry names them. The +`installer` job in `.github/workflows/ci.yml` runs the setup end to end on `windows-latest`, one +case per reachable row of the engine table. + +`SemiBase Setup` sits in Program Files, not in the site's `C:\DISTR\\` folders +that SemiPlot uses, because elevated Setup writes there on every run and the manual recovery runs +that binary elevated with the passwords in its environment. `C:\` grants Authenticated Users +Modify to every folder created under it and lets any user create one, so a `C:\DISTR` path is a +folder a user can fill, replace or turn into a junction. Program Files grants Users read and +execute only, and that inherited DACL is the whole protection: Setup sets no ACL and checks no +reparse point. Measured 2026-10-20 on the development machine, non-elevated, in a scratch folder: +a protected leaf DACL (Administrators and SYSTEM full, Users read) does not stop a user who can +modify the parent from renaming the parent and putting a junction at its old path, which the leaf +path then resolves through. So a `C:\DISTR\Logs\SemiBase` DACL holds only with `C:\DISTR` and +`C:\DISTR\Logs` locked too, folders the setup shares with other products. ## The pinned payload @@ -305,6 +321,13 @@ Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that h - `GetCmdTail` returns the command line as typed, quotes included, after a leading `/SL5="..."` that the bootstrapper adds. The setup compares the two and refuses a password parameter whose value does not appear in it verbatim. +- Measured 2026-10-20 the same way, with a probe that records `GetCmdTail` and `ParamStr`: a + trailing `"` is dropped too, so `/SUPERPASSWORD=Aa1z"` arrives as `Aa1z`, and so does + `"/SUPERPASSWORD=Aa1z""`; the tail keeps both quotes. A prefix match would accept them, so the + value must also end at the end of the tail, at a space, or at the `"` that closes a quote + opened right before `/SUPERPASSWORD=`. `"/SUPERPASSWORD=two words"`, written by + `ProcessStartInfo` and by `Start-Process` (whose tail ends in a space), arrives as `two words` + and passes; `/SUPERPASSWORD="Aa1z"` and `/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w"` are refused. - An unquoted `/SUPERPASSWORD=a b` arrives as two arguments, `/SUPERPASSWORD=a` and `b`, and the first matches the tail verbatim. So the setup also refuses any argument that does not start with `/`; the bootstrapper's `/SL5=...` is argument 1 and does. diff --git a/docs/deployment.md b/docs/deployment.md index 09fbc4c..0bf1839 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,9 +8,9 @@ SemiBase разворачивается установщиком `SemiBase-Setup Работу с базой установщик поручает утилите `semibase.exe` и кладёт её копию в `C:\Program Files\SemiBase` (или в папку, выбранную при установке). Каждый запуск установщика, -в том числе отказавший, кладёт ещё одну копию в `C:\DISTR\Tools\SemiBase`. Вручную утилиту -запускают для [восстановления](#восстановление-после-отказа-установщика), когда установщик -отказывается работать, и на тестовых стендах. +в том числе отказавший, кладёт ещё одну копию в `C:\Program Files\SemiBase Setup`. Вручную +утилиту запускают для [восстановления](#восстановление-после-отказа-установщика), когда +установщик отказывается работать, и на тестовых стендах. ## Требования @@ -103,11 +103,15 @@ $setup.ExitCode | `7` | Установщик отказался работать. Причина записана последней строкой в самом новом журнале `setup-*.log` | | другой | Сбой самой программы установки. Машина в рабочее состояние не приведена | -Журналы лежат в `C:\DISTR\Logs\SemiBase`, паролей в них нет: +Журналы лежат в `C:\Program Files\SemiBase Setup\Logs`, паролей в них нет: - `setup-<ггггммдд-ччммсс>.log` — причина отказа. Если отказала утилита, строка называет её журнал. - `site-<ггггммдд-ччммсс>.log` — полный вывод утилиты за каждый запуск, успешный или нет. +Папка `C:\Program Files\SemiBase Setup` не зависит от выбранной папки установки. Создать или +изменить что-либо в `C:\Program Files` может только администратор, поэтому обычный пользователь +не подменит ни журналы, ни копию утилиты. + ### Запуск с окнами Без `/VERYSILENT` установщик показывает свои страницы. После выбора папки идёт страница паролей: @@ -169,7 +173,7 @@ semibase.exe site --port <порт> --superuser <суперпользовате `unins000.exe` в папке установки, по умолчанию `C:\Program Files\SemiBase` (или «Установка и удаление программ»), удаляет только `semibase.exe` из этой папки. PostgreSQL, его данные, роли, -журналы в `C:\DISTR\Logs\SemiBase` и копия утилиты в `C:\DISTR\Tools\SemiBase` остаются. +журналы и копия утилиты в `C:\Program Files\SemiBase Setup` остаются. ## Порядок ввода в эксплуатацию @@ -219,13 +223,15 @@ semibase.exe site --port <порт> --superuser <суперпользовате Отказ установщика — это код возврата `7`. Восстановление идёт так: -1. Прочитать последнюю строку самого нового журнала `C:\DISTR\Logs\SemiBase\setup-*.log`. +1. Прочитать последнюю строку самого нового журнала + `C:\Program Files\SemiBase Setup\Logs\setup-*.log`. 2. Найти её начало в таблице ниже и сделать то, что там сказано. 3. Запустить установщик снова. Отказы из-за паролей и из-за состояния PostgreSQL установщик выдаёт до любых изменений в -PostgreSQL; до них он только кладёт копию утилиты в `C:\DISTR\Tools\SemiBase`. Отказ установщика -PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже запущена. +PostgreSQL; до них он только кладёт копию утилиты в `C:\Program Files\SemiBase Setup`. Отказ +установщика PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже +запущена. Неверный пароль суперпользователя уже стоящего PostgreSQL 17 обнаруживает только утилита, то есть после того, как служба переведена в автоматический запуск и запущена. @@ -244,7 +250,7 @@ PostgreSQL или утилиты может прийти, когда PostgreSQL | `The PostgreSQL installer exited with code` | Причина записана в журнале установщика PostgreSQL, путь к нему назван в строке. Следующий запуск увидит неполную установку: удалить её, как в строке про неполную установку | | `sc.exe config ...`, `sc.exe start ...`, `... accepted no TCP connection within 60 s` | Служба не запустилась или сервер не принял подключение. Причина записана в журнале сервера, в папке `log` каталога данных, и в журнале событий Windows. Устранить её | | `semibase site exited with code` | Причина записана в журнале `site`, путь к нему назван в строке. `password authentication failed` значит неверный пароль суперпользователя. `role scada_writer does not exist and no password was given to create it` значит, что на уже стоящем PostgreSQL 17 роли ещё нет: передать пароль `scada_writer`. Если служба осталась остановленной, см. [ошибки перезапуска службы](#ошибки-перезапуска-службы) | -| `Setup could not ...`, `Setup stopped on an error` | Не скопировалась утилита в `C:\DISTR\Tools\SemiBase`, не запустился `powershell.exe`, `cmd.exe` или установщик PostgreSQL (его может заблокировать антивирус), не задались переменные с паролями или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | +| `Setup could not ...`, `Setup stopped on an error` | Не скопировалась утилита в `C:\Program Files\SemiBase Setup`, не запустился `powershell.exe`, `cmd.exe` или установщик PostgreSQL (его может заблокировать антивирус), не задались переменные с паролями или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | ### Удаление PostgreSQL @@ -284,9 +290,10 @@ while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall* Для состояний, с которыми установщик не работает, утилиту запускают сами. Каждый запуск установщика первым делом, ещё до проверки паролей, кладёт свою утилиту в -`C:\DISTR\Tools\SemiBase\semibase.exe`. Поэтому она есть и на машине, где установщик ни разу не -завершился успешно, и не нужно ни скачивать её, ни собирать. Удаление SemiBase эту копию не -трогает. +`C:\Program Files\SemiBase Setup\semibase.exe`. Поэтому она есть и на машине, где установщик ни +разу не завершился успешно, и не нужно ни скачивать её, ни собирать. Удаление SemiBase эту копию +не трогает. Заменить её может только администратор, поэтому запускать её от имени администратора +с паролями в окружении безопасно. Утилита готовит только PostgreSQL 17 и откажет серверу другой версии. Ей нужны порт сервера, суперпользователь и, если сервер работает как служба Windows, имя службы: @@ -316,7 +323,7 @@ Set-Service $service -StartupType Automatic; Start-Service $service $env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password $env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password $env:SEMIBASE_PLOT_PASSWORD = 'semiplot' -& 'C:\DISTR\Tools\SemiBase\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service +& 'C:\Program Files\SemiBase Setup\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service $LASTEXITCODE ``` diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index 6335090..ba043ae 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -80,11 +80,11 @@ All cases run in the `installer` CI job on `windows-latest`, in this order, each asserting Setup's exit code as `Process.ExitCode` reports it (Setup is a GUI exe, so `$LASTEXITCODE` is never set). A refusal is exit 7 with its reason as the last line of the newest -`C:\DISTR\Logs\SemiBase\setup-*.log`. The passwords are random 24-character tokens generated per -run; `$psql = "$env:PGBIN\psql.exe"`. +`C:\Program Files\SemiBase Setup\Logs\setup-*.log`. The passwords are random 24-character tokens +generated per run; `$psql = "$env:PGBIN\psql.exe"`. 1. No `/SUPERPASSWORD` and no `SEMIBASE_SUPER_PASSWORD`: a refusal naming the missing superuser - password; `{app}\semibase.exe` does not exist; `C:\DISTR\Tools\SemiBase\semibase.exe` exists - and runs; the runner's `postgresql-x64-17` is still Disabled. + password; `{app}\semibase.exe` does not exist; `C:\Program Files\SemiBase Setup\semibase.exe` + exists and runs; the runner's `postgresql-x64-17` is still Disabled. 2. Existing 17 (the runner's, `SEMIBASE_SUPER_PASSWORD=root` and a `SEMIBASE_WRITER_PASSWORD` token in the environment, no parameter; nothing has created `scada_writer` on that cluster yet): exit 0; the service start type is Automatic; `semiplot` with password `semiplot` and @@ -95,9 +95,11 @@ run; `$psql = "$env:PGBIN\psql.exe"`. 4. A fake `Installations\postgresql-x64-14` key with a `Services` port: a refusal naming the key, version 14 and the port, and not the password; the fake keys are deleted afterwards. 5. On the clean machine, refusals with no `postgresql*` service afterwards: a Cyrillic - `/SUPERPASSWORD`; one containing `"` as a parameter and through the variable; one ending in - `\`; one starting with `-`; one starting with a space; an unquoted `/SUPERPASSWORD=a b`; a - Cyrillic `/WRITERPASSWORD`; a `/WRITERPASSWORD` containing `"`. Then the engine rows, each + `/SUPERPASSWORD`; one containing `"` as a parameter and through the variable; a + `/SUPERPASSWORD` and a `/WRITERPASSWORD` ending in `"`; one ending in `\`; one starting with + `-`; one starting with a space; an unquoted `/SUPERPASSWORD=a b`; a Cyrillic + `/WRITERPASSWORD`; a `/WRITERPASSWORD` containing `"`. A quoted `"/SUPERPASSWORD=two words"` + passes every parameter and password check and refuses at the writer guard. Then the engine rows, each from a fake key or object deleted afterwards: an incomplete 17 key (no `Super User`), an orphaned 17 key (`Service ID` naming no service), a listener on `127.0.0.1:5432`, a file in `C:\Program Files\PostgreSQL\17\data`, and no writer password. @@ -114,13 +116,14 @@ run; `$psql = "$env:PGBIN\psql.exe"`. A run with the registered `Port` set to 15433: a refusal naming `localhost:15433` after the 60 s wait; the port is restored. 8. `/SUPERPASSWORD=wrong`: a refusal; the site log names the failed connection. -9. `Select-String` for all four tokens over the EDB install logs, `C:\DISTR\Logs\SemiBase\*.log` +9. `Select-String` for all four tokens over the EDB install logs, + `C:\Program Files\SemiBase Setup\Logs\*.log` and `C:\Program Files\PostgreSQL\17\data\log\*` finds nothing, and the server log holds the `semiplot` probe statement, so statement logging was on. Setup's `{tmp}` is gone at exit; case 6 watched it. No case passes `/LOG`: the Inno log carries Setup's command line by design. 10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is - still present, `semiplot` still logs in, and `C:\DISTR\Logs\SemiBase\*.log` and - `C:\DISTR\Tools\SemiBase\semibase.exe` remain. + still present, `semiplot` still logs in, and `C:\Program Files\SemiBase Setup\Logs\*.log` + and `C:\Program Files\SemiBase Setup\semibase.exe` remain. ## Solution Overview - All provisioning runs in `PrepareToInstall`, before Inno copies a file. A non-empty return @@ -128,10 +131,14 @@ run; `$psql = "$env:PGBIN\psql.exe"`. `CurStepChanged` happens after files and the uninstaller are registered and may still exit 0. - Under `/VERYSILENT` the returned refusal text reaches no one, and `/LOG` records Setup's command line, so `PrepareToInstall` also appends every refusal, with no password in it, to - `C:\DISTR\Logs\SemiBase\setup-.log`, creating the directory itself because - `[Dirs]` has not run yet. + `C:\Program Files\SemiBase Setup\Logs\setup-.log`, creating the directory + itself because `[Dirs]` has not run yet. Setup's logs and the recovery copy live in + `{commonpf}\SemiBase Setup`, not under `C:\DISTR`: any user can create, fill or replace a + `C:\DISTR` folder, and elevated Setup writes there + (`installer.md#the-setup`). - `semibase.exe` has one `[Files]` entry into `{app}`; `ExtractTemporaryFile` also takes it to - `{tmp}` for the run, and every run first copies it to `C:\DISTR\Tools\SemiBase\semibase.exe`. + `{tmp}` for the run, and every run first copies it to + `C:\Program Files\SemiBase Setup\semibase.exe`. That copy is the recovery tool when Setup refuses a state: every refusal precedes `[Files]`, so `{app}` holds a copy only after a successful run, and the release attaches no Windows binary, which an offline site could not download anyway. Either copy answers @@ -158,7 +165,8 @@ run; `$psql = "$env:PGBIN\psql.exe"`. and no leading or trailing space, both unmeasured (`installer.md#password-characters`). On a registered 17 the password reaches `semibase` byte for byte. Setup's own command-line parser drops every `"`, so a password parameter whose value does not appear verbatim in - `GetCmdTail` is refused too, and so is any argument that does not start with `/`, which an + `GetCmdTail`, or is followed there by anything but the end, a space or a closing quote, is + refused too, and so is any argument that does not start with `/`, which an unquoted space in a password parameter produces; a password with `"` reaches Setup only through its variable (`installer.md#inno-setup`). - The superuser password is required on every run and stored nowhere. @@ -227,7 +235,7 @@ run; `$psql = "$env:PGBIN\psql.exe"`. ### `semibase` invocation - `semibase.exe site --port --superuser --expected-major 17 --restart-service `, output redirected through `cmd.exe /c` to - `C:\DISTR\Logs\SemiBase\site-.log`. No password is on this line. + `C:\Program Files\SemiBase Setup\Logs\site-.log`. No password is on this line. - Before the `Exec`, `[Code]` sets the three variables through `function SetEnvironmentVariable(lpName, lpValue: string): BOOL; external 'SetEnvironmentVariableW@kernel32.dll stdcall';`. @@ -308,7 +316,8 @@ run; `$psql = "$env:PGBIN\psql.exe"`. `{autopf}\SemiBase`, `OutputBaseFilename=SemiBase-Setup`, the three `/D` defines required - [x] `[Languages]` ru and en; `[Dirs]` `C:\DISTR\Logs\SemiBase` with `uninsneveruninstall` (removed in review: `ForceDirectories` creates the folder before `[Dirs]` runs, and the - uninstaller never removes a folder it did not create); `[Files]` as in Solution Overview + uninstaller never removes a folder it did not create; a later review moved the logs to + `{commonpf}\SemiBase Setup\Logs`); `[Files]` as in Solution Overview - [x] resolve the superuser and writer passwords from the parameter, else the `SEMIBASE_*` variable; refuse an empty superuser password in silent mode, any password that is not printable ASCII, and a superuser password that contains `"` or ends in `\`, from From f72dbc909176cc19bf9bed88e5e96cb4fdb7e7b4 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:28:56 +0300 Subject: [PATCH 32/35] fix(installer): refuse a / word Setup does not read An unquoted /SUPERPASSWORD=abc /def reached Setup as two arguments: the value abc matched the command tail verbatim and /def passed the stray-argument check because it starts with /. Inno 6.7.3 ignores an unknown switch, so EDB and site took abc, Setup exited 0 and the typed password did not log in; the same held for /WRITERPASSWORD. Every argument must now be a password parameter or a switch Inno's InitializeSetup loop reads (Setup.MainFunc.pas, SetupLdr.dpr at is-6_7_3), so switches after the passwords still work. The refusal names the argument by number only, since its text may be the rest of a password. A scratch probe confirmed that Inno ignores unknown switches and that the documented forms still pass; installer.md records it. CI case 5 adds both split forms, a /NOCANCEL after the password, and "/SUPERPASSWORD=two words\", which only the whole value carries to the trailing-\ rule. The plan's case 9 line and its ci.yml comment now say what case 6 watches. --- .github/workflows/ci.yml | 13 ++++++-- CLAUDE.md | 9 +++--- Installer/SemiBase.iss | 38 ++++++++++++++++++++++-- docs/architecture/installer.md | 26 +++++++++++++--- docs/deployment.md | 10 +++++-- docs/plans/20261019-windows-installer.md | 20 ++++++++----- 6 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4552c86..7ea2ab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -438,7 +438,9 @@ jobs: # The superuser rules hold on the fresh-install row, which a clean machine takes. The # password parameter comes last, so an unbalanced " swallows no other parameter. The # quoted parameter with a space passes every parameter and password check and stops - # at the writer guard, so the trailing-quote rule does not refuse the documented form. + # at the writer guard, so the trailing-quote rule does not refuse the documented form; + # with a trailing \ it stops at the \ rule, which only the whole value can reach. A + # switch after the password passes; a / word Setup does not read is refused. - name: Refuse a non-ASCII password and a password with a quote run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" @@ -448,9 +450,13 @@ jobs: Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z"') 'The /SUPERPASSWORD value does not reach Setup as typed' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w"') 'The /WRITERPASSWORD value does not reach Setup as typed' Assert-Refused (Invoke-Setup '"/SUPERPASSWORD=two words"') 'No scada_writer password' + Assert-Refused (Invoke-Setup '"/SUPERPASSWORD=two words\"') 'must not contain " or end in \' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z\') 'must not contain " or end in \' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=-Aa1z') 'must not start with -, or start or end with a space' - Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z Aa2z') 'does not start with /' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z Aa2z') 'Setup argument 5 is not a switch Setup reads' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /Aa2z') 'Setup argument 5 is not a switch Setup reads' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w /Aa2w') 'Setup argument 6 is not a switch Setup reads' + Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /NOCANCEL') 'No scada_writer password' Assert-Refused (Invoke-Setup "/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1${nonAscii}z") 'The scada_writer password (/WRITERPASSWORD or SEMIBASE_WRITER_PASSWORD) must be printable ASCII' Assert-Refused (Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1"z') 'The /WRITERPASSWORD value does not reach Setup as typed' $env:SEMIBASE_SUPER_PASSWORD = 'Aa1"z' @@ -619,7 +625,8 @@ jobs: # No case passes /LOG: Inno's own log records Setup's command line, passwords # included. The search prints file names and hit counts, never a matched line. - # Setup's {tmp} is not searched: Inno removes it at exit, and case 6 watched it. + # Setup's {tmp} is not searched: case 6 saw the option file gone once the site + # log appeared, while Setup still ran. - name: Find no password in the logs run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" diff --git a/CLAUDE.md b/CLAUDE.md index ac77476..2a1bf68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,10 +152,11 @@ refuses, installs no `semibase.exe` in `{app}`, leaves the recovery copy in `C:\Program Files\SemiBase Setup` and the runner's service Disabled; the runner's own 17 provisions with exit 0, Automatic, `semiplot`/`semiplot` and the generated `scada_writer` password log in; the runner's PostgreSQL is uninstalled, leaving the empty `Installations` and `Services` keys; a fake -`postgresql-x64-14` key refuses without the password in the log; eleven password and parameter -refusals (non-ASCII, `"`, a trailing `"` on either parameter, trailing `\`, leading `-` and -space, an unquoted split, the writer rules) and a quoted `"/SUPERPASSWORD=two words"` that -passes them; the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data +`postgresql-x64-14` key refuses without the password in the log; fourteen password and parameter +refusals (non-ASCII, `"`, a trailing `"` on either parameter, trailing `\`, a quoted two-word +value ending in `\`, leading `-` and space, an unquoted split, a split before a `/` word Setup +does not read on either parameter, the writer rules) and a quoted `"/SUPERPASSWORD=two words"` +and a `/NOCANCEL` after the password that pass them; the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data directory and a missing writer password refuse with no `postgresql*` service left; a clean machine installs EDB 17 through the `SEMIBASE_*` variables while a job watches the option file appear and vanish before the site log (`restarting service`, nothing pending, `localhost`, diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index 582ba1b..a4d8d2c 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -137,16 +137,48 @@ begin 'reaches Setup only through ' + VariableName + '.'; end; -// An unquoted space splits a password parameter, and Setup would keep the part before it +function IsNameInList(const Name: String; const Names: array of String): Boolean; +var + I: Integer; +begin + Result := False; + for I := 0 to GetArrayLength(Names) - 1 do + if CompareText(Name, Names[I]) = 0 then + begin + Result := True; + Exit; + end; +end; + +// The switches Inno Setup 6.7.3 reads, from Setup.MainFunc.pas and SetupLdr.dpr at tag is-6_7_3, +// named up to and including the first =, as Inno names them. Inno ignores any other switch. +function IsSetupSwitch(const Argument: String): Boolean; +var + Name: String; +begin + Name := Argument; + if Pos('=', Name) > 0 then + Name := Copy(Name, 1, Pos('=', Name)); + Result := IsNameInList(Name, ['/SUPERPASSWORD=', '/WRITERPASSWORD=', '/SP-', '/SILENT', + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/RESTARTEXITCODE=', '/LOG', '/LOG=', + '/NOCANCEL', '/NOSTYLE', '/ALLUSERS', '/CURRENTUSER', '/LANG=', '/DIR=', '/GROUP=', + '/NOICONS', '/TYPE=', '/COMPONENTS=', '/TASKS=', '/MERGETASKS=', '/PASSWORD=', '/LOADINF=', + '/SAVEINF=', '/CLOSEAPPLICATIONS', '/NOCLOSEAPPLICATIONS', '/FORCECLOSEAPPLICATIONS', + '/NOFORCECLOSEAPPLICATIONS', '/LOGCLOSEAPPLICATIONS', '/RESTARTAPPLICATIONS', + '/NORESTARTAPPLICATIONS', '/REDIRECTIONGUARD', '/NOREDIRECTIONGUARD']); +end; + +// An unquoted space splits a password parameter, and Setup would keep the part before it. The +// message names the argument by number alone: its text may be the rest of a password. function StrayArgumentRefusal: String; var I: Integer; begin Result := ''; for I := 1 to ParamCount do - if Copy(ParamStr(I), 1, 1) <> '/' then + if not IsSetupSwitch(ParamStr(I)) then begin - Result := 'Setup argument ' + IntToStr(I) + ' does not start with /. A space in an ' + + Result := 'Setup argument ' + IntToStr(I) + ' is not a switch Setup reads. A space in an ' + 'unquoted password parameter splits it there: put the quotes around the whole ' + 'parameter, or pass the password through its SEMIBASE_* variable.'; Exit; diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index a92fcae..cded0c9 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -35,8 +35,9 @@ In order, `PrepareToInstall`: included; 2. checks that each password parameter arrived as typed (`GetCmdTail`: the value stands verbatim in the tail and ends at the end, at a space, or at a `"` closing a quote opened right before - `/NAME=`) and that every argument starts with `/`, since an unquoted space splits a password - parameter into two arguments; + `/NAME=`) and that every argument is a switch Setup reads, a password parameter or one of Inno's + own ([Inno Setup](#inno-setup)), since an unquoted space splits a password parameter into two + arguments; 3. requires a superuser password and a printable ASCII `scada_writer` password; 4. decides the engine state, applying the [superuser password rules](#password-characters) on the fresh row only, and installs EDB there; @@ -329,8 +330,25 @@ Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that h `ProcessStartInfo` and by `Start-Process` (whose tail ends in a space), arrives as `two words` and passes; `/SUPERPASSWORD="Aa1z"` and `/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w"` are refused. - An unquoted `/SUPERPASSWORD=a b` arrives as two arguments, `/SUPERPASSWORD=a` and `b`, and the - first matches the tail verbatim. So the setup also refuses any argument that does not start - with `/`; the bootstrapper's `/SL5=...` is argument 1 and does. + first matches the tail verbatim. The bootstrapper's `/SL5=...` is in `GetCmdTail` only: + `ParamStr(1)` is the caller's first argument. +- Measured 2026-10-20 the same way, ISCC 6.7.3, with a probe that records `ParamStr` in + `PrepareToInstall` and refuses there: an unknown switch does not stop Setup. With `/Aa2z`, + `/NOSUCHSWITCH`, `/Aa2z=x` or the word `bare` among the arguments, Setup reached + `PrepareToInstall` under `/VERYSILENT` and exited 7, and each reached `ParamStr` unchanged, + `/SP-` too. Inno's source agrees: the argument loop of `InitializeSetup` in + `Projects/Src/Setup.MainFunc.pas` at tag `is-6_7_3` sets a flag per name it knows and skips + every other, and `/SPAWNWND=`, `/FIRSTWND=` and `/DEBUGWND=`, which Inno adds itself, never + reach `ParamStr`. So `/SUPERPASSWORD=Aa1z /Aa2z` would pass as `Aa1z`, and the setup refuses + any argument whose name, up to and including its first `=`, is neither a password parameter + nor one of that loop's names or `/SP-` from `Projects/SetupLdr.dpr`. A copy of the setup with + `PrivilegesRequired=lowest` that refuses after the password checks refused + `/SUPERPASSWORD=Aa1z /Aa2z` and `/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w /Aa2w`, and passed + `/NOCANCEL`, `/LOG=`, `/SP-`, `/DIR=` and a lowercase `/verysilent` after the + passwords, and `"/SUPERPASSWORD=two words\"` reached the trailing-`\` rule. What stays open: + a password whose part after the space is itself such a name, `x /NOCANCEL` or `x /DIR=y`, still + arrives cut unless it is quoted. An Inno update that adds a switch needs it added to + `IsSetupSwitch`, or the setup refuses it. Measured 2026-10-19 the same way, with a scratch setup that extracts two text files and refuses: diff --git a/docs/deployment.md b/docs/deployment.md index 0bf1839..0eae43e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -85,8 +85,12 @@ $setup.ExitCode только переменной. Пароль с пробелом тоже проще передать переменной. Параметром он доходит, только если кавычки стоят вокруг всего параметра в самой командной строке. В `Start-Process` внешние кавычки снимает PowerShell, поэтому их пишут внутри строки: -`-ArgumentList '"/SUPERPASSWORD=two words"'`. Параметр, значение которого дошло не таким, -каким его набрали, и слово командной строки, которое не начинается с `/`, установщик отвергает. +`-ArgumentList '"/SUPERPASSWORD=two words"'`. Установщик отвергает параметр, значение которого +дошло не таким, каким его набрали. Отвергает он и любое слово командной строки, кроме параметров +паролей и ключей самого Inno Setup, таких как `/VERYSILENT`, `/LOG` и `/DIR=`, ведь пароль с +пробелом без кавычек распадается на два слова. Если же после пробела стоит ключ Inno Setup, как в +пароле `x /NOCANCEL`, установщик примет только `x`, поэтому пароль с пробелом без кавычек не +передают. Пароль, переданный параметром, виден в списке процессов, пока установщик работает, и попадает в историю команд PowerShell, если его набрали в строке. С параметром `/LOG` установщик пишет в свой @@ -241,7 +245,7 @@ PostgreSQL; до них он только кладёт копию утилиты | `No scada_writer password` | Установщик ставит PostgreSQL и создаёт `scada_writer`: передать пароль этой роли | | `The superuser password must ...`, `The scada_writer password ... must be printable ASCII` | Выбрать пароль по [правилам](#параметры-и-пароли). Правила для пароля суперпользователя действуют только при новой установке PostgreSQL | | `The /SUPERPASSWORD value does not reach Setup as typed`, то же для `/WRITERPASSWORD` | Передать пароль переменной окружения; пароль с `"` доходит только так | -| `Setup argument ... does not start with /` | Пароль с пробелом разделился на два слова: передать его переменной окружения или взять параметр в кавычки, как в разделе [параметры и пароли](#параметры-и-пароли) | +| `Setup argument ... is not a switch Setup reads` | Пароль с пробелом разделился на два слова, или в командной строке есть ключ, которого установщик не знает. Пароль передать переменной окружения или взять параметр в кавычки, как в разделе [параметры и пароли](#параметры-и-пароли) | | `... is not a complete PostgreSQL 17 installation` | Так выглядит неудачная установка PostgreSQL. Удалить её программой `uninstall-postgresql.exe`, путь к которой назван в строке, и [дождаться конца удаления](#удаление-postgresql) | | `... names no service`, `... names service ..., which does not exist`, `... has no Port value`, `... has a Super User or Service ID that contains " or %` | Регистрация PostgreSQL 17 в реестре не совпадает со службами или её значения нельзя передать утилите. Если сервер работает, подготовить его [утилитой вручную](#ручной-запуск-утилиты). Иначе удалить остатки программой `uninstall-postgresql.exe` из папки установки PostgreSQL и [дождаться конца удаления](#удаление-postgresql) | | `PostgreSQL 17 is not registered, and another major is` | Установщик работает только с PostgreSQL 17 и не ставит его рядом с другой версией. Либо перенести данные старой версии на 17 через `pg_upgrade` в плановый простой, либо удалить старую версию, если её данные не нужны. Готовить старую версию утилитой вручную бесполезно: каждый следующий запуск установщика всё равно откажет | diff --git a/docs/plans/20261019-windows-installer.md b/docs/plans/20261019-windows-installer.md index ba043ae..315edee 100644 --- a/docs/plans/20261019-windows-installer.md +++ b/docs/plans/20261019-windows-installer.md @@ -97,9 +97,12 @@ generated per run; `$psql = "$env:PGBIN\psql.exe"`. 5. On the clean machine, refusals with no `postgresql*` service afterwards: a Cyrillic `/SUPERPASSWORD`; one containing `"` as a parameter and through the variable; a `/SUPERPASSWORD` and a `/WRITERPASSWORD` ending in `"`; one ending in `\`; one starting with - `-`; one starting with a space; an unquoted `/SUPERPASSWORD=a b`; a Cyrillic - `/WRITERPASSWORD`; a `/WRITERPASSWORD` containing `"`. A quoted `"/SUPERPASSWORD=two words"` - passes every parameter and password check and refuses at the writer guard. Then the engine rows, each + `-`; one starting with a space; an unquoted `/SUPERPASSWORD=a b`; `/SUPERPASSWORD=Aa1z /Aa2z` + and `/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w /Aa2w`, each naming the `/Aa2` argument by + number; a Cyrillic `/WRITERPASSWORD`; a `/WRITERPASSWORD` containing `"`. A quoted + `"/SUPERPASSWORD=two words"` and `/SUPERPASSWORD=Aa1z /NOCANCEL` pass every parameter and + password check and refuse at the writer guard; `"/SUPERPASSWORD=two words\"` refuses at the + trailing-`\` rule, which only the whole value reaches. Then the engine rows, each from a fake key or object deleted afterwards: an incomplete 17 key (no `Super User`), an orphaned 17 key (`Service ID` naming no service), a listener on `127.0.0.1:5432`, a file in `C:\Program Files\PostgreSQL\17\data`, and no writer password. @@ -119,8 +122,9 @@ generated per run; `$psql = "$env:PGBIN\psql.exe"`. 9. `Select-String` for all four tokens over the EDB install logs, `C:\Program Files\SemiBase Setup\Logs\*.log` and `C:\Program Files\PostgreSQL\17\data\log\*` finds nothing, and the server log holds the - `semiplot` probe statement, so statement logging was on. Setup's `{tmp}` is gone at exit; - case 6 watched it. No case passes `/LOG`: the Inno log carries Setup's command line by design. + `semiplot` probe statement, so statement logging was on. Setup's `{tmp}` is not searched: + case 6 sees the option file gone once the site log appears, while Setup still runs. No case + passes `/LOG`: the Inno log carries Setup's command line by design. 10. `unins000.exe /VERYSILENT`: exit 0; `{app}\semibase.exe` is gone; `postgresql-x64-17` is still present, `semiplot` still logs in, and `C:\Program Files\SemiBase Setup\Logs\*.log` and `C:\Program Files\SemiBase Setup\semibase.exe` remain. @@ -166,9 +170,9 @@ generated per run; `$psql = "$env:PGBIN\psql.exe"`. registered 17 the password reaches `semibase` byte for byte. Setup's own command-line parser drops every `"`, so a password parameter whose value does not appear verbatim in `GetCmdTail`, or is followed there by anything but the end, a space or a closing quote, is - refused too, and so is any argument that does not start with `/`, which an - unquoted space in a password parameter produces; a password with `"` reaches Setup only - through its variable (`installer.md#inno-setup`). + refused too, and so is any argument that is neither a password parameter nor a switch Inno + 6.7.3 reads, which an unquoted space in a password parameter produces; a password with `"` + reaches Setup only through its variable (`installer.md#inno-setup`). - The superuser password is required on every run and stored nowhere. - Every run sets the `semiplot` role to `semiplot`. SemiPlot's connection file relies on the convention, so the role has one writer and no site-specific value to lose on an upgrade. From d36434d2252ea00b8a96e5ff3069ccf75f834dec Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:00:26 +0300 Subject: [PATCH 33/35] refactor(installer): give each setup fact one home The major 17 stood as a literal in the version check, the --expected-major argument and five messages, and the 60 s in the port wait refusal copied PortWaitMilliseconds. A change to either left the other copy wrong. PgMajor now feeds PgInstallation, PgService, PgDataDir and every message, and the refusal reads the constant. The password page derived the writer rule a second time, with its own wording. It now calls FreshInstallRefusal with ProbeMachine False, so the fresh row's rules and texts live in one function. LogPath creates the log folder, which LogRefusal and SiteRefusal each did before. IsSetupSwitch also accepts the two password parameters, so it is now IsKnownArgument. No refusal text changes. --- Installer/SemiBase.iss | 82 +++++++++++++++++++--------------- docs/architecture/installer.md | 2 +- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/Installer/SemiBase.iss b/Installer/SemiBase.iss index a4d8d2c..a3af983 100644 --- a/Installer/SemiBase.iss +++ b/Installer/SemiBase.iss @@ -1,6 +1,7 @@ ; SemiBase Inno Setup installer script ; Build with: iscc.exe /DAppVersion=1.2.3 /DFileVersion=1.2.3 /DSemibaseExe= Installer\SemiBase.iss -; Run `bash Installer/fetch-postgresql.sh` first; it puts the EDB installer into Installer\payload\. +; Run Installer/fetch-postgresql.sh first, through Git's bash.exe as CLAUDE.md's Build section +; shows; it puts the EDB installer into Installer\payload\. #ifndef AppVersion #error AppVersion must be defined. Pass it as: iscc.exe /DAppVersion=1.2.3 SemiBase.iss @@ -25,11 +26,13 @@ #define ToolRoot "{commonpf}\SemiBase Setup" #define LogRoot ToolRoot + "\Logs" -; The PostgreSQL 17 install this setup creates: its registry key, service, port and data directory -#define PgInstallation "postgresql-x64-17" -#define PgService "postgresql-x64-17" +; The PostgreSQL install this setup creates: its major, registry key, service, port and data +; directory +#define PgMajor "17" +#define PgInstallation "postgresql-x64-" + PgMajor +#define PgService "postgresql-x64-" + PgMajor #define PgPort "5432" -#define PgDataDir "C:\Program Files\PostgreSQL\17\data" +#define PgDataDir "C:\Program Files\PostgreSQL\" + PgMajor + "\data" [Setup] AppId={#AppId} @@ -150,9 +153,10 @@ begin end; end; -// The switches Inno Setup 6.7.3 reads, from Setup.MainFunc.pas and SetupLdr.dpr at tag is-6_7_3, -// named up to and including the first =, as Inno names them. Inno ignores any other switch. -function IsSetupSwitch(const Argument: String): Boolean; +// The setup's two password parameters and the switches Inno Setup 6.7.3 reads, from +// Setup.MainFunc.pas and SetupLdr.dpr at tag is-6_7_3, named up to and including the first =, as +// Inno names them. Inno ignores any other switch. +function IsKnownArgument(const Argument: String): Boolean; var Name: String; begin @@ -176,7 +180,7 @@ var begin Result := ''; for I := 1 to ParamCount do - if not IsSetupSwitch(ParamStr(I)) then + if not IsKnownArgument(ParamStr(I)) then begin Result := 'Setup argument ' + IntToStr(I) + ' is not a switch Setup reads. A space in an ' + 'unquoted password parameter splits it there: put the quotes around the whole ' + @@ -234,8 +238,10 @@ begin 'file, and neither is known to keep such a password.'; end; +// Creates the log folder, so a log written before KeepToolRefusal ran has one too function LogPath(const Prefix: String): String; begin + ForceDirectories(ExpandConstant('{#LogRoot}')); Result := ExpandConstant('{#LogRoot}\') + Prefix + '-' + GetDateTimeString('yyyymmdd-hhnnss', #0, #0) + '.log'; end; @@ -247,7 +253,6 @@ var begin SetArrayLength(Lines, 1); Lines[0] := Message; - ForceDirectories(ExpandConstant('{#LogRoot}')); SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); end; @@ -314,7 +319,7 @@ begin Result := (Pos('"', Value) = 0) and (Pos('%', Value) = 0); end; -// The 17 key exists: it is used only when it is a complete install with a service +// The {#PgInstallation} key exists: it is used only when it is a complete install with a service function RegisteredEngineRefusal(var Engine: TEngine): String; var Key, Version, SuperUser, DataDirectory, BaseDirectory, ServiceName, Missing: String; @@ -327,7 +332,7 @@ begin RegQueryStringValue(HKLM64, Key, 'Data Directory', DataDirectory); RegQueryStringValue(HKLM64, Key, 'Service ID', ServiceName); - if Copy(Version, 1, 3) <> '17.' then + if Copy(Version, 1, Length('{#PgMajor}.')) <> '{#PgMajor}.' then Missing := 'its Version is ''' + Version + '''' else if SuperUser = '' then Missing := 'it has no Super User' @@ -339,7 +344,7 @@ begin begin if not RegQueryStringValue(HKLM64, Key, 'Base Directory', BaseDirectory) then BaseDirectory := ''; - Result := 'HKLM\' + Key + ' is not a complete PostgreSQL 17 installation: ' + Missing + + Result := 'HKLM\' + Key + ' is not a complete PostgreSQL {#PgMajor} installation: ' + Missing + '. A failed PostgreSQL install leaves this; remove it with ' + BaseDirectory + '\uninstall-postgresql.exe, then run Setup again.' + RecoveryHint; Exit; @@ -365,7 +370,7 @@ begin Engine.SuperUser := SuperUser; end; -// The 17 key does not exist, and another major may be registered +// The {#PgInstallation} key does not exist, and another major may be registered function OtherMajorRefusal: String; var Names: TArrayOfString; @@ -392,18 +397,16 @@ begin Version + ', service ' + ServiceName + ', ' + PortText + ')'; end; if Registered <> '' then - Result := 'PostgreSQL 17 is not registered, and another major is: ' + Registered + - '. Setup provisions PostgreSQL 17 only.' + RecoveryHint; + Result := 'PostgreSQL {#PgMajor} is not registered, and another major is: ' + Registered + + '. Setup provisions PostgreSQL {#PgMajor} only.' + RecoveryHint; end; // Nothing is registered: a fresh install must not meet a server or data it does not know -function FreshInstallRefusal(const SuperPassword, WriterPassword: String): String; +function FreshMachineRefusal: String; var Accepts: Boolean; begin - Result := FreshPasswordRefusal(SuperPassword); - if Result <> '' then - Exit; + Result := ''; if not ProbePort({#PgPort}, Accepts) then Result := 'Setup could not probe localhost:{#PgPort} through powershell.exe.' + RecoveryHint else if Accepts then @@ -412,10 +415,20 @@ begin else if IsNonEmptyDirectory('{#PgDataDir}') then Result := 'No PostgreSQL is registered, but {#PgDataDir} exists and is not empty. Setup never ' + 'overwrites existing data: remove the directory, for example after an interrupted ' + - 'install, then run Setup again.' + RecoveryHint - else if WriterPassword = '' then - Result := 'No scada_writer password: this run installs PostgreSQL 17 and creates the ' + - 'scada_writer role. Pass /WRITERPASSWORD= or set SEMIBASE_WRITER_PASSWORD.'; + 'install, then run Setup again.' + RecoveryHint; +end; + +// The fresh row's rules in their order. The password page passes ProbeMachine False: a probe of a +// closed port takes about 7 s, and the page checks only what the user can correct in a field. +function FreshInstallRefusal(const SuperPassword, WriterPassword: String; + ProbeMachine: Boolean): String; +begin + Result := FreshPasswordRefusal(SuperPassword); + if (Result = '') and ProbeMachine then + Result := FreshMachineRefusal; + if (Result = '') and (WriterPassword = '') then + Result := 'No scada_writer password: this run installs PostgreSQL {#PgMajor} and creates ' + + 'the scada_writer role. Pass /WRITERPASSWORD= or set SEMIBASE_WRITER_PASSWORD.'; end; function EngineIsFresh: Boolean; @@ -433,7 +446,7 @@ begin begin Result := OtherMajorRefusal; if Result = '' then - Result := FreshInstallRefusal(SuperPassword, WriterPassword); + Result := FreshInstallRefusal(SuperPassword, WriterPassword, True); end; end; @@ -514,7 +527,8 @@ begin Sleep(1000); until GetTickCount - Started >= PortWaitMilliseconds; Result := 'Service ' + ServiceName + ' is started, but localhost:' + IntToStr(Port) + - ' accepted no TCP connection within 60 s.' + RecoveryHint; + ' accepted no TCP connection within ' + IntToStr(PortWaitMilliseconds div 1000) + ' s.' + + RecoveryHint; end; // Read back rather than trusted: an empty value may delete the variable or store it empty, and @@ -541,11 +555,10 @@ begin RecoveryHint; Exit; end; - ForceDirectories(ExpandConstant('{#LogRoot}')); SiteLog := LogPath('site'); if not Exec(ExpandConstant('{cmd}'), '/s /c ""' + ExpandConstant('{tmp}\semibase.exe') + '" site --port ' + IntToStr(Engine.Port) + ' --superuser "' + Engine.SuperUser + - '" --expected-major 17 --restart-service "' + Engine.ServiceName + '" > "' + SiteLog + + '" --expected-major {#PgMajor} --restart-service "' + Engine.ServiceName + '" > "' + SiteLog + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then Result := 'Setup could not start cmd.exe to run semibase site: ' + SysErrorMessage(ExitCode) + '.' + RecoveryHint @@ -561,10 +574,10 @@ var procedure InitializeWizard; begin PasswordPage := CreateInputQueryPage(wpSelectDir, 'PostgreSQL passwords', - 'Setup provisions PostgreSQL 17 with these passwords.', + 'Setup provisions PostgreSQL {#PgMajor} with these passwords.', 'The superuser password is required on every run. The scada_writer password is required ' + - 'when Setup installs PostgreSQL 17; leave it empty on a server where scada_writer exists ' + - 'to keep its current password.'); + 'when Setup installs PostgreSQL {#PgMajor}; leave it empty on a server where scada_writer ' + + 'exists to keep its current password.'); PasswordPage.Add('&Superuser password:', True); PasswordPage.Add('scada_&writer password:', True); PasswordPage.Values[0] := ResolvePassword('SUPERPASSWORD', 'SEMIBASE_SUPER_PASSWORD'); @@ -585,12 +598,7 @@ begin else Refusal := PasswordRefusal(PasswordPage.Values[0], PasswordPage.Values[1]); if (Refusal = '') and EngineIsFresh and (OtherMajorRefusal = '') then - begin - Refusal := FreshPasswordRefusal(PasswordPage.Values[0]); - if (Refusal = '') and (PasswordPage.Values[1] = '') then - Refusal := 'Enter the scada_writer password: no PostgreSQL 17 is registered, so Setup ' + - 'installs it and creates the scada_writer role.'; - end; + Refusal := FreshInstallRefusal(PasswordPage.Values[0], PasswordPage.Values[1], False); if Refusal <> '' then begin MsgBox(Refusal, mbError, MB_OK); diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index cded0c9..9c5ebef 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -348,7 +348,7 @@ Measured 2026-10-19 the same way, with a copy of `Installer/SemiBase.iss` that h passwords, and `"/SUPERPASSWORD=two words\"` reached the trailing-`\` rule. What stays open: a password whose part after the space is itself such a name, `x /NOCANCEL` or `x /DIR=y`, still arrives cut unless it is quoted. An Inno update that adds a switch needs it added to - `IsSetupSwitch`, or the setup refuses it. + `IsKnownArgument`, or the setup refuses it. Measured 2026-10-19 the same way, with a scratch setup that extracts two text files and refuses: From a4f9e1ae850b9a76017ed2d84cc2a55ea91fb45e Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:00:35 +0300 Subject: [PATCH 34/35] refactor(ci): share the installer case helpers installer-case.ps1 holds the setup's paths, but the diagnostics step spelled out the token file and the log folder again and carried its own redaction loop, and the cases named postgresql-x64-17 per step and ran psql as postgres three different ways. A path or name change had to be made in several places. The helper now holds $PgInstallation, $PgService, Invoke-Super and Write-Redacted, which Write-Logs uses, and the diagnostics step dot-sources it. Shellcheck covers .github/scripts/*.sh too, which it passes with no change, as bash.md asks of every script. --- .github/scripts/installer-case.ps1 | 20 ++++++++-- .github/workflows/ci.yml | 64 ++++++++++++------------------ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/scripts/installer-case.ps1 b/.github/scripts/installer-case.ps1 index e5bdc4b..2d68cfd 100644 --- a/.github/scripts/installer-case.ps1 +++ b/.github/scripts/installer-case.ps1 @@ -4,6 +4,9 @@ $SetupExe = "$env:GITHUB_WORKSPACE\Installer\Output\SemiBase-Setup.exe" $LogDir = "$env:ProgramFiles\SemiBase Setup\Logs" $ToolExe = "$env:ProgramFiles\SemiBase Setup\semibase.exe" $TokenFile = "$env:RUNNER_TEMP\case-passwords.txt" +# The registry key and service of the runner's 17 and of the one the setup installs +$PgInstallation = 'postgresql-x64-17' +$PgService = 'postgresql-x64-17' function Fail([string] $message) { Write-Host "::error::$message" @@ -29,12 +32,15 @@ function Invoke-Setup([string] $arguments = '') { # The setup and site logs carry no password by design; the redaction keeps a regression out of # the public job log +function Write-Redacted([string] $text, [string[]] $tokens = @()) { + foreach ($token in $tokens) { $text = $text.Replace($token, '') } + Write-Host $text +} + function Write-Logs($run, [string[]] $tokens = @()) { foreach ($log in $run.Logs) { - $text = "$(Get-Content -Raw $log.FullName)" - foreach ($token in $tokens) { $text = $text.Replace($token, '') } Write-Host "--- $($log.Name)" - Write-Host $text + Write-Redacted "$(Get-Content -Raw $log.FullName)" $tokens } } @@ -70,3 +76,11 @@ function Assert-Login([string] $role, [string] $password, [string] $database = ' & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U $role -d $database -tAc 'SELECT 1' | Out-Null if ($LASTEXITCODE -ne 0) { Fail "$role cannot log in to $database with the expected password" } } + +# Returns the trimmed output of one statement run as postgres +function Invoke-Super([string] $password, [string] $sql) { + $env:PGPASSWORD = $password + $out = & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -qtAc $sql + if ($LASTEXITCODE -ne 0) { Fail "psql as postgres failed on: $sql" } + "$out".Trim() +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ea2ab7..0a85f5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,8 +135,8 @@ jobs: - name: Test run: go test -race ./... - - name: Shellcheck the payload fetch script - run: shellcheck Installer/fetch-postgresql.sh + - name: Shellcheck the shell scripts + run: shellcheck Installer/*.sh .github/scripts/*.sh # The site path, the only thing that covers the ALTER SYSTEM tuning: the # same binary provisions the service container the way it provisions an @@ -345,15 +345,14 @@ jobs: - name: Refuse a run without a superuser password run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" - $service = 'postgresql-x64-17' if (Test-Path Env:SEMIBASE_SUPER_PASSWORD) { Fail 'SEMIBASE_SUPER_PASSWORD is set, so this case tests nothing' } Assert-Refused (Invoke-Setup) 'No superuser password' if (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe") { Fail 'the refused run installed semibase.exe' } if (-not (Test-Path $ToolExe)) { Fail "the refused run left no recovery copy at $ToolExe" } & $ToolExe version if ($LASTEXITCODE -ne 0) { Fail 'the recovery copy does not run' } - $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode - if ($startMode -ne 'Disabled') { Fail "the refused run changed $service to start type '$startMode' (want Disabled)" } + $startMode = (Get-CimInstance Win32_Service -Filter "Name='$PgService'").StartMode + if ($startMode -ne 'Disabled') { Fail "the refused run changed $PgService to start type '$startMode' (want Disabled)" } # Not GITHUB_ENV: an exported variable would reach every step. A case that # hands a token through the environment sets it inside its own step, and @@ -375,15 +374,14 @@ jobs: run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $tokens = @(Get-Content $TokenFile) - $service = 'postgresql-x64-17' $env:SEMIBASE_WRITER_PASSWORD = $tokens[0] $run = Invoke-Setup Write-Logs $run $tokens if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } Get-SiteLog $run | Out-Null if (-not (Test-Path "$env:ProgramFiles\SemiBase\semibase.exe")) { Fail 'the run did not install semibase.exe' } - $startMode = (Get-CimInstance Win32_Service -Filter "Name='$service'").StartMode - if ($startMode -ne 'Auto') { Fail "$service has start type '$startMode' (want Auto)" } + $startMode = (Get-CimInstance Win32_Service -Filter "Name='$PgService'").StartMode + if ($startMode -ne 'Auto') { Fail "$PgService has start type '$startMode' (want Auto)" } Assert-Login semiplot semiplot Assert-Login scada_writer $tokens[0] @@ -395,7 +393,7 @@ jobs: timeout-minutes: 10 run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" - $base = (Get-ItemProperty 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17').'Base Directory' + $base = (Get-ItemProperty "HKLM:\SOFTWARE\PostgreSQL\Installations\$PgInstallation").'Base Directory' $uninstaller = Start-Process -FilePath "$base\uninstall-postgresql.exe" ` -ArgumentList '--mode', 'unattended', '--unattendedmodeui', 'none' -Wait -PassThru if ($uninstaller.ExitCode -ne 0) { Fail "uninstall-postgresql.exe exited $($uninstaller.ExitCode)" } @@ -468,7 +466,7 @@ jobs: - name: Refuse an incomplete PostgreSQL 17 registration run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" - $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' -Force + $fake = New-Item -Path "HKLM:\SOFTWARE\PostgreSQL\Installations\$PgInstallation" -Force New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '17.11-5' | Out-Null New-ItemProperty -Path $fake.PSPath -Name 'Base Directory' -Value 'C:\Program Files\PostgreSQL\17' | Out-Null $run = Invoke-Setup '/SUPERPASSWORD=Aa1z /WRITERPASSWORD=Aa1w' @@ -481,7 +479,7 @@ jobs: . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $data = New-Item -ItemType Directory -Path "$env:RUNNER_TEMP\orphaned-data" -Force Set-Content -Path "$data\PG_VERSION" -Value 17 - $fake = New-Item -Path 'HKLM:\SOFTWARE\PostgreSQL\Installations\postgresql-x64-17' -Force + $fake = New-Item -Path "HKLM:\SOFTWARE\PostgreSQL\Installations\$PgInstallation" -Force New-ItemProperty -Path $fake.PSPath -Name 'Version' -Value '17.11-5' | Out-Null New-ItemProperty -Path $fake.PSPath -Name 'Super User' -Value 'postgres' | Out-Null New-ItemProperty -Path $fake.PSPath -Name 'Data Directory' -Value $data.FullName | Out-Null @@ -550,19 +548,13 @@ jobs: if (-not $watched) { Fail 'the option-file watch saw no site log' } if (-not $watched.Seen) { Fail 'the watch never saw the option file, so it watched the wrong place' } if ($watched.PresentWithSiteLog) { Fail 'the option file was still there when the site log appeared' } - if (-not (Get-SiteLog $run).Contains('restarting service postgresql-x64-17')) { - Fail 'the site log does not record restarting service postgresql-x64-17' + if (-not (Get-SiteLog $run).Contains("restarting service $PgService")) { + Fail "the site log does not record restarting service $PgService" } - $env:PGPASSWORD = $super - function Invoke-Super([string] $sql) { - $out = & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -tAc $sql - if ($LASTEXITCODE -ne 0) { Fail "psql as postgres failed on: $sql" } - "$out".Trim() - } - $pending = Invoke-Super 'SELECT count(*) FROM pg_settings WHERE pending_restart' - $listen = Invoke-Super 'SHOW listen_addresses' - $databases = Invoke-Super ("SELECT string_agg(concat_ws('|', datname, datcollate, datctype, " + + $pending = Invoke-Super $super 'SELECT count(*) FROM pg_settings WHERE pending_restart' + $listen = Invoke-Super $super 'SHOW listen_addresses' + $databases = Invoke-Super $super ("SELECT string_agg(concat_ws('|', datname, datcollate, datctype, " + "pg_encoding_to_char(encoding)), ',' ORDER BY datname) FROM pg_database " + "WHERE datname IN ('template1', 'scada_archive')") $wantDatabases = 'scada_archive|C|C|UTF8,template1|C|C|UTF8' @@ -580,11 +572,8 @@ jobs: . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $tokens = @(Get-Content $TokenFile) $super, $writer = $tokens[1], $tokens[2] - $env:PGPASSWORD = $super - & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -qtAc "ALTER SYSTEM SET log_statement = 'all'" - if ($LASTEXITCODE -ne 0) { Fail 'could not set log_statement' } - & "$env:PGBIN\psql.exe" -w -h 127.0.0.1 -U postgres -d postgres -qtAc 'SELECT pg_reload_conf()' - if ($LASTEXITCODE -ne 0) { Fail 'could not reload the configuration' } + Invoke-Super $super "ALTER SYSTEM SET log_statement = 'all'" | Out-Null + Invoke-Super $super 'SELECT pg_reload_conf()' | Out-Null $run = Invoke-Setup "/SUPERPASSWORD=$super" Write-Logs $run $tokens if ($run.ExitCode -ne 0) { Fail "want exit 0, got $($run.ExitCode)" } @@ -608,10 +597,10 @@ jobs: run: | . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" $tokens = @(Get-Content $TokenFile) - $key = 'HKLM:\SOFTWARE\PostgreSQL\Services\postgresql-x64-17' + $key = "HKLM:\SOFTWARE\PostgreSQL\Services\$PgService" Set-ItemProperty -Path $key -Name Port -Value 15433 -Type DWord try { $run = Invoke-Setup "/SUPERPASSWORD=$($tokens[1])" } finally { Set-ItemProperty -Path $key -Name Port -Value 5432 -Type DWord } - Assert-Refused $run 'Service postgresql-x64-17 is started, but localhost:15433 accepted no TCP connection within 60 s' + Assert-Refused $run "Service $PgService is started, but localhost:15433 accepted no TCP connection within 60 s" - name: Refuse a wrong superuser password run: | @@ -668,7 +657,7 @@ jobs: if ((Get-Date) -gt $deadline) { Fail 'semibase.exe is still there 60 s after the uninstaller exited' } Start-Sleep -Seconds 1 } - if (-not (Get-Service postgresql-x64-17 -ErrorAction SilentlyContinue)) { Fail 'the uninstall removed postgresql-x64-17' } + if (-not (Get-Service $PgService -ErrorAction SilentlyContinue)) { Fail "the uninstall removed $PgService" } if (@(Get-ChildItem "$LogDir\*.log" -ErrorAction SilentlyContinue).Count -eq 0) { Fail "the uninstall removed the logs in $LogDir" } if (-not (Test-Path $ToolExe)) { Fail "the uninstall removed the recovery copy $ToolExe" } Assert-Login semiplot semiplot @@ -679,15 +668,12 @@ jobs: if: failure() run: | $ErrorActionPreference = 'Continue' - $tokens = @(Get-Content "$env:RUNNER_TEMP\case-passwords.txt" -ErrorAction SilentlyContinue) - function Write-Redacted([string] $text) { - foreach ($t in $tokens) { $text = $text.Replace($t, '') } - Write-Host $text - } + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile -ErrorAction SilentlyContinue) Get-Service postgresql* | Format-Table Name, Status, StartType | Out-String | Write-Host - Write-Redacted ((reg.exe query HKLM\SOFTWARE\PostgreSQL /s 2>&1) -join "`n") + Write-Redacted ((reg.exe query HKLM\SOFTWARE\PostgreSQL /s 2>&1) -join "`n") $tokens Get-ChildItem "$env:TEMP\install-postgresql.log", "$env:TEMP\installbuilder_installer_*.log" -ErrorAction SilentlyContinue | - ForEach-Object { Write-Host "--- $($_.Name), last 200 lines"; Write-Redacted ((Get-Content $_.FullName -Tail 200) -join "`n") } - Get-ChildItem "$env:ProgramFiles\SemiBase Setup\Logs\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | - ForEach-Object { Write-Host "--- $($_.Name)"; Write-Redacted "$(Get-Content -Raw $_.FullName)" } + ForEach-Object { Write-Host "--- $($_.Name), last 200 lines"; Write-Redacted ((Get-Content $_.FullName -Tail 200) -join "`n") $tokens } + $logs = @(Get-ChildItem "$LogDir\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) + Write-Logs ([pscustomobject]@{ Logs = $logs }) $tokens exit 0 From f6cbc10feec38363fb63f6d83edf936fbfc719d9 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:00:36 +0300 Subject: [PATCH 35/35] docs(repo): correct the pin and CI job claims The pin docs said a bump edits the fetch script alone, while installer.md keeps a measured table of the same values. "The Windows job" now names the build job, since installer also runs on Windows. release.yml's image run is the only run of the shipped Linux binary, not of all shipped bytes, since the setup smoke run runs the Windows one. The CLAUDE.md paragraphs this branch edited are rewrapped at 100 columns, and the Windows build job paragraph names the helper and the wider shellcheck. --- .github/workflows/release.yml | 2 +- CLAUDE.md | 84 ++++++++++++++++--------------- Installer/fetch-postgresql.sh | 2 +- docs/architecture/installer.md | 10 ++-- docs/architecture/provisioning.md | 2 +- 5 files changed, 52 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c3cd17..95eb20b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,7 +259,7 @@ jobs: set -euo pipefail docker build --platform linux/amd64 -f Dockerfile -t "$IMAGE:$TAG" image - # The only run of the shipped bytes anywhere. CI's smoke check runs a + # The only run of the shipped Linux binary anywhere. CI's smoke check runs a # binary built without -trimpath and without -s -w, so it exercises a # different artifact than the one consumers pull. docker run --rm "$IMAGE:$TAG" version diff --git a/CLAUDE.md b/CLAUDE.md index 2a1bf68..1151545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,10 @@ iscc /DAppVersion=0.0.0-dev /DFileVersion=0.0.0 "/DSemibaseExe=$PWD\semibase.exe The output is `Installer/Output/SemiBase-Setup.exe`. Under Git Bash, prefix `iscc` with `MSYS2_ARG_CONV_EXCL='*'`, or MSYS rewrites the `/D` arguments as paths. The fetch script holds -the EDB pin alone and saves it as `Installer/payload/postgresql-17-windows-x64.exe`, the name -`#define PgInstaller` uses, so a pin bump edits the script only. CI and release build the setup -through one composite action, `.github/actions/build-setup`. +the EDB pin the build uses and saves it as `Installer/payload/postgresql-17-windows-x64.exe`, the +name `#define PgInstaller` uses, so a pin bump leaves the `.iss` alone: it edits the script's +constants and re-measures the table in `docs/architecture/installer.md#the-pinned-payload`. CI +and release build the setup through one composite action, `.github/actions/build-setup`. ## Release @@ -108,14 +109,15 @@ golangci-lint is pinned to 2.12.2 (`winget install GolangCI.golangci-lint --vers config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build` and `go test -race` on `windows-latest` and `ubuntu-latest` for every push and pull request, and the lint in the Windows `build` job only. -The Windows job then runs the `Restart the runner's PostgreSQL service through site` step, the -only direct test of the service restart; the `installer` job runs it through the setup. It requires `site --restart-service postgresql-x64-17` to -refuse the runner's shipped Disabled service with no `ALTER SYSTEM SET` line, sets it to Manual -and starts it with `sc.exe start`, which returns while it is Start Pending. It then requires the -next run, started at once, to print `restarting service postgresql-x64-17`, the line tying the -service's `-D` to the server on port 5432, and `stopped, nothing answers on :5432`, and leave -`listen_addresses = localhost` with no setting pending, a second run not to restart the service, -and `--restart-service no-such-service` to be refused with no `ALTER SYSTEM SET` line. +The `build` job then runs the `Restart the runner's PostgreSQL service through site` step, the +only direct test of the service restart; the `installer` job runs it through the setup. It +requires `site --restart-service postgresql-x64-17` to refuse the runner's shipped Disabled +service with no `ALTER SYSTEM SET` line, sets it to Manual and starts it with `sc.exe start`, +which returns while it is Start Pending. It then requires the next run, started at once, to print +`restarting service postgresql-x64-17`, the line tying the service's `-D` to the server on port +5432, and `stopped, nothing answers on :5432`, and leave `listen_addresses = localhost` with +no setting pending, a second run not to restart the service, and +`--restart-service no-such-service` to be refused with no `ALTER SYSTEM SET` line. The Linux job provisions a `postgres:17-alpine` service container by running `site` twice — the tuning path and the idempotency check in one step. The `Require the listen_addresses delta` step then requires `listen_addresses` still pending and `localhost` in `postgresql.auto.conf` @@ -139,34 +141,35 @@ password. The 14 step starts its server with all five statement-logging paramete log steps run `.github/scripts/check-server-log.sh`, which requires `logging_collector = off`, so `docker logs` is the whole server log, requires the `semiplot` login's probe statement in it, and requires the passwords absent. The `listen_addresses`, the pens and the 17 log steps need `psql` -on the runner; to run them locally, point `psql` at a container. The Windows step uses the -runner's own `$env:PGBIN\psql.exe`. +on the runner; to run them locally, point `psql` at a container. The `build` job's restart step +uses the runner's own `$env:PGBIN\psql.exe`. The `installer` job (`windows-latest`) builds `semibase.exe` and the setup through `.github/actions/build-setup` (EDB payload cached on the fetch script's hash, Inno Setup 6.7.3); -the Linux job shellchecks the fetch script. Each case step dot-sources -`.github/scripts/installer-case.ps1`, which runs Setup through `ProcessStartInfo` (a GUI exe the -call operator does not wait for, with the argument string passed as written) and asserts a -refusal as exit 7 with its reason last in the newest setup log. In order: no superuser password -refuses, installs no `semibase.exe` in `{app}`, leaves the recovery copy in -`C:\Program Files\SemiBase Setup` and the runner's service Disabled; the runner's own 17 provisions with -exit 0, Automatic, `semiplot`/`semiplot` and the generated `scada_writer` password log in; the -runner's PostgreSQL is uninstalled, leaving the empty `Installations` and `Services` keys; a fake -`postgresql-x64-14` key refuses without the password in the log; fourteen password and parameter -refusals (non-ASCII, `"`, a trailing `"` on either parameter, trailing `\`, a quoted two-word -value ending in `\`, leading `-` and space, an unquoted split, a split before a `/` word Setup -does not read on either parameter, the writer rules) and a quoted `"/SUPERPASSWORD=two words"` -and a `/NOCANCEL` after the password that pass them; the incomplete and orphaned 17 registrations, a busy port 5432, a non-empty data -directory and a missing writer password refuse with no `postgresql*` service left; a clean -machine installs EDB 17 through the `SEMIBASE_*` variables while a job watches the option file -appear and vanish before the site log (`restarting service`, nothing pending, `localhost`, -`C`/`C`/`UTF8`); a superuser-only parameter re-run under `log_statement = 'all'` restarts -nothing and keeps the writer password; a re-run with a new writer password applies it; a wrong -registered port refuses after the 60 s wait; a wrong superuser password refuses with -`password authentication failed`; none of the four generated passwords appears in any EDB, setup, -site or server log, and the server log holds the `semiplot` probe; the uninstaller removes -`semibase.exe` and keeps PostgreSQL, the logs and the recovery copy. No case passes `/LOG`, which -records Setup's command line. +the Linux job shellchecks the fetch script and `.github/scripts/*.sh`. Each case step dot-sources +`.github/scripts/installer-case.ps1`, which holds the setup's paths, the `postgresql-x64-17` key +and service names and the `psql` and redaction helpers, runs Setup through `ProcessStartInfo` (a +GUI exe the call operator does not wait for, with the argument string passed as written) and +asserts a refusal as exit 7 with its reason last in the newest setup log. In order: no superuser +password refuses, installs no `semibase.exe` in `{app}`, leaves the recovery copy in +`C:\Program Files\SemiBase Setup` and the runner's service Disabled; the runner's own 17 +provisions with exit 0, Automatic, `semiplot`/`semiplot` and the generated `scada_writer` +password log in; the runner's PostgreSQL is uninstalled, leaving the empty `Installations` and +`Services` keys; a fake `postgresql-x64-14` key refuses without the password in the log; fourteen +password and parameter refusals (non-ASCII, `"`, a trailing `"` on either parameter, trailing +`\`, a quoted two-word value ending in `\`, leading `-` and space, an unquoted split, a split +before a `/` word Setup does not read on either parameter, the writer rules) and a quoted +`"/SUPERPASSWORD=two words"` and a `/NOCANCEL` after the password that pass them; the incomplete +and orphaned 17 registrations, a busy port 5432, a non-empty data directory and a missing writer +password refuse with no `postgresql*` service left; a clean machine installs EDB 17 through the +`SEMIBASE_*` variables while a job watches the option file appear and vanish before the site log +(`restarting service`, nothing pending, `localhost`, `C`/`C`/`UTF8`); a superuser-only parameter +re-run under `log_statement = 'all'` restarts nothing and keeps the writer password; a re-run +with a new writer password applies it; a wrong registered port refuses after the 60 s wait; a +wrong superuser password refuses with `password authentication failed`; none of the four +generated passwords appears in any EDB, setup, site or server log, and the server log holds the +`semiplot` probe; the uninstaller removes `semibase.exe` and keeps PostgreSQL, the logs and the +recovery copy. No case passes `/LOG`, which records Setup's command line. Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), table-driven. There are no database-touching tests in the repository; the integration checks are @@ -190,7 +193,8 @@ gofmt -w . # run before presenting changes; gofmt is authoritative # the setup, in an elevated PowerShell with SEMIBASE_SUPER_PASSWORD (and on a first install # SEMIBASE_WRITER_PASSWORD) set: exit 0 provisioned, 7 refused; logs in # C:\Program Files\SemiBase Setup\Logs -(Start-Process .\Installer\Output\SemiBase-Setup.exe '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru).ExitCode +(Start-Process .\Installer\Output\SemiBase-Setup.exe -Wait -PassThru ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART').ExitCode ``` `site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` server settings, @@ -245,9 +249,9 @@ may not and issues no password statement superuser password alone carries a run, and the `semiplot` password only decides whether the run also tests that role's TCP login. The setup takes no `semiplot` password: every run sets `SEMIBASE_PLOT_PASSWORD=semiplot`, so it resets the role to that convention each time -(`docs/architecture/installer.md#the-setup`). `public.trends` is created under `SET ROLE scada_writer` on the -superuser connection, so the table's owner is the role the SCADA writes with and no -`pg_hba.conf` line has to admit a `scada_writer` login. +(`docs/architecture/installer.md#the-setup`). `public.trends` is created under +`SET ROLE scada_writer` on the superuser connection, so the table's owner is the role the SCADA +writes with and no `pg_hba.conf` line has to admit a `scada_writer` login. ## Layout diff --git a/Installer/fetch-postgresql.sh b/Installer/fetch-postgresql.sh index f8b4717..df03b5d 100644 --- a/Installer/fetch-postgresql.sh +++ b/Installer/fetch-postgresql.sh @@ -7,7 +7,7 @@ readonly VERSION=17.11-5 readonly URL="https://get.enterprisedb.com/postgresql/postgresql-${VERSION}-windows-x64.exe" readonly SHA256=612c7f5400a003aaffa0372cb273eed52278f93c680ab911eb9cb2229e329072 -# A fixed name, so the pin lives here alone; Installer/SemiBase.iss names the same file +# A fixed name, so a pin bump leaves Installer/SemiBase.iss, which names the same file, unchanged readonly FILE_NAME=postgresql-17-windows-x64.exe PAYLOAD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/payload" readonly PAYLOAD_DIR diff --git a/docs/architecture/installer.md b/docs/architecture/installer.md index 9c5ebef..3aae781 100644 --- a/docs/architecture/installer.md +++ b/docs/architecture/installer.md @@ -136,11 +136,11 @@ path then resolves through. So a `C:\DISTR\Logs\SemiBase` DACL holds only with ` | Size | 413001200 bytes | | Signature | Authenticode, Valid, `CN=EnterpriseDB Corporation, O=EnterpriseDB Corporation, L=Wilmington, S=Delaware, C=US` | -`Installer/fetch-postgresql.sh` holds these constants alone and saves the file as -`Installer/payload/postgresql-17-windows-x64.exe`, the name `#define PgInstaller` in the `.iss` -uses, so a pin bump edits the script and nothing else. A file already there that does not match -the pin fails the script, so a stale payload never reaches `iscc` through a run that fetched -first. CI and release cache `Installer/payload/` on the script's hash: an edit to the script, +`Installer/fetch-postgresql.sh` holds the Version, URL and SHA256 the build uses and saves the +file as `Installer/payload/postgresql-17-windows-x64.exe`, the name `#define PgInstaller` in the +`.iss` uses, so a pin bump leaves the `.iss` unchanged. It edits the script's constants and +re-measures this table. A file already there that does not match the pin fails the script, so a +stale payload never reaches `iscc` through a run that fetched first. CI and release cache `Installer/payload/` on the script's hash: an edit to the script, a comment included, costs one download of 413 MB (about 25 s), and a cached run never asks the URL, so a retired URL surfaces on the first uncached run, not on the pull request that follows it. diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 2314d7d..fde25ae 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -309,7 +309,7 @@ A restart drops every open connection to the server, the SCADA's included. CI's Linux job runs `site` without the flag: its server is a GitHub Actions service container, not a Windows service, and nothing in the job restarts it. The `Require the listen_addresses delta` step then reads `listen_addresses` still pending and `localhost` in `postgresql.auto.conf` -through `pg_file_settings`, which proves `site` wrote the delta. The Windows job's +through `pg_file_settings`, which proves `site` wrote the delta. The `build` job's `Restart the runner's PostgreSQL service through site` step is the only direct test of the restart; the `installer` job's cases run it through the setup, which passes `--restart-service` on every run (`restarting service` in the site logs of the runner's 17 and of the fresh