48614e622d
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.
93 lines
3.4 KiB
Go
93 lines
3.4 KiB
Go
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
|
|
}
|