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..2d68cfd --- /dev/null +++ b/.github/scripts/installer-case.ps1 @@ -0,0 +1,86 @@ +# 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 = "$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" + 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-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) { + Write-Host "--- $($log.Name)" + Write-Redacted "$(Get-Content -Raw $log.FullName)" $tokens + } +} + +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" } +} + +# 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 22f3f9c..0a85f5c 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 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 # installation machine. Run twice to prove idempotency - the second run @@ -316,3 +319,361 @@ 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. 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: + - 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: Build the setup + uses: ./.github/actions/build-setup + with: + app-version: 0.0.0-ci + semibase-exe: ${{ github.workspace }}\semibase.exe + + - name: Refuse a run without a superuser password + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + 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='$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 + # 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 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) + $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='$PgService'").StartMode + if ($startMode -ne 'Auto') { Fail "$PgService 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). 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\$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)" } + $deadline = (Get-Date).AddMinutes(5) + while (Get-CimInstance Win32_Process | Where-Object { $_.Name -like '_uninstall*.000' }) { + 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*') { 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) + 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) { 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 = New-Token + $env:SEMIBASE_SUPER_PASSWORD = $token + $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. 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; + # 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" + $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=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') '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' + 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 + + - name: Refuse an incomplete PostgreSQL 17 registration + run: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $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' + 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\$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 + 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: | + . "$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 + } + } + $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 $PgService")) { + Fail "the site log does not record restarting service $PgService" + } + + $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' + if ($pending -ne '0' -or $listen -ne 'localhost' -or $databases -ne $wantDatabases) { + 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: | + . "$env:GITHUB_WORKSPACE\.github\scripts\installer-case.ps1" + $tokens = @(Get-Content $TokenFile) + $super, $writer = $tokens[1], $tokens[2] + 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)" } + 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\$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 $PgService is started, but localhost:15433 accepted no TCP connection within 60 s" + + - name: Refuse a wrong superuser password + run: | + . "$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: 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" + $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*' + } + $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) { + 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 $tokens) + if ($hits.Count -ne 0) { + $hits | ForEach-Object { Write-Host "password found in $($_.Path):$($_.LineNumber)" } + Fail "$($hits.Count) password hits" + } + + # 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) { Fail "want exit 0, got $($uninstall.ExitCode)" } + $deadline = (Get-Date).AddSeconds(60) + while (Test-Path "$app\semibase.exe") { + 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 $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 + + # 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' + . "$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") $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") $tokens } + $logs = @(Get-ChildItem "$LogDir\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) + Write-Logs ([pscustomobject]@{ Logs = $logs }) $tokens + exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 605538e..95eb20b 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,29 +149,117 @@ 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: 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: + TAG: ${{ needs.build.outputs.tag }} + run: | + . "$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 + 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 + + - 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 - # 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. + # 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 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 @@ -184,31 +279,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 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/CLAUDE.md b/CLAUDE.md index e89ecf8..1151545 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,45 @@ 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.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 +& "$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. The fetch script holds +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 -`.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 `-`), then smoke-runs it once without a password: it must refuse with exit +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. -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, @@ -79,16 +106,18 @@ 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. -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 -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. +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 `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` @@ -112,13 +141,40 @@ 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 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 -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 @@ -134,6 +190,11 @@ 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:\Program Files\SemiBase Setup\Logs +(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, @@ -186,9 +247,11 @@ 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 -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. +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. ## Layout @@ -204,6 +267,11 @@ 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/) +.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/Installer/SemiBase.iss b/Installer/SemiBase.iss new file mode 100644 index 0000000..a3af983 --- /dev/null +++ b/Installer/SemiBase.iss @@ -0,0 +1,651 @@ +; SemiBase Inno Setup installer script +; Build with: iscc.exe /DAppVersion=1.2.3 /DFileVersion=1.2.3 /DSemibaseExe= Installer\SemiBase.iss +; 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 +#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}" + +; 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" + +; 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 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\" + PgMajor + "\data" + +[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 and the PostgreSQL service both need admin rights +PrivilegesRequired=admin + +[Languages] +Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[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 SetEnvironmentVariable(lpName, lpValue: String): BOOL; + external 'SetEnvironmentVariableW@kernel32.dll stdcall'; +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; + 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. 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: String; +begin + Result := ''; + Value := CommandLineParameter(Name); + 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 + '.'; +end; + +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 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 + 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 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 ' + + 'parameter, or pass the password through its SEMIBASE_* variable.'; + Exit; + end; +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; + +// 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(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 (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; + +// 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; + +// 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; + SaveStringsToUTF8FileWithoutBOM(LogPath('setup'), Lines, True); +end; + +function KeepToolRefusal: String; +begin + Result := ''; + ExtractTemporaryFile('semibase.exe'); + 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 +function ScExitCode(const Arguments: String): Integer; +var + ExitCode: Integer; +begin + 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. 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 "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; + +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 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 {#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; + 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, Length('{#PgMajor}.')) <> '{#PgMajor}.' 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 {#PgMajor} 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 (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 + 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 {#PgInstallation} 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 {#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 FreshMachineRefusal: String; +var + Accepts: Boolean; +begin + Result := ''; + if not ProbePort({#PgPort}, Accepts) then + Result := 'Setup could not probe localhost:{#PgPort} through powershell.exe.' + RecoveryHint + else if Accepts then + 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 ' + + 'overwrites existing data: remove the directory, for example after an interrupted ' + + '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; +begin + Result := not RegKeyExists(HKLM64, InstallationsKey + '\{#PgInstallation}'); +end; + +// 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 + if not Fresh then + Result := RegisteredEngineRefusal(Engine) + else + begin + Result := OtherMajorRefusal; + if Result = '' then + Result := FreshInstallRefusal(SuperPassword, WriterPassword, True); + 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 localhost:' + IntToStr(Port) + ' through powershell.exe.' + + RecoveryHint; + Exit; + end; + if Accepts then + Exit; + Sleep(1000); + until GetTickCount - Started >= PortWaitMilliseconds; + Result := 'Service ' + ServiceName + ' is started, but localhost:' + IntToStr(Port) + + ' 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 +// 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; +begin + Result := ''; + 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.' + + RecoveryHint; + Exit; + end; + SiteLog := LogPath('site'); + if not Exec(ExpandConstant('{cmd}'), '/s /c ""' + ExpandConstant('{tmp}\semibase.exe') + + '" site --port ' + IntToStr(Engine.Port) + ' --superuser "' + Engine.SuperUser + + '" --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 + else if ExitCode <> 0 then + Result := 'semibase site exited with code ' + IntToStr(ExitCode) + '; its log is ' + SiteLog + + '.' + RecoveryHint; +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 {#PgMajor} with these passwords.', + 'The superuser password is required on every run. The scada_writer password is required ' + + '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'); + 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 EngineIsFresh and (OtherMajorRefusal = '') then + Refusal := FreshInstallRefusal(PasswordPage.Values[0], PasswordPage.Values[1], False); + 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. 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]; + 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 := 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 + LogRefusal(Result); +end; diff --git a/Installer/fetch-postgresql.sh b/Installer/fetch-postgresql.sh new file mode 100644 index 0000000..df03b5d --- /dev/null +++ b/Installer/fetch-postgresql.sh @@ -0,0 +1,54 @@ +#!/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 + +# 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 +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/README.md b/README.md index 2b6a7d7..334a64c 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,28 @@ 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 и утилиту +`semibase.exe` и ничего не скачивает. Отдельного файла `semibase_<версия>_windows_amd64.exe` в +выпусках больше нет. ## Быстрое развёртывание ```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 +# пароль scada_writer нужен при первой установке; при повторных запусках оставьте его пустым: +# непустой пароль заменяет пароль существующей роли, и SCADA дальше входит только с ним $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:\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/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 new file mode 100644 index 0000000..3aae781 --- /dev/null +++ b/docs/architecture/installer.md @@ -0,0 +1,384 @@ +# Installer + +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. 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:\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:\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 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; +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: + +| 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, 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, 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 | + +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_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 and for the copy in +`{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 + +| 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` | + +`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. + +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` 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 + +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. + +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, +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. + +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. +- 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. 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 + `IsKnownArgument`, or the setup refuses it. + +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. + +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. + +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 d5f47c0..e6fbaa2 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,31 @@ 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 - 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. - -A bench replaces step 2 with `semibase bench` and has no step 3 or 4: the consumer's seeder plays -the writer's part. +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 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. + +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..fde25ae 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 @@ -300,9 +309,11 @@ 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 -`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 +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 +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 ec25351..0eae43e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,33 +1,357 @@ # Развёртывание -SemiBase разворачивается одной утилитой — `semibase.exe`. Это скрипт установки: одна команда -приводит экземпляр в заданное состояние и завершается. Утилита идемпотентна: каждый шаг сначала -проверяет состояние, затем создаёт недостающее. Повторный запуск безопасен. Пароли существующих -ролей меняются только при явной передаче соответствующего флага или переменной окружения. +SemiBase разворачивается установщиком `SemiBase-Setup.exe`. Один запуск приводит машину в рабочее +состояние: ставит PostgreSQL 17, если его нет, настраивает сервер, создаёт архивную базу, роли, +права и таблицы, перезапускает службу PostgreSQL, когда настройки этого ждут, и проверяет доступ +роли `semiplot`. Повторный запуск безопасен: каждый шаг сначала проверяет состояние, затем создаёт +недостающее. + +Работу с базой установщик поручает утилите `semibase.exe` и кладёт её копию в +`C:\Program Files\SemiBase` (или в папку, выбранную при установке). Каждый запуск установщика, +в том числе отказавший, кладёт ещё одну копию в `C:\Program Files\SemiBase Setup`. Вручную +утилиту запускают для [восстановления](#восстановление-после-отказа-установщика), когда +установщик отказывается работать, и на тестовых стендах. -Справка: +## Требования + +| Компонент | Требование | +| --------- | ---------------------------------------------------------------------------------------------- | +| СУБД | PostgreSQL 17. Установщик работает только с 17; утилита на тестовых стендах принимает 14 и новее | +| Писатель | Simple-Scada 2 с архивацией в PostgreSQL | + +На странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest) у каждого выпуска два +файла: + +| Файл | Назначение | +| ------------------------------- | -------------------------------------------------------------------------------------------- | +| `SemiBase-Setup.exe` | Установщик для Windows x86-64. Несёт утилиту и установщик PostgreSQL 17 от EDB, ничего не скачивает | +| `semibase_<версия>_linux_amd64` | Утилита для Linux, для тестовых стендов смежных проектов | + +Отдельного файла `semibase_<версия>_windows_amd64.exe` в выпусках больше нет: утилиту для +Windows несёт установщик. + +Каждый выпуск также публикует образ контейнера `ghcr.io/semiteq/semibase` с той же Linux-сборкой. +Он нужен только тестовым стендам смежных проектов. При пусконаладке ни образ, ни Linux-сборка не +используются. + +Сборка утилиты из исходников: `go build -o semibase.exe ./cmd/semibase`. + +## Установщик + +### Запуск без окон + +Это основной способ. Установщик запускают в PowerShell от имени администратора: он ставит +PostgreSQL и управляет его службой. Пароли передают переменными окружения: ```powershell -.\semibase.exe --help # список команд -.\semibase.exe site --help # флаги конкретной команды +$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`. Нужен, когда роли ещё нет, то есть при первой установке. Если роль есть, пустое значение оставляет ей прежний пароль, а непустое заменяет его, и SCADA дальше входит только с новым. При повторных запусках значение оставляют пустым | +| `/VERYSILENT` | | Запуск без окон | +| `/SUPPRESSMSGBOXES` | | Без диалоговых окон | +| `/NORESTART` | | Без перезагрузки машины по завершении | + +Переменная окружения используется, только когда параметра нет. + +Роли `semiplot` установщик на каждом запуске задаёт пароль `semiplot`. На этот пароль рассчитан +файл подключения SemiPlot, поэтому параметра для него нет. + +Пароль `scada_writer` состоит только из печатных символов ASCII: латиница, цифры, пробел и знаки +препинания. SCADA входит с ним из настроек своего проекта, и как она кодирует другие символы, никто +не проверял. + +Пароль суперпользователя ограничен, только когда установщик ставит PostgreSQL 17, то есть когда на +машине не зарегистрирован ни один PostgreSQL. Тогда он состоит из печатных символов ASCII, не +содержит `"`, не заканчивается на `\`, не начинается с `-` и не начинается и не заканчивается +пробелом. Причина в установщике PostgreSQL: он записывает пароль в кодировке ANSI, а утилита +отправляет его в UTF-8; кавычка или конечная обратная косая черта ломают его командную строку, а +пароли с `-` или пробелом по краям на нём не проверялись. С уже установленным PostgreSQL 17 +пароль суперпользователя может быть любым: установщик передаёт его утилите без изменений. + +Все `"` из своей командной строки установщик выбрасывает, поэтому пароль с кавычкой передают +только переменной. Пароль с пробелом тоже проще передать переменной. Параметром он доходит, +только если кавычки стоят вокруг всего параметра в самой командной строке. В `Start-Process` +внешние кавычки снимает PowerShell, поэтому их пишут внутри строки: +`-ArgumentList '"/SUPERPASSWORD=two words"'`. Установщик отвергает параметр, значение которого +дошло не таким, каким его набрали. Отвергает он и любое слово командной строки, кроме параметров +паролей и ключей самого Inno Setup, таких как `/VERYSILENT`, `/LOG` и `/DIR=`, ведь пароль с +пробелом без кавычек распадается на два слова. Если же после пробела стоит ключ Inno Setup, как в +пароле `x /NOCANCEL`, установщик примет только `x`, поэтому пароль с пробелом без кавычек не +передают. + +Пароль, переданный параметром, виден в списке процессов, пока установщик работает, и попадает в +историю команд PowerShell, если его набрали в строке. С параметром `/LOG` установщик пишет в свой +журнал всю командную строку вместе с паролями, поэтому `/LOG` не используют. Переменная окружения +в командную строку не попадает. При новой установке PostgreSQL пароль суперпользователя на время +создания кластера появляется в командной строке процесса установщика EDB: так устроен этот +установщик. + +### Код возврата и журналы + +| Код | Значение | +| ------ | ---------------------------------------------------------------------------------------------------------------- | +| `0` | Машина в рабочем состоянии, все проверки пройдены | +| `7` | Установщик отказался работать. Причина записана последней строкой в самом новом журнале `setup-*.log` | +| другой | Сбой самой программы установки. Машина в рабочее состояние не приведена | + +Журналы лежат в `C:\Program Files\SemiBase Setup\Logs`, паролей в них нет: + +- `setup-<ггггммдд-ччммсс>.log` — причина отказа. Если отказала утилита, строка называет её журнал. +- `site-<ггггммдд-ччммсс>.log` — полный вывод утилиты за каждый запуск, успешный или нет. + +Папка `C:\Program Files\SemiBase Setup` не зависит от выбранной папки установки. Создать или +изменить что-либо в `C:\Program Files` может только администратор, поэтому обычный пользователь +не подменит ни журналы, ни копию утилиты. + +### Запуск с окнами + +Без `/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 с ждёт, пока порт на `localhost` начнёт +принимать подключения, и запускает утилиту: + +```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. + +### Удаление + +`unins000.exe` в папке установки, по умолчанию `C:\Program Files\SemiBase` (или «Установка и +удаление программ»), удаляет только `semibase.exe` из этой папки. PostgreSQL, его данные, роли, +журналы и копия утилиты в `C:\Program Files\SemiBase Setup` остаются. + +## Порядок ввода в эксплуатацию + +Порядок важен: утилита выставляет права на чтение до того, как создаёт `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 перья не добавляет: переменные, появившиеся + позже, добавляет следующее нажатие кнопки. Дальше перья меняют в том же редакторе. Вручную их + не добавляют, а удалить перо может только суперпользователь. + +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`, значит таблица была создана раньше, +чем выставлены права по умолчанию; способ починки назван в тексте ошибки. Если роль не может писать +свою таблицу или, наоборот, держит лишнее право на архив, в тексте ошибки названы таблица, операция +и команда `GRANT` или `REVOKE`, которая это чинит. Если роль может добавить, удалить или +перенумеровать перо, в тексте ошибки названа команда `REVOKE`, которая забирает лишнее право у +`semiplot`, у `PUBLIC` или у роли, в которую входит `semiplot`. + +## Восстановление после отказа установщика + +Отказ установщика — это код возврата `7`. Восстановление идёт так: + +1. Прочитать последнюю строку самого нового журнала + `C:\Program Files\SemiBase Setup\Logs\setup-*.log`. +2. Найти её начало в таблице ниже и сделать то, что там сказано. +3. Запустить установщик снова. + +Отказы из-за паролей и из-за состояния PostgreSQL установщик выдаёт до любых изменений в +PostgreSQL; до них он только кладёт копию утилиты в `C:\Program Files\SemiBase Setup`. Отказ +установщика PostgreSQL или утилиты может прийти, когда PostgreSQL уже установлен или служба уже +запущена. +Неверный пароль суперпользователя уже стоящего PostgreSQL 17 обнаруживает только утилита, то есть +после того, как служба переведена в автоматический запуск и запущена. + +| Строка в журнале | Что делать | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `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 ... 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` в плановый простой, либо удалить старую версию, если её данные не нужны. Готовить старую версию утилитой вручную бесполезно: каждый следующий запуск установщика всё равно откажет | +| `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` значит неверный пароль суперпользователя. `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:\Program Files\SemiBase Setup`, не запустился `powershell.exe`, `cmd.exe` или установщик PostgreSQL (его может заблокировать антивирус), не задались переменные с паролями или не записался временный файл. Запустить установщик снова; если отказ повторяется, приложить журнал к обращению в поддержку | + +### Удаление 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 Setup\semibase.exe`. Поэтому она есть и на машине, где установщик ни +разу не завершился успешно, и не нужно ни скачивать её, ни собирать. Удаление SemiBase эту копию +не трогает. Заменить её может только администратор, поэтому запускать её от имени администратора +с паролями в окружении безопасно. + +Утилита готовит только PostgreSQL 17 и откажет серверу другой версии. Ей нужны порт сервера, +суперпользователь и, если сервер работает как служба Windows, имя службы: + +| Значение | 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 +Get-CimInstance Win32_Service | Where-Object PathName -like '*pg_ctl*' | Format-List Name, State, StartMode, PathName +``` + +Утилита не работает с отключённой или остановленной службой, поэтому службу переводят в +автоматический запуск и запускают. Дальше в окне 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 Setup\semibase.exe' site --port $port --superuser $superuser --expected-major 17 --restart-service $service +$LASTEXITCODE +``` -| Компонент | Требование | -| --------- | --------------------------------------------- | -| СУБД | PostgreSQL 17; минимально поддерживаемая — 14 | -| Писатель | Simple-Scada 2 с архивацией в PostgreSQL | +Пароль `scada_writer` нужен, только если роли ещё нет; иначе строку пропускают. Пароль `semiplot` +задан тем же, что задаёт установщик. Перед любыми изменениями утилита сверяет каталог после `-D` в +пути к исполняемому файлу службы с каталогом данных сервера на порту. Если они не совпадают, +`--restart-service` называет не ту службу, и утилита завершается с ошибкой, ничего не изменив. -Утилита — один исполняемый файл. Готовые сборки для Windows и Linux (x86-64) находятся на странице [Releases](https://github.com/Semiteq/SemiBase/releases/latest). +Если сервер работает не как служба Windows, например запущен вручную через `pg_ctl start`, строку +`Set-Service` и флаг `--restart-service $service` убирают. Тогда утилита сообщает, какие настройки +ждут перезапуска, и сервер перезапускают вручную. Контейнер так не готовят: после перезапуска +сервер слушает только локальный интерфейс, и контейнер закрывается для подключений извне. -Каждый выпуск дополнительно публикует образ контейнера `ghcr.io/semiteq/semibase`. Он нужен -только тестовым стендам смежных проектов, которые встраивают эту утилиту в свои сборки. При -пусконаладке образ не используется — берите `semibase.exe` со страницы Releases. +Код `0` значит то же, что код `0` установщика. -Сборка из исходников: `go build -o semibase.exe ./cmd/semibase`. +## Утилита semibase.exe -## Команды +Утилита — скрипт установки: одна команда приводит экземпляр в заданное состояние и завершается. +Пароли существующих ролей она меняет только при явной передаче флага или переменной окружения. + +```powershell +.\semibase.exe --help # список команд +.\semibase.exe site --help # флаги конкретной команды +``` | Команда | Действие | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -37,18 +361,19 @@ SemiBase разворачивается одной утилитой — `semibas Обе команды заканчиваются проверкой: `semiplot` читает `public.trends` и версию схемы, вызывает функцию регистрации новых перьев, пишет свои собственные таблицы и не может ни писать в архив, ни -менять версию схемы, ни создавать объекты в схеме `public`. Ненулевой код возврата означает, что заявленное состояние не достигнуто. +менять версию схемы, ни создавать объекты в схеме `public`. Ненулевой код возврата означает, что +заявленное состояние не достигнуто. -При обращении за поддержкой приложите вывод `semibase version` — он однозначно называет -сборку утилиты на машине. +При обращении за поддержкой приложите вывод `semibase version`: он однозначно называет сборку +утилиты на машине. -## Пароли +### Пароли Пароли утилита берёт из флагов или переменных окружения, флаг важнее переменной. Файлов с паролями утилита не читает и сама их не записывает. Флаг с паролем, набранный в командной строке, PowerShell сохраняет в истории команд на диске. Поэтому пароли задают переменными окружения, а значение вводят по запросу `Read-Host`, как в -[порядке ввода в эксплуатацию](#порядок-ввода-в-эксплуатацию). +[ручном запуске утилиты](#ручной-запуск-утилиты). Команды, которые задают пароль роли, в журнал PostgreSQL не попадают: ни при `log_statement = 'all'`, ни при журналировании медленных или ошибочных запросов. На соединениях @@ -61,6 +386,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 +436,6 @@ DDL. Права `CREATE` в схеме `public` у роли `semiplot` нет входит отдельно под `scada_writer`: владелец и права на таблицу выходят те же, а `pg_hba.conf` править не придётся. -Пароли ролей нужны только на первом запуске — том, который эти роли создаёт. Когда роли уже -есть, достаточно пароля суперпользователя; пароль `semiplot` тогда лишь включает -дополнительную проверку входа под этой ролью (только по TCP). - ## Таблицы SemiPlot Утилита создаёт пять таблиц и задаёт их схему. SemiPlot меняет настройки перьев в @@ -144,115 +482,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 new file mode 100644 index 0000000..315edee --- /dev/null +++ b/docs/plans/20261019-windows-installer.md @@ -0,0 +1,460 @@ +# 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 +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:\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:\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 + `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; a + `/SUPERPASSWORD` and a `/WRITERPASSWORD` ending in `"`; one ending in `\`; one starting with + `-`; 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. +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:\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 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. + +## 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. +- 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:\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:\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 + `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 + 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`). +- 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`, or is followed there by anything but the end, a space or a closing quote, is + 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. + +## 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`; 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 | +| 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: refuse before installing anything. Otherwise install EDB 17 unattended, then continue as in the first row | + +- 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 `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; 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) + +| 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:\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';`. + +### 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/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 + 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` + (removed in review: `ForceDirectories` creates the folder before `[Dirs]` runs, and the + 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 + `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` + +- [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}` +- [x] map them to the engine-state table; each refusal returns its message from + `PrepareToInstall`, including the silent writer-password guard +- [x] run once on the development machine (14 registered): Setup refuses and names + `postgresql-x64-14` and port 15432 +- [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 +- [x] push and read the CI run - green before task 5 (run 39284793107) + +### Task 5: Provisioning run + +**Files:** +- Modify: `Installer/SemiBase.iss`, `.github/workflows/ci.yml` + +- [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 +- [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 + +**Files:** +- Modify: `Installer/SemiBase.iss` + +- [x] a `CreateInputQueryPage` with the superuser and writer password fields, prefilled from the + parameters or variables; not shown under `/SILENT` or `/VERYSILENT` +- [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] 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 + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [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 +- [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 + +**Files:** +- Modify: `.github/workflows/release.yml` + +- [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 +- [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] 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`) + +### Task 9: Verify acceptance criteria +- [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 +- [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 +- [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 +- [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` +- [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 +- [x] `docs/architecture/overview.md`: Components gets `Installer/`; Provisioning order step 1 + (`overview.md:56`) becomes the setup +- [x] `CLAUDE.md`: the deliverable list, the Layout, and the Release section, including the + 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] 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 + `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:418` names the `winget` install. Its other hit, + `docs/plans/completed/20260810-archive-populator.md:1270`, is a completed plan and stays. +- 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 + 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. 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) — требования, установщик и его параметры, порядок ввода в + эксплуатацию, восстановление после отказа установщика, команды утилиты.