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// 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 }