Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,58 @@ jobs:
go build -o semibase ./cmd/semibase
./semibase all --database semiplot_dev --expected-major 17
./semibase all --database semiplot_dev --expected-major 17

# The socket path, which nothing else covers: the same binary, run the way
# the official image runs an init script. During init the server has
# listen_addresses empty and only /var/run/postgresql answers, superuser
# auth is local trust, and the script's own "set -e" makes the entrypoint
# abort on failure. So the container reaching a state where semiplot_reader
# selects over TCP proves create completed over the socket.
- name: Provision over a unix socket, as a postgres image init script
env:
SEMIBASE_WRITER_PASSWORD: ci-writer
SEMIBASE_READER_PASSWORD: ci-reader
# postgres:17-alpine is musl. The runner has a C toolchain, so an
# unset CGO_ENABLED defaults to 1 and links against glibc; the image
# then reports the binary as "not found", which reads as a missing
# file rather than a missing interpreter. release.yml builds the
# shipped Linux artifact the same way.
CGO_ENABLED: "0"
run: |
set -euo pipefail
go build -o semibase-linux ./cmd/semibase
if ! file semibase-linux | grep -q 'statically linked'; then
echo "::error::semibase-linux is not statically linked, so it cannot run on Alpine"
file semibase-linux
exit 1
fi
cat > initdb-semibase.sh <<'SCRIPT'
#!/bin/sh
set -e
/semibase create --host /var/run/postgresql --database semiplot_dev --expected-major 17
SCRIPT
# the entrypoint sources a non-executable init script instead of running it,
# and a binary without the bit set cannot run at all
chmod +x semibase-linux initdb-semibase.sh
cat > Dockerfile.initdb <<'DOCKERFILE'
FROM postgres:17-alpine
COPY semibase-linux /semibase
COPY initdb-semibase.sh /docker-entrypoint-initdb.d/10-semibase.sh
DOCKERFILE
docker build -f Dockerfile.initdb -t semibase-initdb .
# nothing is published: the host side of a published port is bound by
# docker-proxy before postgres listens, so it answers a probe too early
docker run -d --name semibase-initdb -e POSTGRES_PASSWORD=ci-super \
-e SEMIBASE_WRITER_PASSWORD -e SEMIBASE_READER_PASSWORD semibase-initdb
# TCP inside the container opens only once the init scripts have run
for _ in $(seq 60); do
if docker exec semibase-initdb pg_isready -h 127.0.0.1 -q 2>/dev/null; then break; fi
if [ "$(docker inspect -f '{{.State.Running}}' semibase-initdb)" != true ]; then break; fi
sleep 2
done
if ! docker exec -e PGPASSWORD=ci-reader semibase-initdb psql -h 127.0.0.1 \
-U semiplot_reader -d semiplot_dev -tAc 'select count(*) from semiplot_tags'; then
echo "::error::provisioning over the unix socket did not complete"
docker logs semibase-initdb
exit 1
fi
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ linters:
- path: _test\.go
linters:
- gosec
- linters:
- revive
text: "^(exported|package-comments):"
4 changes: 2 additions & 2 deletions cmd/semibase/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ func TestLoadDotEnvAbsentFileIsSilent(t *testing.T) {

func TestLoadDotEnvDirectoryReportsReadError(t *testing.T) {
t.Chdir(t.TempDir())
// On Windows os.Open succeeds on a directory; the read fails, exercising
// the scanner.Err() warning path.
// on Windows os.Open succeeds on a directory; the read fails, exercising
// the scanner.Err() warning path
if err := os.Mkdir(dotEnvName, 0o755); err != nil {
t.Fatal(err)
}
Expand Down
45 changes: 13 additions & 32 deletions cmd/semibase/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Command semibase provisions the SemiBase PostgreSQL instance.
package main

import (
Expand Down Expand Up @@ -43,25 +42,18 @@ directory (flag wins over environment, environment wins over .env):
Run 'semibase <command> --help' for the command's flags.
`

// revision identifies the build; a release sets it with
// -ldflags "-X main.revision=...". Empty means "not embedded".
// set by a release with -ldflags "-X main.revision=..."
var revision string

// phaseTimeout bounds every command. Provisioning is DDL on empty objects, so
// a generous fixed bound is enough.
const phaseTimeout = 5 * time.Minute

// output receives the success reporting (help text, version, the final Done
// line) and errorOutput the usage and flag-parse reporting. Variables so tests
// capture the output instead of writing to the test log.
// variables so tests can capture the streams
var (
output io.Writer = os.Stdout
errorOutput io.Writer = os.Stderr
)

// commands is the single source for command validation and dispatch. A name
// missing here is an error, never a fallback to All. Production code never
// writes this map; only tests insert stub entries.
// only tests write to this map, to insert stubs
var commands = map[string]func(context.Context, provision.Options) error{
"config": func(ctx context.Context, options provision.Options) error { return options.Config(ctx) },
"create": func(ctx context.Context, options provision.Options) error { return options.Create(ctx) },
Expand All @@ -77,9 +69,6 @@ func main() {
os.Exit(exitCode)
}

// run owns the whole command lifecycle and returns the exit code; main alone
// turns it into os.Exit. Ctrl+C cancels ctx, which cancels the in-flight
// query through pgx instead of hard-killing the process mid-DDL.
func run(ctx context.Context, arguments []string) int {
if len(arguments) == 0 {
fmt.Fprint(errorOutput, usage)
Expand Down Expand Up @@ -116,15 +105,11 @@ func run(ctx context.Context, arguments []string) int {
fmt.Fprintln(errorOutput, "error:", err)
return 1
}
fmt.Fprintf(output, "\nDone: %s completed against %s:%d/%s.\n",
command, options.Host, options.Port, options.Database)
fmt.Fprintf(output, "\nDone: %s completed against %s.\n",
command, options.Endpoint())
return 0
}

// resolveRevision returns the embedded ldflags revision verbatim when a
// release set it; otherwise it falls back to the VCS revision the Go
// toolchain recorded at build time. Test binaries and non-VCS builds resolve
// to "unknown".
func resolveRevision(embedded string) string {
if embedded != "" {
return embedded
Expand Down Expand Up @@ -156,13 +141,13 @@ func revisionFromSettings(settings []debug.BuildSetting) string {
return vcsRevision
}

// newFlagSet registers the shared command flags on a ContinueOnError set.
// Password flags default to empty so the usage output can only ever show the
// environment 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)
flags.StringVar(&options.Host, "host", "localhost", "server host")
flags.StringVar(&options.Host, "host", "localhost",
"server host, or a unix socket directory such as /var/run/postgresql")
flags.IntVar(&options.Port, "port", 5432, "server port")
flags.StringVar(&options.Database, "database", "scada_archive", "archive database name")
flags.StringVar(&options.SuperUser, "superuser", "postgres", "superuser role name")
Expand Down Expand Up @@ -191,8 +176,6 @@ func parseOptions(command string, arguments []string) (provision.Options, error)
return options, nil
}

// resolvePasswordsFromEnvironment fills every password the flags left empty
// from its SEMIBASE_* variable.
func resolvePasswordsFromEnvironment(options *provision.Options) {
if options.SuperPassword == "" {
options.SuperPassword = os.Getenv("SEMIBASE_SUPER_PASSWORD")
Expand All @@ -207,10 +190,8 @@ func resolvePasswordsFromEnvironment(options *provision.Options) {

const dotEnvName = ".env"

// loadDotEnv applies variables from a .env file in the working directory.
// Variables already present in the process environment win; flags win over both.
// A missing file is normal and silent; any other failure is reported so an
// unreadable file does not degrade into a misleading missing-password error.
// an unreadable file is reported so it does not degrade into a misleading
// missing-password error later
func loadDotEnv() {
file, err := os.Open(dotEnvName)
if err != nil {
Expand Down Expand Up @@ -250,8 +231,8 @@ func applyEnv(reader io.Reader) error {
return scanner.Err()
}

// Only a matching pair of surrounding quotes ("..." or '...') is removed, so
// a password that legitimately starts or ends with a quote passes intact.
// only a matching pair is removed, so a password that legitimately starts or
// ends with a quote passes intact
func trimMatchingQuotes(value string) string {
if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] {
return value[1 : len(value)-1]
Expand Down
40 changes: 28 additions & 12 deletions cmd/semibase/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ var passwordEnvironmentNames = []string{
"SEMIBASE_READER_PASSWORD",
}

// captureOutput redirects the package success stream into a buffer for the
// duration of the test. Tests mutating it must not run in parallel.
// callers must not use t.Parallel: the streams are package-level
func captureOutput(t *testing.T) *bytes.Buffer {
t.Helper()
buffer := &bytes.Buffer{}
Expand All @@ -32,7 +31,6 @@ func captureOutput(t *testing.T) *bytes.Buffer {
return buffer
}

// Same contract as captureOutput, for the error stream.
func captureErrorOutput(t *testing.T) *bytes.Buffer {
t.Helper()
buffer := &bytes.Buffer{}
Expand All @@ -42,9 +40,8 @@ func captureErrorOutput(t *testing.T) *bytes.Buffer {
return buffer
}

// unsetEnvForTest removes the variables for the duration of the test:
// t.Setenv registers restoration of the original value, and the Unsetenv
// right after actually clears the variable from the process environment.
// t.Setenv registers restoration of the original value, the Unsetenv right after
// clears the variable from the process environment
func unsetEnvForTest(t *testing.T, names ...string) {
t.Helper()
for _, name := range names {
Expand Down Expand Up @@ -263,7 +260,7 @@ func TestRunGivesCommandsADeadline(t *testing.T) {
captureOutput(t)
clearPasswordEnvironment(t)

// Mutates the shared commands map, so this test must not use t.Parallel.
// mutates the shared commands map, so no t.Parallel here
var deadline time.Time
var hasDeadline bool
commands["stub-deadline"] = func(ctx context.Context, _ provision.Options) error {
Expand All @@ -278,19 +275,40 @@ func TestRunGivesCommandsADeadline(t *testing.T) {
if !hasDeadline {
t.Fatal("command context carries no deadline")
}
// The lower bound catches a dispatch bug wrapping commands in a
// near-zero timeout, which "at most phaseTimeout" alone would pass.
// the lower bound catches a dispatch bug wrapping commands in a near-zero
// timeout, which "at most phaseTimeout" alone would pass
remaining := time.Until(deadline)
if remaining > phaseTimeout || remaining < phaseTimeout/2 {
t.Errorf("deadline is %v away, want close to %v", remaining, phaseTimeout)
}
}

// the Done line is the only place the endpoint reaches an operator on success, and the
// socket spelling of it is what the init-script path prints
func TestRunReportsTheEndpointItRanAgainst(t *testing.T) {
captureErrorOutput(t)
buffer := captureOutput(t)
clearPasswordEnvironment(t)

// mutates the shared commands map, so no t.Parallel here
commands["stub-done"] = func(context.Context, provision.Options) error { return nil }
t.Cleanup(func() { delete(commands, "stub-done") })

arguments := []string{"stub-done", "--host", "/var/run/postgresql", "--database", "semiplot_dev"}
if got := run(context.Background(), arguments); got != 0 {
t.Fatalf("run(stub-done) = %d, want exit code 0", got)
}
want := "Done: stub-done completed against /var/run/postgresql/.s.PGSQL.5432 (semiplot_dev)."
if !strings.Contains(buffer.String(), want) {
t.Errorf("output %q does not contain %q", buffer.String(), want)
}
}

func TestRunReportsCommandFailure(t *testing.T) {
buffer := captureErrorOutput(t)
clearPasswordEnvironment(t)

// Mutates the shared commands map, so this test must not use t.Parallel.
// mutates the shared commands map, so no t.Parallel here
commands["stub-failure"] = func(context.Context, provision.Options) error {
return errors.New("stub-failure sentinel")
}
Expand All @@ -305,8 +323,6 @@ func TestRunReportsCommandFailure(t *testing.T) {
}

func TestCancelledContextReturnsPromptly(t *testing.T) {
// Create prints its step banner through the provision console; discard it
// so the test output stays clean.
previous := provision.SetConsoleOutput(io.Discard)
t.Cleanup(func() { provision.SetConsoleOutput(previous) })

Expand Down
33 changes: 30 additions & 3 deletions docs/architecture/provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ production instance alike. That is the point: a tool that has created the develo
many times is proven; a tool written for commissioning and run once, on site, on the day it
matters, is a liability.

The binary is self-contained: `pgx/v5` talks to the server directly over TCP, so neither
`psql.exe` nor any runtime needs to be present on the target machine. `sql/semiplot_tags.sql`
is embedded at build time. Installing the PostgreSQL engine itself is one documented `winget`
The binary is self-contained: `pgx/v5` speaks the wire protocol itself, over TCP or a unix
socket, so neither `psql.exe` nor any runtime needs to be present on the target machine.
`sql/semiplot_tags.sql` is embedded at build time. Installing the PostgreSQL engine itself is one documented `winget`
line, deliberately outside the tool — OS package management is not database provisioning.

## Commands
Expand All @@ -31,6 +31,33 @@ file in the working directory is read as the lowest-precedence source (flag > en
existing credentials untouched. Flag usage output shows only the environment variable names,
never their values.

## The connection target

`--host` takes a hostname or a unix socket directory, and the split is the driver's own:
`pgconn.isAbsolutePath` calls a value a socket directory when it starts with `/` or is a
drive-letter path such as `C:\pgsock`. A socket directory cannot ride in the URL authority —
its slashes would end the authority — so the connection string moves it, and the port with it,
into the query, percent-encoded:

```
postgres://postgres:***@/scada_archive?host=%2Fvar%2Frun%2Fpostgresql&port=5432
```

pgx then dials `<directory>/.s.PGSQL.<port>` on the `unix` network. Widening the test past pgx's
would be a trap, not a courtesy: libpq also accepts a leading `@` for Linux's abstract namespace,
but pgx resolves such a host as a TCP name, so `semibase` leaves it on the TCP path.

The socket form is what lets `create` run as an init script in the official `postgres` image,
whose entrypoint serves `/docker-entrypoint-initdb.d/` from a temporary server with
`listen_addresses` set to empty — reachable over the socket only. The Linux CI job runs exactly
that: it layers the freshly built binary onto `postgres:17-alpine` behind such an init script and
asserts the container reaches a state where the reader can query.

Messages name the socket file rather than a URL —
`/var/run/postgresql/.s.PGSQL.5432 (scada_archive)`. The database is in parentheses because a
slash would make the socket file read as a directory and send the operator to a path that can
never exist.

## Invariants the provision package owns

The package that runs the SQL owns its guards; the CLI is a thin dispatcher.
Expand Down
12 changes: 6 additions & 6 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ winget install --id PostgreSQL.PostgreSQL.17 --exact

## Основные флаги

| Флаг | По умолчанию | Назначение |
| ------------------ | --------------- | --------------------------------- |
| `--host` | `localhost` | Адрес сервера |
| `--port` | `5432` | Порт сервера |
| `--database` | `scada_archive` | Имя архивной базы |
| `--expected-major` | не проверяется | Требуемая мажорная версия сервера |
| Флаг | По умолчанию | Назначение |
| ------------------ | --------------- | ------------------------------------------------------------- |
| `--host` | `localhost` | Адрес сервера или каталог unix-сокета (`/var/run/postgresql`) |
| `--port` | `5432` | Порт сервера |
| `--database` | `scada_archive` | Имя архивной базы |
| `--expected-major` | не проверяется | Требуемая мажорная версия сервера |

Полный перечень — `semibase <команда> --help`.
11 changes: 6 additions & 5 deletions docs/plans/completed/20260814-linux-bench-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ vanilla PostgreSQL 17 via `winget` (`PostgreSQL.PostgreSQL.17`), so the bench ru
2. [x] `GOOS=linux GOARCH=amd64 go build ./...` and `GOOS=darwin GOARCH=arm64 go build ./...`
both exit 0; `GOOS=linux go vet ./...` exits 0.
3. [x] `go mod tidy` drops `golang.org/x/sys` — nothing else in the module uses it.
4. [ ] The Ubuntu CI job is green: build, unit tests, and the provisioning step — `all` run twice
4. [x] The Ubuntu CI job is green: build, unit tests, and the provisioning step — `all` run twice
against the `postgres:17-alpine` service container, proving the bench path and idempotency
in one step (`verify` inside `all` reports "writer has not run" as a state, not a failure).
Lint runs once, on the Windows job — the same config over the same untagged code.
5. [ ] The Windows CI job stays green.
Confirmed 2026-08-14: master push run `31793374588` succeeded.
5. [x] The Windows CI job stays green. Same run.

## Implementation Steps

Expand Down Expand Up @@ -81,7 +82,7 @@ vanilla PostgreSQL 17 via `winget` (`PostgreSQL.PostgreSQL.17`), so the bench ru
- [x] `ubuntu-latest` job in `.github/workflows/ci.yml`: build, `go test -race`, and a
`postgres:17-alpine` service container provisioned by running `all` twice
- [x] the stale "does not build on a Linux runner" comment removed from `ci.yml` and `CLAUDE.md`
- [ ] first push shows both jobs green (acceptance items 4 and 5)
- [x] first push shows both jobs green (acceptance items 4 and 5)

### Task 4: Documentation — done

Expand All @@ -94,8 +95,8 @@ vanilla PostgreSQL 17 via `winget` (`PostgreSQL.PostgreSQL.17`), so the bench ru

### Task 5: Close the plan

- [ ] CI green on master confirms acceptance items 4 and 5
- [ ] move this plan to `docs/plans/completed/`
- [x] CI green on master confirms acceptance items 4 and 5
- [x] move this plan to `docs/plans/completed/`

## Post-Completion

Expand Down
3 changes: 0 additions & 3 deletions embed.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// Package semibase carries repository-level assets embedded into the binary.
package semibase

import _ "embed"

// SemiplotTagsSQL is the DDL for the one object SemiBase adds to the archive database.
//
//go:embed sql/semiplot_tags.sql
var SemiplotTagsSQL string
Loading