diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c41b40c..047c483 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,6 +26,13 @@ on: push: branches: [development] +# The dictionary release the test suite validates against โ€” the current +# scrabble-dictionary release. Centralised here so a release bump is one edit; the +# unit/integration jobs inherit it. The deploy job overrides it per contour with +# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. +env: + DICT_VERSION: v1.2.1 + jobs: # changes detects which areas a PR/push touched, so the test jobs can skip when # irrelevant. It defaults to running everything when the diff cannot be computed. @@ -86,7 +93,6 @@ jobs: # The engine consumes the published scrabble-solver module from this Gitea; # GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB). GOPRIVATE: gitea.iliadenisov.ru/* - DICT_VERSION: v1.0.0 steps: - name: Checkout uses: actions/checkout@v4 @@ -134,7 +140,6 @@ jobs: # suite's TestMain terminates its own container, so disable it. TESTCONTAINERS_RYUK_DISABLED: "true" GOPRIVATE: gitea.iliadenisov.ru/* - DICT_VERSION: v1.0.0 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/PRERELEASE.md b/PRERELEASE.md index 0c9894f..9dd46c8 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -38,6 +38,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a โœ–๏ธ **block** control (mirroring ๐Ÿค, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name โ€” never the shared robot account โ€” so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** | | FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) โ€” not a seed โ€” is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | | SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing โ€” the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | +| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in a `.seed_version` marker and refuses to boot when a bumped build seed would relabel a live volume (silently serving the wrong dictionary + voiding games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | โ†’ | Stage 18 โ€” prod contour deploy | โ€” | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list โ€” read before starting a phase) diff --git a/backend/Dockerfile b/backend/Dockerfile index ae17cc3..31cad95 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/README.md b/backend/README.md index 57c66ff..8643d00 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 `/` +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 diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index 5cf9c11..f153471 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -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// // 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() diff --git a/backend/internal/engine/reload_test.go b/backend/internal/engine/reload_test.go index 75c6122..8d2426f 100644 --- a/backend/internal/engine/reload_test.go +++ b/backend/internal/engine/reload_test.go @@ -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) { diff --git a/backend/internal/engine/seedmarker.go b/backend/internal/engine/seedmarker.go new file mode 100644 index 0000000..5425e31 --- /dev/null +++ b/backend/internal/engine/seedmarker.go @@ -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) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index 7bfe49f..ae4fdcc 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -10,12 +10,13 @@ POSTGRES_USER=scrabble POSTGRES_PASSWORD=change-me # required # --- Dictionary ------------------------------------------------------------- -# scrabble-dictionary release tag baked into the image as the SEED dictionary -# (image build-arg; also labels the resident seed version). After first boot the -# dawg-data volume preserves versions uploaded through the admin console, and the -# active version lives in the DB โ€” so bumping this on a later deploy does NOT change -# a running contour; update the dictionary through /_gm/dictionary instead. -DICT_VERSION=v1.0.0 +# scrabble-dictionary release tag baked into the image as the SEED dictionary for a +# FRESH volume (image build-arg; also labels the resident seed version). After first +# boot the dawg-data volume preserves versions uploaded through the admin console and +# the active version lives in the DB. On a live volume the backend refuses to start if +# this no longer matches the recorded seed (the seed-drift guard) โ€” change a running +# contour's dictionary through /_gm/dictionary, not by bumping this (ARCHITECTURE.md ยง5). +DICT_VERSION=v1.2.1 # --- Logging ---------------------------------------------------------------- LOG_LEVEL=info diff --git a/deploy/README.md b/deploy/README.md index 5031fac..b38b41d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -71,7 +71,7 @@ compose, but the connector **fails at boot** when it is empty. | --- | --- | --- | --- | | `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_USER` | variable | `scrabble` | Database user. | -| `DICT_VERSION` | variable | `v1.0.0` | `scrabble-dictionary` release tag baked into the backend image (build-arg). | +| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; the backend refuses to boot if it drifts from the volume's recorded `.seed_version` (the seed-drift guard, ARCHITECTURE.md ยง5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`debug\|info\|warn\|error`). | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1046b9d..db2195f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -66,7 +66,9 @@ services: context: .. dockerfile: backend/Dockerfile args: - DICT_VERSION: ${DICT_VERSION:-v1.0.0} + # Seed dictionary for a FRESH volume; the per-contour value comes from the + # deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below. + DICT_VERSION: ${DICT_VERSION:-v1.2.1} restart: unless-stopped logging: *default-logging depends_on: @@ -94,9 +96,11 @@ services: # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the # volume preserves them โ€” and the versions in-progress games pin โ€” across - # redeploys. Once seeded the volume is not re-seeded, so bumping DICT_VERSION on a - # later deploy has no effect on an existing contour; update via the console - # instead (docs/ARCHITECTURE.md ยง5). + # redeploys. Once seeded the volume is not re-seeded: DICT_VERSION is the seed for + # a FRESH volume only. On a live volume the backend refuses to start if DICT_VERSION + # no longer matches the recorded seed (the seed-drift guard), so a running contour's + # dictionary is changed through the admin console, never by bumping the seed + # (docs/ARCHITECTURE.md ยง5). volumes: - dawg-data:/opt/dawg # No container healthcheck: the distroless image has no shell/wget. Readiness diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3871bcc..58e1251 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -273,9 +273,18 @@ Key points: skipping the `.staging/` upload area) and restores the active pointer from `dictionary_state`. The volume preserves uploaded versions across redeploys; once seeded it is not re-seeded, so after bootstrap dictionary changes go through - the console rather than a rebuild. (The dictionaries ship as a versioned - **release artifact** from the `scrabble-dictionary` repo; the build's - `DICT_VERSION` selects only the seed.) + the console rather than a rebuild. Because the flat DAWGs carry no embedded + version, `OpenWithVersions` records the version the flat directory was first + opened at in a `.seed_version` marker on the volume and **refuses to boot** when a + later `BACKEND_DICT_VERSION` disagrees โ€” the **seed-drift guard**: it stops a + bumped build seed on a live volume from relabelling the already-seeded bytes, + which would silently serve the wrong dictionary and void games pinned to the prior + label. A running contour therefore moves to a new release **through the console** + (the prior version stays resident, so its games keep replaying); `DICT_VERSION` is + the seed for a **fresh** volume only, set per contour from the deploy's + `TEST_`/`PROD_DICT_VERSION`. (The dictionaries ship as a versioned **release + artifact** from the `scrabble-dictionary` repo; the build's `DICT_VERSION` selects + only the seed.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** diff --git a/loadtest/Dockerfile b/loadtest/Dockerfile index acb4787..410bd34 100644 --- a/loadtest/Dockerfile +++ b/loadtest/Dockerfile @@ -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 \