feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs unreachable (the volume mount shadows the image copy) and the active version stuck at whatever the admin console last installed — a native offline-first client then fetched the server's older pinned version over the network instead of using its bundled dict. On boot the backend now DELIVERS the build version onto the volume add-only, from a second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when absent (the flat seed and prior uploads untouched, so in-flight games keep theirs), and InitActiveVersion makes the build version active — except a console-installed version NEWER than the build (compared numerically) is not downgraded by a restart. The .seed_version guard still protects the flat seed's label (a bump is a new subdirectory, never a relabel). The console stays for out-of-band updates. Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion (add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update integration test's deploy-delivers path.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DeliverVersion makes the build's dictionary version resident on the persistent
|
||||
// dictionary volume without disturbing the versions in-flight games already pin.
|
||||
//
|
||||
// The volume is seeded from the image once and never re-seeded, and the image's
|
||||
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
|
||||
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
|
||||
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
|
||||
// copies it into dictDir/<version>/ when the version is not already present —
|
||||
// neither the flat seed's own recorded label nor an existing version
|
||||
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
|
||||
// uploads are never touched, so old games keep the version they pin while the new
|
||||
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
|
||||
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
|
||||
func DeliverVersion(dictDir, seedDir, version string) error {
|
||||
if seedDir == "" || version == "" {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dictDir, version)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return nil // already delivered on a prior boot
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
|
||||
}
|
||||
// A fresh volume is populated from the image and recorded as this version by the
|
||||
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
|
||||
// records the label on first use, matching OpenWithVersions.
|
||||
seed, err := resolveSeedVersion(dictDir, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seed == version {
|
||||
return nil
|
||||
}
|
||||
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
|
||||
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
|
||||
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
|
||||
if err := os.RemoveAll(staging); err != nil {
|
||||
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
|
||||
}
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
|
||||
}
|
||||
for _, file := range dictFiles {
|
||||
src := filepath.Join(seedDir, file)
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
continue // a variant absent from the seed is simply absent under this version
|
||||
} else if err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
|
||||
}
|
||||
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(staging, target); err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a single file, truncating the destination.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: create %s: %w", dst, err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("engine: finalise %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeliverVersion(t *testing.T) {
|
||||
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
|
||||
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
|
||||
t.Fatalf("blank seedDir: %v", err)
|
||||
}
|
||||
|
||||
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
|
||||
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
|
||||
dict := t.TempDir()
|
||||
seed := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range dictFiles {
|
||||
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("deliver v1.3.1: %v", err)
|
||||
}
|
||||
for _, f := range dictFiles {
|
||||
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
|
||||
if err != nil {
|
||||
t.Fatalf("delivered %s: %v", f, err)
|
||||
}
|
||||
if string(got) != "dawg:"+f {
|
||||
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
|
||||
}
|
||||
}
|
||||
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
|
||||
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
|
||||
}
|
||||
// Idempotent: a second call is a no-op and leaves no staging directory behind.
|
||||
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("re-deliver v1.3.1: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
|
||||
t.Errorf("a staging directory survived delivery")
|
||||
}
|
||||
|
||||
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
|
||||
// as a redundant subdirectory.
|
||||
fresh := t.TempDir()
|
||||
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("fresh deliver: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
|
||||
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
|
||||
}
|
||||
}
|
||||
|
||||
func hasStaging(entries []os.DirEntry) bool {
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
|
||||
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
||||
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
||||
//
|
||||
// So bumping the build seed on a live volume is a harmless no-op (it only takes
|
||||
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
|
||||
// which would void games pinned to the prior label and mis-serve new ones. New games
|
||||
// still pin the active version (DB-persisted, set by the admin console), which is the
|
||||
// real way a running contour moves to a new release.
|
||||
// So a later build seed never relabels the already-seeded flat bytes — which would void
|
||||
// games pinned to the prior label and mis-serve new ones. The new build version is instead
|
||||
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
|
||||
// so a redeploy still moves new games to it while old games keep the flat label. New games
|
||||
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
|
||||
//
|
||||
// A directory that cannot be written makes the first record fail; that also breaks
|
||||
// the admin console (which writes version subdirectories here), so the error is
|
||||
|
||||
Reference in New Issue
Block a user