feat(engine,deploy): seed-drift guard + track current dictionary release
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at v1.0.0 in CI and the image/compose seed defaults. Two problems: 1. CI validated against a stale dictionary. 2. The contour seed could be bumped on a live volume, which silently relabels the already-seeded bytes — voiding games pinned to the prior label and serving the wrong dictionary for new ones. The flat DAWGs carry no embedded version, so this drift was undetectable. Changes: - Seed-drift guard: OpenWithVersions records the flat dir's version in a .seed_version marker on first boot and refuses to start when a later BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh* volume only; a live contour migrates through the admin console (old versions stay resident, in-progress games keep replaying). - Track the current release: CI's DICT_VERSION centralised to one workflow-level env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy job keeps reading the per-contour vars.TEST_DICT_VERSION. - Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE tracker (DV row). Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and integration (-tags=integration) all green.
This commit is contained in:
+2
-2
@@ -12,7 +12,7 @@
|
||||
|
||||
# --- dictionary artifact -----------------------------------------------------
|
||||
FROM alpine:3.20 AS dawg
|
||||
ARG DICT_VERSION=v1.0.0
|
||||
ARG DICT_VERSION=v1.2.1
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
@@ -40,7 +40,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
||||
# label the binary pins, so the resident version equals the release tag.
|
||||
ARG DICT_VERSION=v1.0.0
|
||||
ARG DICT_VERSION=v1.2.1
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||
|
||||
+9
-3
@@ -190,7 +190,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Seed dictionary version new games pin (the flat dir's label). Recorded in a `.seed_version` marker on first boot; the backend refuses to start if a later value drifts from it — the seed-drift guard (ARCHITECTURE.md §5). |
|
||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||
@@ -212,7 +212,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
```sh
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
BACKEND_DICT_DIR=/tmp/dawg \
|
||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||
@@ -251,7 +251,13 @@ local solver co-development you may add a temporary replace — see `go.work`).
|
||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot).
|
||||
(a missing dictionary aborts the boot). The flat directory is the seed version,
|
||||
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
|
||||
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
|
||||
carry no embedded version, the first boot records the seed in a `.seed_version`
|
||||
marker and a later boot **refuses to start** if `BACKEND_DICT_VERSION` no longer
|
||||
matches it (the seed-drift guard), so a live contour's dictionary is changed through
|
||||
the console, never by bumping the build seed (ARCHITECTURE.md §5).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -71,13 +71,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
||||
// version V, the variants whose committed DAWG it carries. This is the
|
||||
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
||||
// at runtime is resident again after a restart. A subdirectory named like the
|
||||
// boot version is skipped (the flat dir already is the boot version). A partially
|
||||
// loaded registry is closed before any error is returned.
|
||||
// boot version is skipped (the flat dir already is the boot version). It records
|
||||
// and enforces a seed-version marker on the flat dir (see checkSeedMarker), failing
|
||||
// when the build seed was bumped on an already-seeded volume. A partially loaded
|
||||
// registry is closed before any error is returned.
|
||||
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
r, err := Open(dir, bootVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Guard the seed-drift footgun before scanning for additional versions: refuse
|
||||
// to boot when the flat dir was seeded under a different version than bootVersion.
|
||||
if err := checkSeedMarker(dir, bootVersion); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
_ = r.Close()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -112,6 +113,59 @@ func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
|
||||
// version in the flat dir's marker, the marker is not mistaken for a version, and a
|
||||
// reboot at the same seed version succeeds.
|
||||
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, v := range Variants() {
|
||||
copyDawg(t, testDictDir(), dir, v)
|
||||
}
|
||||
|
||||
reg, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("first open: %v", err)
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
|
||||
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
|
||||
}
|
||||
_ = reg.Close()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
|
||||
if err != nil {
|
||||
t.Fatalf("read seed marker: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(data)); got != "v1" {
|
||||
t.Fatalf("seed marker = %q, want v1", got)
|
||||
}
|
||||
|
||||
reg2, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("reboot at same seed: %v", err)
|
||||
}
|
||||
_ = reg2.Close()
|
||||
}
|
||||
|
||||
// TestOpenWithVersionsRejectsSeedDrift verifies the guard refuses to boot a directory
|
||||
// seeded as one version when BACKEND_DICT_VERSION (bootVersion) names another — the
|
||||
// seed-drift footgun a bumped build seed on a live volume would cause.
|
||||
func TestOpenWithVersionsRejectsSeedDrift(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, v := range Variants() {
|
||||
copyDawg(t, testDictDir(), dir, v)
|
||||
}
|
||||
|
||||
reg, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("seed open: %v", err)
|
||||
}
|
||||
_ = reg.Close()
|
||||
|
||||
if _, err := OpenWithVersions(dir, "v2"); err == nil {
|
||||
t.Fatal("open after seed bump: want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
|
||||
// already resident, moves the latest pointer and keeps the earlier version.
|
||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// seedMarkerFile names the file, in the flat dictionary directory, that records the
|
||||
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
|
||||
// version scan skips it (like the .staging upload area).
|
||||
const seedMarkerFile = ".seed_version"
|
||||
|
||||
// checkSeedMarker reconciles the flat dictionary directory's recorded seed version
|
||||
// with bootVersion, guarding the seed-drift footgun.
|
||||
//
|
||||
// The contour's dictionary lives on a named volume seeded from the image once and
|
||||
// never re-seeded (deploy/docker-compose.yml). The flat directory's DAWG files carry
|
||||
// no embedded version, so their version is only the label BACKEND_DICT_VERSION gives
|
||||
// them. Bumping the build seed on a live volume would therefore relabel the already
|
||||
// seeded bytes: games that pinned the old label become unreplayable (voided) and new
|
||||
// games would silently use the wrong dictionary. checkSeedMarker records the seed
|
||||
// version on a fresh directory and, on every later boot, returns an error when
|
||||
// bootVersion no longer matches the recorded seed — so an operator changes a live
|
||||
// dictionary by uploading the new release through the admin console, or wipes the
|
||||
// volume to re-seed, never by bumping the build seed (docs/ARCHITECTURE.md §5).
|
||||
//
|
||||
// A directory that cannot be written makes the first record fail; that already breaks
|
||||
// the admin console (which writes version subdirectories here), so the error is
|
||||
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
|
||||
func checkSeedMarker(dir, bootVersion string) error {
|
||||
path := filepath.Join(dir, seedMarkerFile)
|
||||
data, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if recorded := strings.TrimSpace(string(data)); recorded != bootVersion {
|
||||
return fmt.Errorf("engine: dictionary volume was seeded as %q but BACKEND_DICT_VERSION is %q; "+
|
||||
"change a live dictionary by uploading the release through the admin console, or wipe the "+
|
||||
"volume to re-seed — do not bump the build seed (docs/ARCHITECTURE.md §5)", recorded, bootVersion)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
if err := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("engine: record dictionary seed marker %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user