Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
81ace74
feat(provision): listen on loopback only
mrcsin Oct 19, 2026
ae435d3
feat(cmd): accept --restart-service for site
mrcsin Oct 19, 2026
30373a7
feat(provision): restart the service on demand
mrcsin Oct 19, 2026
8c9342c
ci: restart the runner's PostgreSQL through site
mrcsin Oct 19, 2026
9577716
docs(plans): record the Windows restart run
mrcsin Oct 19, 2026
34d9fda
docs(plans): record the acceptance run
mrcsin Oct 19, 2026
9e80374
docs(site): describe loopback and --restart-service
mrcsin Oct 19, 2026
6910e56
fix(provision): bound and finish the service restart
mrcsin Oct 19, 2026
f19d7ff
ci: exercise refusals and a start-pending service
mrcsin Oct 19, 2026
c2af598
docs(site): state the restart guarantee and gaps
mrcsin Oct 19, 2026
0205789
ci: end the restart step on its own verdict
mrcsin Oct 19, 2026
b552995
docs(site): record the runner's -w service command
mrcsin Oct 19, 2026
44708f0
docs(ci): describe the refusal and start-pending runs
mrcsin Oct 19, 2026
0e468d3
fix(provision): start only after the old server exits
mrcsin Oct 19, 2026
ba6e8bb
docs(site): name the exit wait and start timeout
mrcsin Oct 19, 2026
3671802
docs(site): separate kernel and postmaster facts
mrcsin Oct 19, 2026
9c17a89
fix(provision): tie the service to the run's server
mrcsin Oct 19, 2026
19a1d92
docs(site): align restart docs and tests with code
mrcsin Oct 19, 2026
e6e7a4e
refactor(provision): cut comments that restate code
mrcsin Oct 19, 2026
3de4771
docs(plans): record the site restart run
mrcsin Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,83 @@ jobs:
with:
version: v2.12.2

# sc.exe start returns while the service is Start Pending; run site at once
# with no readiness wait, so openService's own wait is what runs. The
# sequence is in docs/architecture/provisioning.md#the-service-restart.
- name: Restart the runner's PostgreSQL service through site
env:
SEMIBASE_SUPER_PASSWORD: root
SEMIBASE_WRITER_PASSWORD: ci-writer
SEMIBASE_PLOT_PASSWORD: ci-plot
PGPASSWORD: root
run: |
$service = 'postgresql-x64-17'
$psql = "$env:PGBIN\psql.exe"
Get-CimInstance Win32_Service -Filter "Name='$service'" |
Select-Object Name, State, StartMode, PathName | Format-List
go build -o semibase.exe ./cmd/semibase
if ($LASTEXITCODE -ne 0) { exit 1 }

# stdout and stderr joined into one string: -like over an array filters
# elements instead of answering yes or no
function Invoke-Site([string] $name) {
$ErrorActionPreference = 'Continue'
$lines = & .\semibase.exe site --port 5432 --expected-major 17 --restart-service $name 2>&1 |
ForEach-Object { "$_" }
$lines | Write-Host
[pscustomobject]@{ Code = $LASTEXITCODE; Output = $lines -join "`n" }
}
function Assert-Refused($run, [string] $reason) {
if ($run.Code -ne 1 -or $run.Output -notlike "*$reason*" -or $run.Output -like '*ALTER SYSTEM SET*') {
Write-Host "::error::want exit 1 with '$reason' and no ALTER SYSTEM SET, got exit $($run.Code)"
exit 1
}
}

Assert-Refused (Invoke-Site $service) "service $service is Disabled"

Set-Service $service -StartupType Manual
sc.exe start $service
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host "state after sc.exe start: $((Get-Service $service).Status)"

$first = Invoke-Site $service
if ($first.Code -ne 0) { Write-Host "::error::the first site run exited $($first.Code)"; exit 1 }
if ($first.Output -notlike "*restarting service $service*") {
Write-Host "::error::the first site run did not restart $service"
exit 1
}
if ($first.Output -notlike "*service $service runs data directory C:\PostgreSQL\17\data, as the server on *:5432 does*") {
Write-Host "::error::the first site run did not tie $service to the server on port 5432"
exit 1
}
if ($first.Output -notlike "*service $service stopped, nothing answers on *:5432*") {
Write-Host "::error::the first site run did not see the old server's port refused before the Start"
exit 1
}

Write-Host "data_directory: $(& $psql -h 127.0.0.1 -U postgres -d postgres -tAc 'SHOW data_directory')"
$listen = & $psql -h 127.0.0.1 -U postgres -d postgres -tAc 'SHOW listen_addresses'
if ($LASTEXITCODE -ne 0) { exit 1 }
$pending = & $psql -h 127.0.0.1 -U postgres -d postgres -tAc `
'SELECT count(*) FROM pg_settings WHERE pending_restart'
if ($LASTEXITCODE -ne 0) { exit 1 }
if ("$listen".Trim() -ne 'localhost' -or "$pending".Trim() -ne '0') {
Write-Host "::error::listen_addresses '$listen' (want localhost), pending_restart count '$pending' (want 0)"
exit 1
}

$second = Invoke-Site $service
if ($second.Code -ne 0) { Write-Host "::error::the second site run exited $($second.Code)"; exit 1 }
if ($second.Output -like '*restarting service*') {
Write-Host "::error::the second site run restarted $service with nothing pending"
exit 1
}

Assert-Refused (Invoke-Site 'no-such-service') 'service no-such-service does not exist'
# the runner exits with the last native exit code, which the refusal left at 1
exit 0

linux:
runs-on: ubuntu-latest
env:
Expand Down Expand Up @@ -70,6 +147,25 @@ jobs:
./semibase site --database semiplot_dev --expected-major 17
./semibase site --database semiplot_dev --expected-major 17

# The service container is never restarted, so the loopback-only value
# stays pending.
- name: Require the listen_addresses delta
env:
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: ci-super
run: |
set -euo pipefail
pending=$(psql -d postgres -tAc \
"SELECT pending_restart FROM pg_settings WHERE name = 'listen_addresses'")
written=$(psql -d postgres -tAc \
"SELECT setting FROM pg_file_settings WHERE name = 'listen_addresses'
AND sourcefile LIKE '%postgresql.auto.conf'")
if [[ "$pending" != t || "$written" != localhost ]]; then
echo "::error::listen_addresses pending_restart '$pending' (want t), auto.conf value '$written' (want localhost)"
exit 1
fi

# Every other step runs semiplot_register_new_pens() over an empty
# public.trends, so its INSERT adds nothing.
- name: Register pens and probe the semiplot_tags constraints
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:
echo "$DELIMITER"
} >> "$GITHUB_OUTPUT"

# The module is pure pgx with no cgo, so one runner cross-compiles both
# The module is pure Go with no cgo, so one runner cross-compiles both
# targets. Native-platform coverage is CI's job, not the release's.
- name: Build
env:
Expand Down
58 changes: 43 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ CLI binary, `semibase.exe`, a container image carrying its Linux build, plus the
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 `pgx` and compiles for any GOOS; Linux builds serve the
containerised test bench of consumers (SemiPlot runs `bench` against an ephemeral `postgres:17`
container). All commands run from the repository root.
`cmd/semibase`. The module is pure Go (`pgx/v5`, plus `golang.org/x/sys` for the Windows service
restart) and compiles for any GOOS; Linux builds serve the containerised test bench of consumers
(SemiPlot runs `bench` against an ephemeral `postgres:17` container). All commands run from the
repository root.

## Build

Expand All @@ -24,7 +25,7 @@ Without ldflags, `version` falls back to the VCS revision recorded by the Go too

`.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
`pgx`, so native runners are CI's job, not the release's — and attaches
Go, so native runners are CI's job, not the release's — and attaches
`semibase_<version>_<goos>_amd64[.exe]` to the GitHub release — no extension on the Linux
artifact, an ELF is executable by its mode bit. `-X main.revision=<tag>` is embedded, so
`semibase version` prints the tag.
Expand Down Expand Up @@ -79,10 +80,21 @@ 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 Linux job also provisions a `postgres:17-alpine` service container by running `site`
twice — the tuning path and the idempotency check in one step.
It then runs `bench` against a `postgres:14-alpine` container — the version floor, and the only
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 <IP>: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`
(`pg_file_settings`): the container is never restarted, so the pending value and the file line
prove `site` wrote the delta.
The job then runs `bench` against a `postgres:14-alpine` container — the version floor, and the only
place `REVOKE CREATE ON SCHEMA public FROM PUBLIC` bites, because 15 and later withhold that grant
by engine default — provisions a third container with `bench` as a `postgres` image init script
over the unix socket, and builds the container image, so a broken `Dockerfile` fails on the pull
Expand All @@ -99,12 +111,13 @@ password. The 14 step starts its server with all five statement-logging paramete
`Keep the CREATE ROLE password statements out of the PostgreSQL 14 log` step reads that log. Both
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 pens and the 17 log steps need `psql` on the runner; to run
them locally, point `psql` at a container.
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`.

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 six CI database steps above,
the `semiplot` access check both commands run at the tail and the eight CI database steps above,
against a live server.

## Format
Expand All @@ -118,11 +131,12 @@ gofmt -w . # run before presenting changes; gofmt is authoritative
```powershell
.\semibase.exe --help # commands: site | bench | version
.\semibase.exe site # an installation machine: tuning, then everything else
.\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
```

`site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` memory constants,
`site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` server settings,
`bench` does not. Both create the database, the roles, the grants, the five SemiPlot tables
(`semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_markers`, `semiplot_meta`), the
`semiplot_register_new_pens()` function and `public.trends`, and both end by actually reading
Expand All @@ -135,6 +149,18 @@ in a rolled-back transaction, requiring 42501 for `INSERT`, `DELETE` and `UPDATE
on the archive or on `semiplot_meta` and no `CREATE` on schema `public` (revoked from `PUBLIC` by
`create`, since 14 still grants it). A failed check is a non-zero exit.

Two of the settings, `shared_buffers` and `listen_addresses = 'localhost'`, wait for a service
restart. Without a flag the run warns and the operator restarts. `site --restart-service <name>`
restarts that Windows service inside the tuning phase when settings wait, so `create` and the
tail check run against the restarted server. Only `site` registers the flag, and it refuses a
`--host` other than `localhost`, `127.0.0.1` or `::1`. The service is checked before anything
changes, including that the `-D` in its `PathName` is the `data_directory` of the server the run
connects to. The Start waits until the IP:port that connection reached refuses connections, since
`pg_ctl` reports Stopped before a slow postmaster exits, and once the service accepts the Stop the
restart runs to completion despite an interrupt
(`docs/architecture/provisioning.md#the-service-restart`, which also lists the cases that still
leave it stopped).

The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the nine settings
columns, never `id`, so `has_table_privilege(..., 'UPDATE')` answers false for that table and no
check may ask it.
Expand Down Expand Up @@ -168,9 +194,11 @@ superuser connection, so the table's owner is the role the SCADA writes with and

```
cmd/semibase/ CLI entry point: command dispatch, flags, usage text
internal/provision/ one file per phase behind the two commands: config.go (ALTER SYSTEM),
create.go (db/roles/grants/trends), schema.go (the semiplot schema files,
their grants, the pen-registration function's grant and its owner's
internal/provision/ one file per phase behind the two commands: config.go (ALTER SYSTEM and
the optional service restart, whose service control lives in
service_windows.go and service_other.go), create.go
(db/roles/grants/trends), schema.go (the semiplot schema files, their
grants, the pen-registration function's grant and its owner's
registrarStatements), check.go (semiplot access). provision.go
holds Options, Site/Bench and the connection plumbing they share
sql/ embedded DDL: the five semiplot_* files (ours: the tables, the
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The image carries the linux/amd64 binary and nothing else. Its only consumer
# copies the binary out (COPY --from=ghcr.io/semiteq/semibase:latest /semibase),
# so a base with a shell, CA certificates or a libc would ship weight nobody
# runs: the module is pure pgx, builds CGO_ENABLED=0 and speaks no TLS on the
# runs: the module is pure Go, builds CGO_ENABLED=0 and speaks no TLS on the
# bench. The ENTRYPOINT costs one line and makes `docker run <image> version`
# a valid smoke check.
#
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус
# установка pgsql
winget install --id PostgreSQL.PostgreSQL.17 --exact

# дальше всё в PowerShell от имени администратора: утилита перезапускает службу PostgreSQL
# пароли ролей: каждая строка запрашивает пароль, в историю PowerShell он не попадает
$env:SEMIBASE_SUPER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль postgres' -AsSecureString)).Password
$env:SEMIBASE_WRITER_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль scada_writer' -AsSecureString)).Password
$env:SEMIBASE_PLOT_PASSWORD = [Net.NetworkCredential]::new('', (Read-Host 'Пароль semiplot' -AsSecureString)).Password

# конфигурация сервера, архивная база, роли `scada_writer`, `semiplot` и
# `semiplot_registrar`, права, таблица архива, пять таблиц SemiPlot и функция
# регистрации новых перьев
.\semibase.exe site
# регистрации новых перьев; перезапуск службы, если настройки его ждут
.\semibase.exe site --restart-service postgresql-x64-17
```
22 changes: 17 additions & 5 deletions cmd/semibase/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Usage:
semibase <command> [flags]

Commands:
site an installation machine: apply the memory tuning (ALTER SYSTEM + reload),
site an installation machine: apply the server settings (ALTER SYSTEM + reload),
then create the archive database, the roles, the access chain, public.trends,
the five semiplot_* tables and semiplot_register_new_pens()
bench a throwaway container: the same, without the tuning
Expand All @@ -33,6 +33,11 @@ refused an insert, a delete or a key change on semiplot_tags; it writes neither
archive nor semiplot_meta and holds no CREATE on schema public. A failed check is a
non-zero exit.

The tuning includes settings that take effect only after a server restart. site
reports them and leaves the restart to the operator, unless --restart-service names
the Windows service to restart; the restart needs a loopback --host, because the
server then listens on the loopback interface only.

Every step checks before it creates; re-running either command is safe. Passwords of
existing roles change only when the corresponding flag or variable is set.

Expand Down Expand Up @@ -108,6 +113,10 @@ func run(ctx context.Context, arguments []string) int {
defer cancel()
if err := execute(commandContext, options); err != nil {
fmt.Fprintln(errorOutput, "error:", err)
if errors.Is(commandContext.Err(), context.DeadlineExceeded) {
fmt.Fprintf(errorOutput, "%s ran out of its %s limit; every step checks before it creates, "+
"so re-running it continues from where it stopped\n", command, phaseTimeout)
}
return 1
}
fmt.Fprintf(output, "\nDone: %s completed against %s.\n",
Expand Down Expand Up @@ -146,10 +155,8 @@ func revisionFromSettings(settings []debug.BuildSetting) string {
return vcsRevision
}

// One flag set serves both commands: site and bench differ only in the tuning phase,
// which reads no flag of its own, so every flag below is used by either command.
// Password flags default to empty so the usage output can only ever show the variable
// names, never their values.
// Password flags default to empty so the usage output can only ever show the variable names,
// never their values.
func newFlagSet(command string, options *provision.Options) *flag.FlagSet {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
flags.SetOutput(errorOutput)
Expand All @@ -166,6 +173,11 @@ func newFlagSet(command string, options *provision.Options) *flag.FlagSet {
"semiplot password (env SEMIBASE_PLOT_PASSWORD)")
flags.IntVar(&options.ExpectedMajor, "expected-major", 0,
"required server major version; 0 accepts any major >= 14")
if command == "site" {
flags.StringVar(&options.RestartService, "restart-service", "",
"Windows service to restart when the server settings wait for a restart; "+
"needs a loopback --host")
}
return flags
}

Expand Down
Loading
Loading