Merge pull request 'feat(engine,deploy): seed-drift guard + track current dictionary release (v1.2.1)' (#92) from feature/dict-version-track-release into development
This commit was merged in pull request #92.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-3
@@ -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**
|
||||
|
||||
+1
-1
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user