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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Precedence: command-line flag > process environment > this file.
SEMIBASE_SUPER_PASSWORD=
SEMIBASE_WRITER_PASSWORD=
SEMIBASE_READER_PASSWORD=
SEMIBASE_PLOT_PASSWORD=
23 changes: 23 additions & 0 deletions .github/scripts/wait-for-postgres.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Waits for the PostgreSQL inside a container to answer. A timeout fails here with a named
# error rather than falling through, which would hand the next step a diagnosis of its own.
set -euo pipefail

container=${1:-}
[ -n "$container" ] || { echo "usage: wait-for-postgres.sh <container>" >&2; exit 2; }

for _ in $(seq 60); do
if docker exec "$container" pg_isready -h 127.0.0.1 -q 2>/dev/null; then
exit 0
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$container")" != true ]; then
echo "::error::$container stopped before its PostgreSQL answered"
docker logs "$container"
exit 1
fi
sleep 2
done

echo "::error::PostgreSQL in $container did not answer within 120 s"
docker logs "$container"
exit 1
60 changes: 46 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:

linux:
runs-on: ubuntu-latest
env:
SEMIBASE_SUPER_PASSWORD: ci-super
SEMIBASE_WRITER_PASSWORD: ci-writer
services:
postgres:
image: postgres:17-alpine
Expand Down Expand Up @@ -61,24 +64,57 @@ jobs:
# finds every object in place and still exits on a passing access check.
- name: Provision the service container
env:
SEMIBASE_SUPER_PASSWORD: ci-super
SEMIBASE_WRITER_PASSWORD: ci-writer
SEMIBASE_READER_PASSWORD: ci-reader
SEMIBASE_PLOT_PASSWORD: ci-plot
run: |
go build -o semibase ./cmd/semibase
./semibase site --database semiplot_dev --expected-major 17
./semibase site --database semiplot_dev --expected-major 17

# Every other step runs semiplot_register_new_pens() over an empty
# public.trends, so its INSERT adds nothing.
- name: Register pens from the keys SCADA wrote
env:
PGHOST: localhost
PGDATABASE: semiplot_dev
run: |
set -euo pipefail
as_writer() { PGUSER=scada_writer PGPASSWORD=ci-writer psql -v ON_ERROR_STOP=1 -qtAc "$1"; }
as_plot() { PGUSER=semiplot PGPASSWORD=ci-plot psql -v ON_ERROR_STOP=1 -qtAc "$1"; }
as_writer "CREATE TABLE public.tp2026m01d01 PARTITION OF public.trends
FOR VALUES FROM ('2026-01-01') TO ('2026-01-02');
INSERT INTO public.trends (id, l, t, v, q) VALUES
(0, 0, '2026-01-01 00:00:00', 1.5, 192), (0, 0, '2026-01-01 00:00:01', 1.6, 192),
(13, 0, '2026-01-01 00:00:00', 20, 192), (40, 1, '2026-01-01 00:01:00', 7, 192)"
first=$(as_plot 'SELECT semiplot_register_new_pens()')
second=$(as_plot 'SELECT semiplot_register_new_pens()')
pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start), ','
ORDER BY id) FROM semiplot_tags")
want='0 0 #4E79A7 f,13 13 #F28E2B f,40 40 #59A14F f'
if [[ "$first" != 3 || "$second" != 0 || "$pens" != "$want" ]]; then
echo "::error::first call '$first' (want 3), second '$second' (want 0), pens '$pens' (want '$want')"
exit 1
fi

- name: Provision a PostgreSQL 14 container, where the REVOKE bites
env:
SEMIBASE_PLOT_PASSWORD: ci-plot
run: |
set -euo pipefail
docker run -d --name semibase-pg14 -e POSTGRES_PASSWORD=ci-super \
-p 15434:5432 postgres:14-alpine
bash .github/scripts/wait-for-postgres.sh semibase-pg14
# ./semibase is the binary the service-container step built
./semibase bench --host 127.0.0.1 --port 15434 --database semiplot_dev --expected-major 14

# 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
# abort on failure. So the container reaching a state where semiplot
# selects over TCP proves bench 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
SEMIBASE_PLOT_PASSWORD: ci-plot
# 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
Expand Down Expand Up @@ -110,15 +146,11 @@ jobs:
# 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
-e SEMIBASE_WRITER_PASSWORD -e SEMIBASE_PLOT_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
bash .github/scripts/wait-for-postgres.sh semibase-initdb
if ! docker exec -e PGPASSWORD=ci-plot semibase-initdb psql -h 127.0.0.1 \
-U semiplot -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
Expand Down
68 changes: 51 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ provisions and configures the instance that hosts the Simple-Scada 2 archive: th
CLI binary, `semibase.exe`, a container image carrying its Linux build, plus the instance's
architecture docs.
Deployment target: Windows. Language: Go (module `github.com/Semiteq/SemiBase`), driver
`pgx/v5`, `sql/semiplot_tags.sql` and `sql/trends.sql` embedded via `go:embed`. Entry point:
`pgx/v5`, the four `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.
Expand Down Expand Up @@ -81,14 +81,21 @@ golangci-lint is pinned to 2.12.2 (`winget install GolangCI.golangci-lint --vers
config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build`, `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 — provisions a second 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 request rather than at tag time. CI pushes
nothing.
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
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
request rather than at tag time. CI pushes nothing. Between the 17 and the 14 steps, the
`Register pens from the keys SCADA wrote` step writes `trends` rows as `scada_writer`, calls
`semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0, and the three rows it
added: the only place the function body runs over a non-empty archive. It needs `psql` on the
runner; to run it locally, point `psql` at a container.

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 check is
the reader-access check both commands run at the tail, against a live server.
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 four CI database steps above,
against a live server.

## Format

Expand All @@ -106,24 +113,51 @@ gofmt -w . # run before presenting changes; gofmt is authoritative
```

`site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` memory constants,
`bench` does not. Both create the database, the roles, the grants, `semiplot_tags` and
`public.trends`, and both end by actually reading `public.trends` as `semiplot_reader` and
checking that the same role holds no `INSERT`. A failed check is a non-zero exit.
`bench` does not. Both create the database, the roles, the grants, the four SemiPlot tables
(`semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_meta`), the
`semiplot_register_new_pens()` function and `public.trends`, and both end by actually reading
`public.trends` and `semiplot_meta.schema_version` as `semiplot`, actually calling
`semiplot_register_new_pens()`, proving it ignores a temporary `trends` of its caller, and writing
`semiplot_groups`, `semiplot_pen_groups` and the settings columns of `semiplot_tags` as `semiplot`
in a rolled-back transaction, requiring 42501 for `INSERT`, `DELETE` and `UPDATE ... SET id` on
`semiplot_tags` in the same transaction, and checking that `PUBLIC` holds no `EXECUTE` on
`semiplot_register_new_pens()`, and that `semiplot` holds no `INSERT`, `UPDATE` or `DELETE`
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.

The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the eight settings
columns, never `id`, so `has_table_privilege(..., 'UPDATE')` answers false for that table and no
check may ask it.

Four roles take part: the superuser the tool connects as (it owns the `semiplot_*` tables),
`scada_writer`, `semiplot`, and `semiplot_registrar`, a `NOLOGIN` role that owns
`semiplot_register_new_pens()` and holds only what its body needs. The function is `SECURITY
DEFINER` with `search_path = pg_catalog, pg_temp` and a schema-qualified body; the reasons are in
`docs/architecture/provisioning.md#registering-new-pens`. `scada_writer` is trusted: the grants bound
the operator acting through `semiplot`, not the SCADA (`docs/architecture/provisioning.md#trust`).
`semiplot_meta.schema_version` is `1` in
this release and is a floor: a viewer refuses a database below the version it needs and accepts one
above.

Passwords come from flags, env, or a `.env` file in the working directory (flag > env > `.env`;
template `.env.example`): `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD`,
`SEMIBASE_READER_PASSWORD`. The writer and reader passwords are needed only on a first run,
which creates those roles; `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.
`SEMIBASE_PLOT_PASSWORD`. 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.

## Layout

```
cmd/semibase/ CLI entry point: command dispatch, flags, usage text
internal/provision/ the phases behind the two commands: config (ALTER SYSTEM), create
(db/roles/grants/semiplot_tags/trends), check (reader access)
sql/ embedded DDL: semiplot_tags (ours) and trends (the vendor's shape)
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
registrarStatements), check.go (semiplot access). provision.go
holds Options, Site/Bench and the connection plumbing they share
sql/ embedded DDL: the four semiplot_* files (ours: the tables, the
pen-registration function, the version stamp) and trends (the vendor's shape)
docs/ human docs in Russian (enter at docs/readme.md)
docs/architecture/ agent-facing design docs in English
docs/plans/ dated implementation plans
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус
# установка pgsql
winget install --id PostgreSQL.PostgreSQL.17 --exact

# конфигурация сервера, архивная база, роли, права, таблица архива
# конфигурация сервера, архивная база, роли `scada_writer`, `semiplot` и
# `semiplot_registrar`, права, таблица архива, четыре таблицы SemiPlot и функция
# регистрации новых перьев
.\semibase.exe site
```
29 changes: 17 additions & 12 deletions cmd/semibase/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ Usage:

Commands:
site an installation machine: apply the memory tuning (ALTER SYSTEM + reload),
then create the archive database, the roles, the access chain,
semiplot_tags and public.trends
then create the archive database, the roles, the access chain, public.trends,
the four semiplot_* tables and semiplot_register_new_pens()
bench a throwaway container: the same, without the tuning
version print the build revision

Both commands end by reading public.trends as semiplot_reader and checking that the
same role cannot write it; a failed check is a non-zero exit.
Both commands end by checking the semiplot role in a transaction that is rolled back:
it reads public.trends and semiplot_meta, registers new pens through
semiplot_register_new_pens(), updates pen settings and groups, and is refused an insert,
a delete or a key change on semiplot_tags; it writes neither the archive nor
semiplot_meta and holds no CREATE on schema public. A failed check is a non-zero exit.

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 All @@ -39,11 +42,13 @@ Passwords come from flags, environment variables, or a .env file in the working
directory (flag wins over environment, environment wins over .env):
--super-password SEMIBASE_SUPER_PASSWORD superuser
--writer-password SEMIBASE_WRITER_PASSWORD scada_writer
--reader-password SEMIBASE_READER_PASSWORD semiplot_reader
--plot-password SEMIBASE_PLOT_PASSWORD semiplot

A first run creates both roles and fails without their passwords. Once the roles exist,
the superuser password alone carries a run; the reader password then only decides
whether the run also tests the semiplot_reader login, which it does over TCP.
A first run creates scada_writer and semiplot and fails without their passwords; it also
creates semiplot_registrar, which owns the registration function and never logs in, so
it has no password. Once the roles exist,
the superuser password alone carries a run; the semiplot password then only decides
whether the run also tests the semiplot login, which it does over TCP.

Run 'semibase <command> --help' for the command's flags.
`
Expand Down Expand Up @@ -161,8 +166,8 @@ func newFlagSet(command string, options *provision.Options) *flag.FlagSet {
"superuser password (env SEMIBASE_SUPER_PASSWORD)")
flags.StringVar(&options.WriterPassword, "writer-password", "",
"scada_writer password (env SEMIBASE_WRITER_PASSWORD)")
flags.StringVar(&options.ReaderPassword, "reader-password", "",
"semiplot_reader password (env SEMIBASE_READER_PASSWORD)")
flags.StringVar(&options.PlotPassword, "plot-password", "",
"semiplot password (env SEMIBASE_PLOT_PASSWORD)")
flags.IntVar(&options.ExpectedMajor, "expected-major", 0,
"required server major version; 0 accepts any major >= 14")
return flags
Expand All @@ -189,8 +194,8 @@ func resolvePasswordsFromEnvironment(options *provision.Options) {
if options.WriterPassword == "" {
options.WriterPassword = os.Getenv("SEMIBASE_WRITER_PASSWORD")
}
if options.ReaderPassword == "" {
options.ReaderPassword = os.Getenv("SEMIBASE_READER_PASSWORD")
if options.PlotPassword == "" {
options.PlotPassword = os.Getenv("SEMIBASE_PLOT_PASSWORD")
}
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/semibase/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
var passwordEnvironmentNames = []string{
"SEMIBASE_SUPER_PASSWORD",
"SEMIBASE_WRITER_PASSWORD",
"SEMIBASE_READER_PASSWORD",
"SEMIBASE_PLOT_PASSWORD",
}

// callers must not use t.Parallel: the streams are package-level
Expand Down Expand Up @@ -59,7 +59,7 @@ func TestFlagUsageHidesPasswordValues(t *testing.T) {
secrets := map[string]string{
"SEMIBASE_SUPER_PASSWORD": "hunter2-super",
"SEMIBASE_WRITER_PASSWORD": "hunter2-writer",
"SEMIBASE_READER_PASSWORD": "hunter2-reader",
"SEMIBASE_PLOT_PASSWORD": "hunter2-plot",
}
for name, value := range secrets {
t.Setenv(name, value)
Expand Down
Loading
Loading