carlos / platform Public

edges: every edges-file read site is inventoried, discovered by type, and ignores offline_moves for served-ness

Self-serve moves plan 3, Task 4 (tests only). TestEveryEdgesFileReadSiteIsInventoried
loads ./internal/... with go/packages and finds every read of the edges file by
type: (i) a use of an edgesfile reader, (ii) a json decode into a type with a
top-level hosts or box_id field (through aliases, named types, embedding,
slices, and decode helpers taking any or a type parameter), (iii) a use of a
wrapper of those, followed further only while the result can carry
offline_moves, and (iv) any use of an offline_moves field (pinned to the writer
and the home map's candidate builder). 48 sites, each mapped to the test that
asserts it; eight non-edges types excluded with reasons; both lists pinned, and
every named test must exist. Fixture packages under
internal/edgesfile/testdata/audit (out of ./... by the testdata rule) and the
real steering.parseEdgesFileView prove the detector.

Each inventory test reads internal/edgesfile/testdata/offline-moves.json three
ways with the same box evidence: as written, without offline_moves, and with
the hold counted as served routes. The first two agree everywhere; where the
reader depends on hosts the third differs, so each assertion fails if the hold
were counted (shown by scratch -overlay mutations of edgesfile.Parse,
edge.parseEdgesFile and steering.parseEdgesFileView).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e9c26e2dd4336f984c20cc887a7fda9a9e4ee177 parent 1f992d2
15 files changed, +2390 −0
  • internal/console/edges_offline_moves_test.go +278 −0
  • internal/domains/aliases_offline_moves_test.go +134 −0
  • internal/edgesfile/audit_test.go +963 −0
  • internal/edgesfile/offline_moves_test.go +233 −0
  • internal/edgesfile/testdata/audit/alias/alias.go +50 −0
  • internal/edgesfile/testdata/audit/boxid/boxid.go +19 −0
  • internal/edgesfile/testdata/audit/helper/helper.go +45 −0
  • internal/edgesfile/testdata/audit/holds/holds.go +30 −0
  • internal/edgesfile/testdata/audit/unrelated/unrelated.go +33 −0
  • internal/edgesfile/testdata/audit/wrapper/wrapper.go +23 −0
  • internal/host/edgesfile_decoders_test.go +243 −0
  • internal/host/movespass_offline_moves_test.go +31 −0
  • internal/host/movesreport_offline_moves_test.go +85 −0
  • internal/hostactors/references_offline_moves_test.go +100 −0
  • internal/steering/offline_moves_test.go +123 −0

CI failed — run details

CI log
=== gate ===
sigwho: pid 2 ppid 1 pgid 0 sid 0; child pid 3
.amadan/test-gate.sh
gate: 2026-10-24T05:11:52Z on aarch64, 32 cpus, 61313 MB available, go1.26.8 linux/arm64
gate: +0s start launchctx
gate: +0s start vet
gate: +0s start gofmt
gate: +1s start rest
gate: +16s start internal_console@0
gate: +16s start internal_console@1
gate: +16s start internal_console@2
gate: +16s start internal_console@3
gate: +16s start internal_console@4
gate: +16s start internal_console@5
gate: +16s start internal_console@6
gate: +16s start internal_console@7
gate: +16s waiting for internal_console's shards before the next split package
gate: +182s start root@0
gate: +182s start root@1
gate: +182s start root@2
gate: +182s waiting for root's shards before the next split package

amadan: step gate failed: signal: terminated

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/console/edges_offline_moves_test.go b/internal/console/edges_offline_moves_test.go
new file mode 100644
index 000000000..4f0ff6d09
--- /dev/null
+++ b/internal/console/edges_offline_moves_test.go
@@ -0,0 +1,278 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// The fixture three ways (self-serve moves plan 3, Task 4): as written,
+// without offline_moves, and with each holding entry's host and aliases
+// published as plain home routes (what counting the hold as served would
+// mean). edit sets box-level fields on all three alike, so the evidence
+// varies only in offline_moves. internal/edgesfile/offline_moves_test.go
+// holds the reference copy of this helper.
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxBox = "annie"
+ fxPath = "../edgesfile/testdata/offline-moves.json"
+)
+
+type holdVariants struct{ with, without, served []byte }
+
+func fixtureVariants(t *testing.T, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(fxPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ m[k], _ = json.Marshal(v)
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ len(holds[0].Aliases) != 1 || holds[0].Aliases[0] != fxHeldAlias {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ hosts[name], _ = json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// consoleVariants is the fixture three ways, each with the same box-level
+// evidence: annie publishes placement uk in eu-west-2, fully eligible
+// (hosting, shared pool, inbound, the deployment's key, the lifetimes and
+// region HA capabilities).
+func consoleVariants(t *testing.T) map[string][]byte {
+ t.Helper()
+ v := fixtureVariants(t, map[string]any{
+ "placement": "uk", "region": "eu-west-2", "hibernation_configured": true, "serves_shared_pool": true,
+ "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{capLifetimes1, boxchoice.Capability},
+ })
+ return map[string][]byte{"with": v.with, "without": v.without, "served": v.served}
+}
+
+// edgesServer is a console over a bucket holding body as annie's edges
+// file beside a fresh status report for annie.
+func edgesServer(t *testing.T, body []byte) *Server {
+ t.Helper()
+ st := release.NewDirStore(t.TempDir())
+ if _, err := st.Put(context.Background(), edgesfile.Key(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(context.Background(), st, status.Box{GeneratedAt: time.Now(), Label: fxBox}); err != nil {
+ t.Fatal(err)
+ }
+ return &Server{cfg: Config{Releases: st, SecretsPub: testKeyPub}}
+}
+
+// The placement join (readEdgesSnapshot under readEdgesFiles): loadEdgesJoin
+// names the served route's box and region and never the held host or its
+// alias; offlineServes, the start door's "the target already lists the
+// host" check, is false for the hold; placements and movePlacements are
+// host-agnostic and unchanged. Counted as served, the hold would appear in
+// the join and offlineServes would refuse the move.
+func TestConsolePlacementJoinLeavesTheHoldOut(t *testing.T) {
+ ctx := context.Background()
+ type read struct {
+ join map[string][]servedBy
+ serves map[string]bool
+ caps map[string]placementCapability
+ moveCaps map[string]placementCapability
+ published bool
+ }
+ got := map[string]read{}
+ for name, body := range consoleVariants(t) {
+ s := edgesServer(t, body)
+ if snap, err := readEdgesSnapshot(ctx, s.cfg.Releases); err != nil || !snap.Complete || len(snap.Files) != 1 {
+ t.Fatalf("%s: readEdgesSnapshot = %+v, %v", name, snap, err)
+ }
+ files := readEdgesFiles(ctx, s.cfg.Releases)
+ if len(files) != 1 {
+ t.Fatalf("%s: readEdgesFiles = %+v", name, files)
+ }
+ r := read{join: loadEdgesJoin(ctx, s.cfg.Releases), serves: map[string]bool{}}
+ for _, h := range []string{fxServed, fxHeld, fxHeldAlias} {
+ r.serves[h] = offlineServes(files[0], h)
+ }
+ r.caps, r.published = placements(ctx, s.cfg.Releases)
+ r.moveCaps, _, _ = movePlacements(ctx, s.cfg.Releases, "")
+ got[name] = r
+ }
+ with := got["with"]
+ if !reflect.DeepEqual(with.join[fxServed], []servedBy{{Box: fxBox, Region: "eu-west-2"}}) {
+ t.Fatalf("loadEdgesJoin[%s] = %v", fxServed, with.join[fxServed])
+ }
+ if _, ok := with.join[fxHeld]; ok {
+ t.Fatalf("loadEdgesJoin lists the held host: %v", with.join)
+ }
+ if _, ok := with.join[fxHeldAlias]; ok {
+ t.Fatalf("loadEdgesJoin lists the held alias: %v", with.join)
+ }
+ if !reflect.DeepEqual(with.serves, map[string]bool{fxServed: true, fxHeld: false, fxHeldAlias: false}) {
+ t.Fatalf("offlineServes = %v, want only the served route", with.serves)
+ }
+ if !with.published || !with.caps["uk"].Hosting || !with.moveCaps["uk"].Reachable {
+ t.Fatalf("placements = %+v (published %v), movePlacements = %+v", with.caps, with.published, with.moveCaps)
+ }
+ if !reflect.DeepEqual(with, got["without"]) {
+ t.Fatalf("with offline_moves %+v, without %+v", with, got["without"])
+ }
+ served := got["served"]
+ if !served.serves[fxHeld] || len(served.join[fxHeld]) != 1 {
+ t.Fatalf("test bug: counted as served, the hold must join and refuse: %+v", served)
+ }
+ if !reflect.DeepEqual(with.caps, served.caps) || !reflect.DeepEqual(with.moveCaps, served.moveCaps) {
+ t.Fatal("placements depend on hosts")
+ }
+}
+
+// liveEdges (readLiveEdgesSnapshot) feeds every automatic choice: the
+// region candidates and boxchoice's executor choice pick annie for the
+// served route's region uk, identically with and without offline_moves and
+// with the hold counted (both are host-agnostic), and the live files'
+// offlineServes ignores the hold.
+func TestConsoleLiveEdgesChooseTheSameBox(t *testing.T) {
+ ctx := context.Background()
+ roster := map[string]boxchoice.RosterBox{fxBox: {Placement: "uk",
+ Cloud: boxid.Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-2", InstanceID: "i-0123456789abcdef0"}}}
+ type read struct {
+ cands []regionCandidate
+ chosen boxchoice.Candidate
+ serves bool
+ }
+ got := map[string]read{}
+ for name, body := range consoleVariants(t) {
+ s := edgesServer(t, body)
+ snap, err := s.liveEdges(ctx)
+ if err != nil || !snap.EvidenceComplete() || len(snap.Files) != 1 || !snap.Files[0].Fresh {
+ t.Fatalf("%s: liveEdges = %+v, %v", name, snap, err)
+ }
+ if direct, err := readLiveEdgesSnapshot(ctx, s.cfg.Releases, time.Now()); err != nil || !reflect.DeepEqual(direct, snap) {
+ t.Fatalf("%s: readLiveEdgesSnapshot = %+v, %v", name, direct, err)
+ }
+ r := read{cands: s.regionCands(snap), serves: offlineServes(snap.Files[0], fxHeld)}
+ cands := boxchoice.Eligible(snap.BoxChoice(), roster, "uk", "eu-west-2", "", s.expectedSecretsFP(), nil)
+ var ok bool
+ if r.chosen, ok = boxchoice.Choose(fxServed, cands, nil, "bab/shop"); !ok {
+ t.Fatalf("%s: no eligible box for uk: %+v", name, snap.BoxChoice())
+ }
+ got[name] = r
+ }
+ with := got["with"]
+ if with.chosen.BoxID != fxBox || with.serves {
+ t.Fatalf("with offline_moves: chose %+v, offlineServes(hold) %v", with.chosen, with.serves)
+ }
+ eligible := false
+ for _, c := range with.cands {
+ eligible = eligible || (c.Code == "uk" && c.Eligible && c.AWSRegion == "eu-west-2")
+ }
+ if !eligible {
+ t.Fatalf("region candidates %+v, want uk eligible in eu-west-2", with.cands)
+ }
+ if !reflect.DeepEqual(with, got["without"]) {
+ t.Fatalf("with offline_moves %+v, without %+v", with, got["without"])
+ }
+ if served := got["served"]; !served.serves || !reflect.DeepEqual(with.cands, served.cands) || with.chosen != served.chosen {
+ t.Fatalf("served: %+v; want offlineServes true and the same choice", served)
+ }
+}
+
+// The lifetimes box floor and its watch read capabilities only: annie
+// publishes lifetimes-1 and reports fresh, so the door accepts and the
+// watch pages nobody, whatever offline_moves says.
+func TestConsoleLifetimesFloorIsUnchangedByOfflineMoves(t *testing.T) {
+ old := lifetimesPage
+ pages := 0
+ lifetimesPage = func(context.Context, string, string, string) error { pages++; return nil }
+ t.Cleanup(func() { lifetimesPage = old })
+ for name, body := range consoleVariants(t) {
+ s := edgesServer(t, body)
+ if r := s.lifetimesBoxFloor(context.Background()); r != nil {
+ t.Fatalf("%s: lifetimesBoxFloor = %+v, want accepted", name, r)
+ }
+ s.lifetimesFloorWatch(context.Background(), time.Now())
+ if pages != 0 || len(s.floorPagedAt) != 0 {
+ t.Fatalf("%s: the floor watch paged %d times (%v)", name, pages, s.floorPagedAt)
+ }
+ }
+}
+
+// apidomains.go's read: the held alias is not "taken" by another tenant's
+// route, so the attach doors (deploymentRouteTaken under heldElsewhere)
+// admit it; the served route is taken. Counted as served, the hold would
+// take its own alias.
+func TestConsoleHeldAliasIsNotTaken(t *testing.T) {
+ ctx := context.Background()
+ vs := consoleVariants(t)
+ for _, name := range []string{"with", "without"} {
+ s := edgesServer(t, vs[name])
+ if s.deploymentRouteTaken(ctx, fxHeldAlias, "bab", "shop") || s.deploymentRouteTaken(ctx, fxHeld, "bab", "shop") {
+ t.Fatalf("%s: the hold's names read as another box's route", name)
+ }
+ if r := s.heldElsewhere(ctx, fxHeldAlias, "bab", "shop"); r != nil {
+ t.Fatalf("%s: heldElsewhere(%s) = %+v, want admitted", name, fxHeldAlias, r)
+ }
+ if !s.deploymentRouteTaken(ctx, fxServed, "bab", "shop") || s.heldElsewhere(ctx, fxServed, "bab", "shop") == nil {
+ t.Fatalf("%s: the served route must be taken (live evidence)", name)
+ }
+ }
+ if s := edgesServer(t, vs["served"]); !s.deploymentRouteTaken(ctx, fxHeldAlias, "bab", "shop") {
+ t.Fatal("test bug: counted as served, the hold must take its alias")
+ }
+}
+
+// The edge address fold (realFoldEdgeIPs under foldedEdgeIPs) is classified
+// unaffected: annie's address, whatever it hosts.
+func TestConsoleEdgeIPFoldIsUnchangedByOfflineMoves(t *testing.T) {
+ for name, body := range consoleVariants(t) {
+ s := edgesServer(t, body)
+ if got := s.realFoldEdgeIPs(context.Background()); !reflect.DeepEqual(got, []string{"1.2.3.4"}) {
+ t.Fatalf("%s: realFoldEdgeIPs = %v", name, got)
+ }
+ if got := s.foldedEdgeIPs(); !reflect.DeepEqual(got, []string{"1.2.3.4"}) {
+ t.Fatalf("%s: foldedEdgeIPs = %v", name, got)
+ }
+ }
+}
diff --git a/internal/domains/aliases_offline_moves_test.go b/internal/domains/aliases_offline_moves_test.go
new file mode 100644
index 000000000..c56465122
--- /dev/null
+++ b/internal/domains/aliases_offline_moves_test.go
@@ -0,0 +1,134 @@
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// The fixture three ways (self-serve moves plan 3, Task 4): as written,
+// without offline_moves, and with each holding entry's host and aliases
+// published as plain home routes (what counting the hold as served would
+// mean). edit sets box-level fields on all three alike, so the evidence
+// varies only in offline_moves. internal/edgesfile/offline_moves_test.go
+// holds the reference copy of this helper.
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxBox = "annie"
+ fxPath = "../edgesfile/testdata/offline-moves.json"
+)
+
+type holdVariants struct{ with, without, served []byte }
+
+func fixtureVariants(t *testing.T, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(fxPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ m[k], _ = json.Marshal(v)
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ len(holds[0].Aliases) != 1 || holds[0].Aliases[0] != fxHeldAlias {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ hosts[name], _ = json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// liveEdgesStore holds body as the box's edges file beside a fresh status
+// snapshot for the box.
+func liveEdgesStore(t *testing.T, body []byte) release.Store {
+ t.Helper()
+ st := release.NewDirStore(t.TempDir())
+ if _, err := st.Put(context.Background(), edgesfile.Key(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(context.Background(), st, status.Box{GeneratedAt: time.Now(), Label: fxBox}); err != nil {
+ t.Fatal(err)
+ }
+ return st
+}
+
+// ReconcileAliases through its DeploymentRoutes input, as the agent's
+// fastConvergeLoop and instancesLoop fold it: the held alias is not another
+// tenant's route, so the moving instance's alias claim materializes on the
+// box that now has its row, while the served route stays reserved. Counted
+// as served, the hold would refuse it.
+func TestReconcileAliasesDoesNotSeeTheHoldAsARoute(t *testing.T) {
+ v := fixtureVariants(t, nil)
+ materialized := func(body []byte) map[string]bool {
+ s := liveEdgesStore(t, body)
+ reg := aliasRegWith(t, registry.Route{Host: fxHeld, App: "shop", Account: "bab", Kind: "instance", Status: "ready"})
+ putClaim(t, s, Claim{Host: fxHeldAlias, Account: "bab", App: "shop", Route: fxHeld})
+ putClaim(t, s, Claim{Host: fxServed, Account: "bab", App: "shop", Route: fxHeld})
+ routes := edgesfile.DeploymentRoutes(context.Background(), s, time.Now())
+ if !routes[fxServed] {
+ t.Fatalf("test bug: the served route must be a deployment route (fresh status evidence): %v", routes)
+ }
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), routes, nil); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]bool{}
+ for _, a := range aliases {
+ out[a.Alias] = true
+ }
+ return out
+ }
+ with, without, served := materialized(v.with), materialized(v.without), materialized(v.served)
+ if !with[fxHeldAlias] || with[fxServed] {
+ t.Fatalf("with offline_moves: aliases %v, want %s materialized and %s refused (another box's route)", with, fxHeldAlias, fxServed)
+ }
+ if len(with) != len(without) || !without[fxHeldAlias] || without[fxServed] {
+ t.Fatalf("aliases with offline_moves %v, without %v", with, without)
+ }
+ if served[fxHeldAlias] {
+ t.Fatalf("test bug: counted as served, the hold must refuse the alias: %v", served)
+ }
+}
diff --git a/internal/edgesfile/audit_test.go b/internal/edgesfile/audit_test.go
new file mode 100644
index 000000000..a958841f0
--- /dev/null
+++ b/internal/edgesfile/audit_test.go
@@ -0,0 +1,963 @@
+package edgesfile
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "go/types"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "golang.org/x/tools/go/packages"
+)
+
+// The discovery check of self-serve moves plan 3, Task 4. The edges file now
+// carries offline_moves (hosts mid-move, Task 1's writer), and the Global
+// Constraint is that offline_moves is never a reason to count a host as
+// served. Every reader of the edges file is therefore inventoried below with
+// the test that asserts it over internal/edgesfile/testdata/offline-moves.json,
+// and this check finds the readers BY TYPE so that a new one cannot appear
+// un-inventoried. A read site is:
+//
+// (i) any use (a call or a function value) of an edgesfile reader:
+// Parse, ReadSnapshot, ReadLiveSnapshot, DeploymentRoutes, EdgeIPs,
+// LiveSnapshot;
+// (ii) any json.Unmarshal or (*json.Decoder).Decode whose target type,
+// after pointer, alias and named-type resolution (and through a
+// slice, array or map element and an embedded struct), has a
+// top-level field tagged json:"hosts" or json:"box_id", in any
+// package under internal/. A decode helper whose target is one of its
+// own interface-typed or type-parameter-typed parameters is followed
+// to its callers, so a site through such a helper is judged by the
+// caller's argument type;
+// (iii) a use, outside its own body, of a wrapper: a named function or
+// method with results whose body (outside any nested function
+// literal) holds a site of (i) or (ii). One level up from a decode,
+// except where offline_moves itself is passed on: a caller of a
+// wrapper whose results can carry offline_moves (a type with an
+// offline_moves field, by value or in a field) that itself returns
+// such a type is a wrapper too, to a fixpoint;
+// (iv) any use of a struct field tagged json:"offline_moves" (pinned to
+// the writer and the home map's candidate builder: nobody else may
+// read the holds at all).
+//
+// Callers further up consume a decision (a bool, a host set, a candidate)
+// that the named assertion already covers, and following them would
+// inventory every handler; the walk stops there on purpose.
+//
+// A site is keyed "<package>.<function> -> <what is read>", the package
+// relative to the module. Every site must be in edgesReadInventory (with the
+// test that asserts it) or have its target type in edgesReadExclusions (a
+// type with a hosts or box_id field that is not an edges file, with the
+// reason). Both lists are pinned: an entry the code no longer produces fails
+// too, so neither list can go stale.
+//
+// Known limits, stated: a call through an interface method or through a
+// function value stored in a field is not followed to its implementation
+// (the wrapper's own use where the value is taken IS a site of (iii)); data
+// passed DOWN as an argument is not followed (kind (iv) is what stops a
+// callee reading the holds); a decode into map[string]any or
+// json.RawMessage carries no type to judge; files are loaded for linux, so
+// a darwin- or windows-only file is not audited; test files are not
+// audited.
+
+const auditModule = "github.com/carlosframework/platform"
+
+// edgesReadInventory is every read site of the edges file under internal/,
+// each with the test that asserts, over the fixture, that offline_moves does
+// not make a host served there.
+//
+// A site of (iii) one level above a decode is covered by the assertion
+// over the decision it consumes, named here; kind (iv), a use of an
+// offline_moves field itself, has exactly two sites: the writer and the
+// home map's candidate builder.
+var edgesReadInventory = map[string]string{
+ // internal/edgesfile: the shared readers.
+ "internal/edgesfile.Parse -> json decode into internal/edgesfile.File": "internal/edgesfile.TestParseLeavesTheHoldOutOfHosts",
+ "internal/edgesfile.ReadSnapshot -> json decode into internal/edgesfile.PlacementView": "internal/edgesfile.TestReadSnapshotLeavesTheHoldOutOfHosts",
+ "internal/edgesfile.ReadLiveSnapshot -> edgesfile.ReadSnapshot": "internal/edgesfile.TestReadLiveSnapshotLeavesTheHoldOutOfHosts",
+ "internal/edgesfile.LiveSnapshot -> edgesfile.ReadLiveSnapshot": "internal/edgesfile.TestLiveSnapshotIsUnchangedByOfflineMoves",
+ "internal/edgesfile.EdgeIPs -> edgesfile.Parse": "internal/edgesfile.TestEdgeIPsIsUnchangedByOfflineMoves",
+ "internal/edgesfile.DeploymentRoutes -> edgesfile.Parse": "internal/edgesfile.TestDeploymentRoutesLeavesTheHoldOut",
+ "internal/edge.RunWithActivatorReady -> edgesfile.EdgeIPs": "internal/edgesfile.TestEdgeIPsIsUnchangedByOfflineMoves",
+ "internal/host.domainsLoop -> edgesfile.EdgeIPs": "internal/edgesfile.TestEdgeIPsIsUnchangedByOfflineMoves",
+ "internal/host.NewRelocationRuntime -> edgesfile.LiveSnapshot": "internal/edgesfile.TestLiveSnapshotIsUnchangedByOfflineMoves",
+ "internal/host.fastConvergeLoop -> edgesfile.DeploymentRoutes": "internal/domains.TestReconcileAliasesDoesNotSeeTheHoldAsARoute",
+ "internal/host.instancesLoop -> edgesfile.DeploymentRoutes": "internal/domains.TestReconcileAliasesDoesNotSeeTheHoldAsARoute",
+
+ // internal/console.
+ "internal/console.readEdgesSnapshot -> edgesfile.ReadSnapshot": "internal/console.TestConsolePlacementJoinLeavesTheHoldOut",
+ "internal/console.readEdgesFiles -> via internal/console.readEdgesSnapshot": "internal/console.TestConsolePlacementJoinLeavesTheHoldOut",
+ "internal/console.readLiveEdgesSnapshot -> edgesfile.ReadLiveSnapshot": "internal/console.TestConsoleLiveEdgesChooseTheSameBox",
+ "internal/console.(*Server).liveEdges -> via internal/console.readLiveEdgesSnapshot": "internal/console.TestConsoleLiveEdgesChooseTheSameBox",
+ "internal/console.(*Server).lifetimesBoxFloor -> via internal/console.readEdgesSnapshot": "internal/console.TestConsoleLifetimesFloorIsUnchangedByOfflineMoves",
+ "internal/console.(*Server).lifetimesFloorWatch -> via internal/console.readEdgesSnapshot": "internal/console.TestConsoleLifetimesFloorIsUnchangedByOfflineMoves",
+ "internal/console.(*Server).deploymentRouteTaken -> edgesfile.DeploymentRoutes": "internal/console.TestConsoleHeldAliasIsNotTaken",
+ "internal/console.(*Server).heldElsewhere -> via internal/console.(*Server).deploymentRouteTaken": "internal/console.TestConsoleHeldAliasIsNotTaken",
+ "internal/console.(*Server).realFoldEdgeIPs -> edgesfile.EdgeIPs": "internal/console.TestConsoleEdgeIPFoldIsUnchangedByOfflineMoves",
+ "internal/console.(*Server).foldedEdgeIPs -> via internal/console.(*Server).realFoldEdgeIPs": "internal/console.TestConsoleEdgeIPFoldIsUnchangedByOfflineMoves",
+
+ // internal/steering, which keeps its own decoder.
+ "internal/steering.parseEdgesFileView -> json decode into internal/steering.edgesFileView": "internal/steering.TestSteeringDesiredStateIgnoresTheHold",
+ "internal/steering.ConvergeDeployment -> via internal/steering.parseEdgesFileView": "internal/steering.TestSteeringDesiredStateIgnoresTheHold",
+
+ // internal/hostactors: the anonymous box-id reader.
+ "internal/hostactors.Directory.Referenced -> json decode into an anonymous struct": "internal/hostactors.TestReferencedIgnoresOfflineMoves",
+ "internal/hostactors.Directory.enroll -> via internal/hostactors.Directory.Referenced": "internal/hostactors.TestReferencedIgnoresOfflineMoves",
+
+ // internal/edge: the home map (its own decoder). Asserted from
+ // internal/host through FoldedHome, the exported fold.
+ "internal/edge.parseEdgesFile -> json decode into internal/edge.edgesFileView": "internal/host.TestHomeMapFoldServesTheRouteNotTheHold",
+ "internal/edge.FoldedHome -> via internal/edge.parseEdgesFile": "internal/host.TestHomeMapFoldServesTheRouteNotTheHold",
+ "internal/edge.(*homeMap).refreshHosts -> via internal/edge.parseEdgesFile": "internal/host.TestHomeMapFoldServesTheRouteNotTheHold",
+ "internal/edge.buildCandidates -> uses internal/edge.edgesFileView.offline_moves": "internal/edge.TestHoldingViewDecodesTheWriterFixture",
+
+ // internal/host: the writer's own shape, read back.
+ "internal/host.seedEdgesFileGate -> json decode into internal/host.edgesFile": "internal/host.TestEdgesFileGateRoundTripsOfflineMoves",
+ "internal/host.edgesFileLoop -> via internal/host.seedEdgesFileGate": "internal/host.TestEdgesFileGateRoundTripsOfflineMoves",
+ "internal/host.edgesFileWriteClaims -> json decode into internal/host.edgesFile": "internal/host.TestWriterPriorPublicationRecoveryIgnoresTheHold",
+ "internal/host.edgesFileWriteClaims -> uses internal/host.edgesFile.offline_moves": "internal/host.TestHoldingFixtureIsTheWritersOutput",
+ "internal/host.edgesFileLoop -> via internal/host.edgesFileWriteClaims": "internal/host.TestWriterPriorPublicationRecoveryIgnoresTheHold",
+ "internal/host.edgesFileWrite -> via internal/host.edgesFileWriteClaims": "internal/host.TestWriterPriorPublicationRecoveryIgnoresTheHold",
+ "internal/host.(*landWorld).publishErr -> via internal/host.edgesFileWriteClaims": "internal/host.TestWriterPriorPublicationRecoveryIgnoresTheHold",
+ "internal/host.(*movesWorker).offlineTargetReady -> json decode into internal/host.edgesFile": "internal/host.TestOfflineTargetReadyReadsOnlyBoxFields",
+ "internal/host.(*movesWorker).offlineReadyReport -> via internal/host.(*movesWorker).offlineTargetReady": "internal/host.TestOfflineTargetReadyReadsOnlyBoxFields",
+ "internal/host.(*movesWorker).writeReturnReport -> via internal/host.(*movesWorker).offlineTargetReady": "internal/host.TestOfflineTargetReadyReadsOnlyBoxFields",
+ "internal/host.loadMoveFleet -> json decode into internal/host.edgesFile": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.movesPass -> via internal/host.loadMoveFleet": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.(*movesWorker).offlineTarget -> via internal/host.loadMoveFleet": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.(*movesWorker).offlineTakeSeal -> via internal/host.(*movesWorker).offlineTarget": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.(*movesWorker).offlineStageRound -> via internal/host.(*movesWorker).offlineTarget": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.(*movesWorker).offlineSealRevert -> via internal/host.(*movesWorker).offlineTarget": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.(*rollbackRun).returning -> via internal/host.loadMoveFleet": "internal/host.TestMoveFleetIsUnchangedByOfflineMoves",
+ "internal/host.moveTargets -> json decode into internal/host.edgesFile": "internal/host.TestMoveTargetsAreUnchangedByOfflineMoves",
+ "internal/host.movesPass -> via internal/host.moveTargets": "internal/host.TestMoveTargetsAreUnchangedByOfflineMoves",
+}
+
+// edgesReadExclusions is every type with a top-level hosts or box_id field
+// that IS decoded somewhere under internal/ but is not an edges file.
+var edgesReadExclusions = map[string]string{
+ "internal/dbverify.Record": "a hibernation verify run's per-host verdicts, read from its own verify key",
+ "internal/hostops.BackfillPlan": "the claim-activation backfill plan inside a signed root envelope",
+ "internal/hostops.HostsQuarantine": "the backfill's quarantined-host list, its own control object",
+ "internal/hostops.PurgeJournal": "a purge operation's journal of the hosts it retires",
+ "internal/moves.OfflineReport": "an offline move participant's report (box_id names the reporter), not a publication of served hosts",
+ "internal/release.Generations": "the release pins' per-host generation map",
+ "internal/release.HistoryReconstruction": "a root history reconstruction's per-host entries, inside a signed root envelope",
+ "internal/release.historyApp": "one app's root history entry (the hosts it was minted for)",
+}
+
+// auditFixtureDir holds the fixture packages that prove the detector. They
+// live under testdata, so `go build ./...` and `go vet ./...` never see them;
+// the audit loads them by path.
+const auditFixtureDir = "internal/edgesfile/testdata/audit"
+
+var auditFixturePackages = []string{"unrelated", "boxid", "wrapper", "alias", "helper", "holds"}
+
+type auditLoad struct {
+ root string
+ pkgs []*packages.Package
+ took time.Duration
+ err error
+}
+
+var (
+ auditOnce sync.Once
+ auditData auditLoad
+)
+
+// loadAudited loads ./internal/... and the fixture packages once, in one
+// packages.Load, for both tests below.
+func loadAudited(t *testing.T) auditLoad {
+ t.Helper()
+ auditOnce.Do(func() {
+ root, err := filepath.Abs(filepath.Join("..", ".."))
+ if err != nil {
+ auditData.err = err
+ return
+ }
+ auditData.root = root
+ patterns := []string{"./internal/..."}
+ for _, p := range auditFixturePackages {
+ patterns = append(patterns, "./"+auditFixtureDir+"/"+p)
+ }
+ cfg := &packages.Config{
+ Dir: root,
+ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports,
+ Env: append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false"),
+ }
+ start := time.Now()
+ pkgs, err := packages.Load(cfg, patterns...)
+ auditData.took = time.Since(start)
+ if err != nil {
+ auditData.err = err
+ return
+ }
+ var msgs []string
+ packages.Visit(pkgs, nil, func(p *packages.Package) {
+ for _, e := range p.Errors {
+ msgs = append(msgs, e.Error())
+ }
+ })
+ if len(msgs) > 0 {
+ auditData.err = fmt.Errorf("loading the audited packages failed:\n%s", strings.Join(msgs, "\n"))
+ return
+ }
+ auditData.pkgs = pkgs
+ })
+ if auditData.err != nil {
+ t.Fatal(auditData.err)
+ }
+ return auditData
+}
+
+// readSite is one finding.
+type readSite struct {
+ key string // "<pkg>.<func> -> <what>"
+ pos token.Position
+ kind string // "i", "ii", "iii", "iv"
+}
+
+type edgesAudit struct {
+ exclusions map[string]string
+ sites []readSite
+ // excludedSeen counts, per excluded type, the decodes it excused.
+ excludedSeen map[string]int
+}
+
+var edgesfileReaders = map[string]bool{
+ "Parse": true, "ReadSnapshot": true, "ReadLiveSnapshot": true,
+ "DeploymentRoutes": true, "EdgeIPs": true, "LiveSnapshot": true,
+}
+
+func isEdgesfileReader(obj types.Object) bool {
+ fn, ok := obj.(*types.Func)
+ return ok && fn.Pkg() != nil && fn.Pkg().Path() == auditModule+"/internal/edgesfile" &&
+ edgesfileReaders[fn.Name()] && fn.Type().(*types.Signature).Recv() == nil
+}
+
+func relPkg(path string) string {
+ if path == auditModule {
+ return "."
+ }
+ return strings.TrimPrefix(path, auditModule+"/")
+}
+
+// funcName is the key's function part for a declaration.
+func funcName(pkg *types.Package, d *ast.FuncDecl) string {
+ name := d.Name.Name
+ if d.Recv != nil && len(d.Recv.List) == 1 {
+ rt := d.Recv.List[0].Type
+ star := ""
+ if s, ok := rt.(*ast.StarExpr); ok {
+ star, rt = "*", s.X
+ }
+ if ix, ok := rt.(*ast.IndexExpr); ok {
+ rt = ix.X
+ }
+ if ix, ok := rt.(*ast.IndexListExpr); ok {
+ rt = ix.X
+ }
+ if id, ok := rt.(*ast.Ident); ok {
+ if star != "" {
+ name = "(*" + id.Name + ")." + name
+ } else {
+ name = id.Name + "." + name
+ }
+ }
+ }
+ return relPkg(pkg.Path()) + "." + name
+}
+
+func qualify(p *types.Package) string { return relPkg(p.Path()) }
+
+// edgesShaped reports whether t, resolved, has a top-level hosts or box_id
+// field, and the named type that carries it ("" for an anonymous struct).
+func edgesShaped(t types.Type, seen map[types.Type]bool) (bool, string) {
+ for {
+ t = types.Unalias(t)
+ if seen[t] {
+ return false, ""
+ }
+ seen[t] = true
+ switch u := t.(type) {
+ case *types.Pointer:
+ t = u.Elem()
+ continue
+ case *types.Slice:
+ t = u.Elem()
+ continue
+ case *types.Array:
+ t = u.Elem()
+ continue
+ case *types.Map:
+ t = u.Elem()
+ continue
+ case *types.Named:
+ st, ok := u.Underlying().(*types.Struct)
+ if !ok {
+ t = u.Underlying()
+ continue
+ }
+ if structShaped(st, seen) {
+ return true, types.TypeString(u, qualify)
+ }
+ return false, ""
+ case *types.Struct:
+ return structShaped(u, seen), ""
+ default:
+ return false, ""
+ }
+ }
+}
+
+func structShaped(st *types.Struct, seen map[types.Type]bool) bool {
+ for i := 0; i < st.NumFields(); i++ {
+ f := st.Field(i)
+ name, _, _ := strings.Cut(reflect.StructTag(st.Tag(i)).Get("json"), ",")
+ if name == "hosts" || name == "box_id" {
+ return true
+ }
+ if f.Embedded() && name == "" {
+ if ok, _ := edgesShaped(f.Type(), seen); ok {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// decodeTarget returns the target expression of a json.Unmarshal or a
+// (*json.Decoder).Decode call, or nil.
+func decodeTarget(info *types.Info, call *ast.CallExpr) ast.Expr {
+ sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr)
+ if !ok {
+ return nil
+ }
+ fn, ok := info.Uses[sel.Sel].(*types.Func)
+ if !ok || fn.Pkg() == nil || fn.Pkg().Path() != "encoding/json" {
+ return nil
+ }
+ sig := fn.Type().(*types.Signature)
+ switch {
+ case fn.Name() == "Unmarshal" && sig.Recv() == nil && len(call.Args) == 2:
+ return call.Args[1]
+ case fn.Name() == "Decode" && sig.Recv() != nil && len(call.Args) == 1:
+ return call.Args[0]
+ }
+ return nil
+}
+
+// fnUnit is one named function's body (or a package-level var's value).
+type fnUnit struct {
+ pkg *packages.Package
+ name string
+ obj types.Object // nil for a var initializer
+ body ast.Node
+ sig *types.Signature
+ param map[types.Object]int // parameter objects by index
+}
+
+// helperRef is a decode helper's target: a parameter by index, or a type
+// parameter by index.
+type helperRef struct {
+ param int // -1 if a type parameter
+ tparm int // -1 if a parameter
+}
+
+func runEdgesAudit(pkgs []*packages.Package, exclusions map[string]string) *edgesAudit {
+ a := &edgesAudit{exclusions: exclusions, excludedSeen: map[string]int{}}
+ var units []*fnUnit
+ for _, p := range pkgs {
+ for _, f := range p.Syntax {
+ for _, d := range f.Decls {
+ switch d := d.(type) {
+ case *ast.FuncDecl:
+ if d.Body == nil {
+ continue
+ }
+ obj := p.TypesInfo.Defs[d.Name]
+ u := &fnUnit{pkg: p, name: funcName(p.Types, d), obj: obj, body: d.Body, param: map[types.Object]int{}}
+ if fn, ok := obj.(*types.Func); ok {
+ u.sig = fn.Type().(*types.Signature)
+ for i := 0; i < u.sig.Params().Len(); i++ {
+ u.param[u.sig.Params().At(i)] = i
+ }
+ }
+ units = append(units, u)
+ case *ast.GenDecl:
+ for _, s := range d.Specs {
+ vs, ok := s.(*ast.ValueSpec)
+ if !ok || len(vs.Values) == 0 {
+ continue
+ }
+ for i, v := range vs.Values {
+ name := "_"
+ if i < len(vs.Names) {
+ name = vs.Names[i].Name
+ }
+ units = append(units, &fnUnit{pkg: p, name: relPkg(p.Types.Path()) + "." + name, body: v, param: map[types.Object]int{}})
+ }
+ }
+ }
+ }
+ }
+ }
+
+ type found struct {
+ u *fnUnit
+ site readSite
+ direct bool // outside any nested function literal
+ // carries: a (iii) site through a wrapper whose results can carry
+ // offline_moves, so the site's own function wraps it further.
+ carries bool
+ }
+ var direct []found
+ seenKey := map[string]bool{}
+ add := func(u *fnUnit, s readSite, top bool) {
+ direct = append(direct, found{u: u, site: s, direct: top})
+ }
+
+ // Pass 1: the decode helpers, to a fixpoint. A helper's target is one of
+ // its own parameters of interface type, or of a type-parameter type.
+ helpers := map[types.Object]helperRef{}
+ type pendingCall struct {
+ u *fnUnit
+ call *ast.CallExpr
+ top bool
+ }
+ var calls []pendingCall
+ for _, u := range units {
+ inspectTop(u.body, func(n ast.Node, top bool) {
+ if c, ok := n.(*ast.CallExpr); ok {
+ calls = append(calls, pendingCall{u, c, top})
+ }
+ })
+ }
+ asHelperTarget := func(u *fnUnit, e ast.Expr) (helperRef, bool) {
+ if u.sig == nil {
+ return helperRef{}, false
+ }
+ info := u.pkg.TypesInfo
+ e = ast.Unparen(e)
+ if id, ok := e.(*ast.Ident); ok {
+ if i, ok := u.param[info.Uses[id]]; ok {
+ if _, isIface := types.Unalias(u.sig.Params().At(i).Type()).Underlying().(*types.Interface); isIface {
+ if _, isTP := types.Unalias(u.sig.Params().At(i).Type()).(*types.TypeParam); !isTP {
+ return helperRef{param: i, tparm: -1}, true
+ }
+ }
+ }
+ }
+ t := types.Unalias(info.TypeOf(e))
+ if p, ok := t.(*types.Pointer); ok {
+ t = types.Unalias(p.Elem())
+ }
+ if tp, ok := t.(*types.TypeParam); ok && u.sig.TypeParams() != nil {
+ for i := 0; i < u.sig.TypeParams().Len(); i++ {
+ if u.sig.TypeParams().At(i) == tp {
+ return helperRef{param: -1, tparm: i}, true
+ }
+ }
+ }
+ return helperRef{}, false
+ }
+ // calleeOf resolves a call's static callee and its instance type args.
+ calleeOf := func(info *types.Info, c *ast.CallExpr) (types.Object, *types.TypeList) {
+ fun := ast.Unparen(c.Fun)
+ if ix, ok := fun.(*ast.IndexExpr); ok {
+ fun = ix.X
+ }
+ if ix, ok := fun.(*ast.IndexListExpr); ok {
+ fun = ix.X
+ }
+ var id *ast.Ident
+ switch f := fun.(type) {
+ case *ast.Ident:
+ id = f
+ case *ast.SelectorExpr:
+ id = f.Sel
+ default:
+ return nil, nil
+ }
+ obj := info.Uses[id]
+ if fn, ok := obj.(*types.Func); ok {
+ obj = fn.Origin()
+ }
+ return obj, info.Instances[id].TypeArgs
+ }
+ for changed := true; changed; {
+ changed = false
+ for _, pc := range calls {
+ if _, done := helpers[pc.u.obj]; done || pc.u.obj == nil {
+ continue
+ }
+ var target ast.Expr
+ if tgt := decodeTarget(pc.u.pkg.TypesInfo, pc.call); tgt != nil {
+ target = tgt
+ } else if obj, _ := calleeOf(pc.u.pkg.TypesInfo, pc.call); obj != nil {
+ if h, ok := helpers[obj]; ok && h.param >= 0 && h.param < len(pc.call.Args) {
+ target = pc.call.Args[h.param]
+ }
+ }
+ if target == nil {
+ continue
+ }
+ if h, ok := asHelperTarget(pc.u, target); ok {
+ helpers[pc.u.obj] = h
+ changed = true
+ }
+ }
+ }
+
+ judge := func(u *fnUnit, t types.Type, what string, pos token.Pos, top bool) {
+ ok, named := edgesShaped(t, map[types.Type]bool{})
+ if !ok {
+ return
+ }
+ if _, excluded := a.exclusions[named]; excluded && named != "" {
+ a.excludedSeen[named]++
+ return
+ }
+ typ := named
+ if typ == "" {
+ typ = "an anonymous struct"
+ }
+ add(u, readSite{key: u.name + " -> " + what + " into " + typ, pos: u.pkg.Fset.Position(pos), kind: "ii"}, top)
+ }
+
+ // Pass 2: (i) and (ii).
+ for _, u := range units {
+ info := u.pkg.TypesInfo
+ inspectTop(u.body, func(n ast.Node, top bool) {
+ switch n := n.(type) {
+ case *ast.Ident:
+ fn, ok := info.Uses[n].(*types.Func)
+ if !ok || !isEdgesfileReader(fn) {
+ return
+ }
+ add(u, readSite{key: u.name + " -> edgesfile." + fn.Name(), pos: u.pkg.Fset.Position(n.Pos()), kind: "i"}, top)
+ case *ast.SelectorExpr:
+ // (iv): a use of a field tagged json:"offline_moves".
+ sel := info.Selections[n]
+ if sel == nil || sel.Kind() != types.FieldVal {
+ return
+ }
+ if owner, tag := fieldTag(sel.Recv(), sel.Index()); tag == "offline_moves" {
+ add(u, readSite{key: u.name + " -> uses " + owner + ".offline_moves", pos: u.pkg.Fset.Position(n.Sel.Pos()), kind: "iv"}, top)
+ }
+ case *ast.CallExpr:
+ if tgt := decodeTarget(info, n); tgt != nil {
+ if _, isHelper := asHelperTarget(u, tgt); isHelper {
+ return // judged at the helper's callers
+ }
+ judge(u, info.TypeOf(tgt), "json decode", n.Pos(), top)
+ return
+ }
+ obj, targs := calleeOf(info, n)
+ h, ok := helpers[obj]
+ if !ok {
+ return
+ }
+ if _, isHelper := helpers[u.obj]; isHelper && u.obj != nil {
+ // u is itself a helper on this argument: judged further out.
+ if h.param >= 0 && h.param < len(n.Args) {
+ if _, ok := asHelperTarget(u, n.Args[h.param]); ok {
+ return
+ }
+ }
+ }
+ what := "json decode via " + objName(obj)
+ switch {
+ case h.param >= 0 && h.param < len(n.Args):
+ judge(u, info.TypeOf(n.Args[h.param]), what, n.Pos(), top)
+ case h.tparm >= 0 && targs != nil && h.tparm < targs.Len():
+ judge(u, targs.At(h.tparm), what, n.Pos(), top)
+ }
+ }
+ })
+ }
+
+ // Pass 3: (iii). A wrapper is a named function with results whose body,
+ // outside nested function literals, holds a site of (i) or (ii). Its
+ // uses elsewhere are sites. The walk goes one level up from a decode
+ // and stops there, except where offline_moves itself is passed on: a
+ // caller of a wrapper whose results can carry offline_moves (a type
+ // with an offline_moves field, by value or in a field), which itself
+ // returns such a type, is a wrapper too, to a fixpoint.
+ wrappers := map[types.Object]bool{}
+ scanned := 0
+ have := map[string]bool{}
+ for {
+ for _, f := range direct[scanned:] {
+ if !f.direct || f.u.sig == nil || f.u.sig.Results().Len() == 0 {
+ continue
+ }
+ if f.site.kind == "iv" {
+ continue // a field use is not a read of the file
+ }
+ if f.site.kind == "iii" && !(f.carries && resultsCarryOffline(f.u.sig)) {
+ continue // a decision, not the holds passed on
+ }
+ wrappers[f.u.obj] = true
+ }
+ scanned = len(direct)
+ for _, u := range units {
+ info := u.pkg.TypesInfo
+ inspectTop(u.body, func(n ast.Node, top bool) {
+ id, ok := n.(*ast.Ident)
+ if !ok {
+ return
+ }
+ obj := info.Uses[id]
+ if fn, ok := obj.(*types.Func); ok {
+ obj = fn.Origin()
+ }
+ if !wrappers[obj] || obj == u.obj || isEdgesfileReader(obj) {
+ return // a reader's own use is already a site of (i)
+ }
+ key := u.name + " -> via " + objName(obj)
+ if have[key] {
+ return
+ }
+ have[key] = true
+ carries := false
+ if fn, ok := obj.(*types.Func); ok {
+ carries = resultsCarryOffline(fn.Type().(*types.Signature))
+ }
+ add(u, readSite{key: key, pos: u.pkg.Fset.Position(id.Pos()), kind: "iii"}, top)
+ direct[len(direct)-1].carries = carries
+ })
+ }
+ if len(direct) == scanned {
+ break
+ }
+ }
+
+ for _, f := range direct {
+ if seenKey[f.site.key] {
+ continue
+ }
+ seenKey[f.site.key] = true
+ a.sites = append(a.sites, f.site)
+ }
+ sort.Slice(a.sites, func(i, j int) bool { return a.sites[i].key < a.sites[j].key })
+ return a
+}
+
+// fieldTag follows a field selection's index path from recv and returns
+// the struct type that declares the selected field (its name, or "an
+// anonymous struct") and the field's json name.
+func fieldTag(recv types.Type, index []int) (string, string) {
+ t := recv
+ owner, name := "", ""
+ for _, i := range index {
+ t = types.Unalias(t)
+ if p, ok := t.(*types.Pointer); ok {
+ t = types.Unalias(p.Elem())
+ }
+ owner = "an anonymous struct"
+ if n, ok := t.(*types.Named); ok {
+ owner = types.TypeString(n, qualify)
+ }
+ st, ok := t.Underlying().(*types.Struct)
+ if !ok || i >= st.NumFields() {
+ return "", ""
+ }
+ name, _, _ = strings.Cut(reflect.StructTag(st.Tag(i)).Get("json"), ",")
+ t = st.Field(i).Type()
+ }
+ return owner, name
+}
+
+// offlineShaped reports whether t, resolved as edgesShaped resolves it,
+// has a top-level field tagged json:"offline_moves".
+func offlineShaped(t types.Type, seen map[types.Type]bool) bool {
+ for {
+ t = types.Unalias(t)
+ if seen[t] {
+ return false
+ }
+ seen[t] = true
+ switch u := t.(type) {
+ case *types.Pointer:
+ t = u.Elem()
+ case *types.Slice:
+ t = u.Elem()
+ case *types.Array:
+ t = u.Elem()
+ case *types.Map:
+ t = u.Elem()
+ case *types.Named:
+ t = u.Underlying()
+ case *types.Struct:
+ for i := 0; i < u.NumFields(); i++ {
+ name, _, _ := strings.Cut(reflect.StructTag(u.Tag(i)).Get("json"), ",")
+ if name == "offline_moves" {
+ return true
+ }
+ if u.Field(i).Embedded() && name == "" && offlineShaped(u.Field(i).Type(), seen) {
+ return true
+ }
+ }
+ return false
+ default:
+ return false
+ }
+ }
+}
+
+// resultsCarryOffline reports whether any of sig's results is, or has a
+// field that is, offline-shaped.
+func resultsCarryOffline(sig *types.Signature) bool {
+ for i := 0; i < sig.Results().Len(); i++ {
+ t := sig.Results().At(i).Type()
+ if offlineShaped(t, map[types.Type]bool{}) {
+ return true
+ }
+ if st, ok := types.Unalias(t).Underlying().(*types.Struct); ok {
+ for j := 0; j < st.NumFields(); j++ {
+ if offlineShaped(st.Field(j).Type(), map[types.Type]bool{}) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+// objName names a function object as the keys do.
+func objName(obj types.Object) string {
+ fn, ok := obj.(*types.Func)
+ if !ok || fn.Pkg() == nil {
+ return obj.Name()
+ }
+ sig := fn.Type().(*types.Signature)
+ if sig.Recv() == nil {
+ return relPkg(fn.Pkg().Path()) + "." + fn.Name()
+ }
+ rt := types.Unalias(sig.Recv().Type())
+ star := ""
+ if p, ok := rt.(*types.Pointer); ok {
+ star, rt = "*", types.Unalias(p.Elem())
+ }
+ if n, ok := rt.(*types.Named); ok {
+ if star != "" {
+ return relPkg(fn.Pkg().Path()) + ".(*" + n.Obj().Name() + ")." + fn.Name()
+ }
+ return relPkg(fn.Pkg().Path()) + "." + n.Obj().Name() + "." + fn.Name()
+ }
+ return relPkg(fn.Pkg().Path()) + "." + fn.Name()
+}
+
+// inspectTop walks n, telling the visitor whether each node lies outside
+// every nested function literal.
+func inspectTop(n ast.Node, visit func(ast.Node, bool)) {
+ var walk func(ast.Node, bool)
+ walk = func(n ast.Node, top bool) {
+ ast.Inspect(n, func(m ast.Node) bool {
+ if m == nil {
+ return false
+ }
+ if lit, ok := m.(*ast.FuncLit); ok && m != n {
+ walk(lit.Body, false)
+ return false
+ }
+ visit(m, top)
+ return true
+ })
+ }
+ walk(n, true)
+}
+
+func splitAudited(pkgs []*packages.Package) (real, fixtures []*packages.Package) {
+ for _, p := range pkgs {
+ if strings.Contains(p.PkgPath, "/testdata/") {
+ fixtures = append(fixtures, p)
+ continue
+ }
+ real = append(real, p)
+ }
+ return real, fixtures
+}
+
+// checkInventory compares an audit's sites with an inventory and its
+// exclusions: missing are sites in neither, stale are entries the code no
+// longer produces (an inventory key no site has, an exclusion no decode
+// used).
+func checkInventory(root string, a *edgesAudit, inventory, exclusions map[string]string) (missing, stale []string) {
+ found := map[string]bool{}
+ for _, s := range a.sites {
+ found[s.key] = true
+ if _, ok := inventory[s.key]; !ok {
+ rel, _ := filepath.Rel(root, s.pos.Filename)
+ missing = append(missing, fmt.Sprintf("%s:%d: (%s) %q", filepath.ToSlash(rel), s.pos.Line, s.kind, s.key))
+ }
+ }
+ for k := range inventory {
+ if !found[k] {
+ stale = append(stale, k)
+ }
+ }
+ for k := range exclusions {
+ if a.excludedSeen[k] == 0 {
+ stale = append(stale, "exclusion "+k)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ return missing, stale
+}
+
+// testFuncs lists the Test functions declared in a package directory's test
+// files.
+func testFuncs(t *testing.T, dir string) map[string]bool {
+ t.Helper()
+ out := map[string]bool{}
+ files, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fset := token.NewFileSet()
+ for _, f := range files {
+ af, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range af.Decls {
+ if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && strings.HasPrefix(fd.Name.Name, "Test") {
+ out[fd.Name.Name] = true
+ }
+ }
+ }
+ return out
+}
+
+func TestEveryEdgesFileReadSiteIsInventoried(t *testing.T) {
+ l := loadAudited(t)
+ start := time.Now()
+ real, _ := splitAudited(l.pkgs)
+ a := runEdgesAudit(real, edgesReadExclusions)
+ t.Logf("loaded and type-checked %d packages in %s; the audit took %s and found %d sites",
+ len(l.pkgs), l.took.Round(time.Millisecond), time.Since(start).Round(time.Millisecond), len(a.sites))
+ missing, stale := checkInventory(l.root, a, edgesReadInventory, edgesReadExclusions)
+ if len(missing) > 0 {
+ t.Errorf("%d edges-file read site(s) not in edgesReadInventory (assert over testdata/offline-moves.json that offline_moves does not make a host served there and list the site with its test, or exclude its type in edgesReadExclusions with a reason):\n%s", len(missing), strings.Join(missing, "\n"))
+ }
+ if len(stale) > 0 {
+ t.Errorf("%d pinned entr(ies) the code no longer produces (remove them):\n%s", len(stale), strings.Join(stale, "\n"))
+ }
+ // Every inventory entry names a test that exists.
+ tests := map[string]map[string]bool{}
+ var unknown []string
+ for key, test := range edgesReadInventory {
+ pkg, name, ok := strings.Cut(test, ".")
+ if !ok || !strings.HasPrefix(pkg, "internal/") {
+ unknown = append(unknown, fmt.Sprintf("%s: %q is not <package>.<Test>", key, test))
+ continue
+ }
+ if tests[pkg] == nil {
+ tests[pkg] = testFuncs(t, filepath.Join(l.root, filepath.FromSlash(pkg)))
+ }
+ if !tests[pkg][name] {
+ unknown = append(unknown, fmt.Sprintf("%s: no test %s", key, test))
+ }
+ }
+ sort.Strings(unknown)
+ if len(unknown) > 0 {
+ t.Errorf("inventory entries naming no test:\n%s", strings.Join(unknown, "\n"))
+ }
+ // Every exclusion has a reason (and, by the stale check above, excused
+ // at least one decode of an edges-shaped type).
+ for typ, reason := range edgesReadExclusions {
+ if strings.TrimSpace(reason) == "" {
+ t.Errorf("exclusion %s has no reason", typ)
+ }
+ }
+}
+
+// fixtureKey is a key in the fixture package p.
+func fixtureKey(p, rest string) string {
+ return auditFixtureDir + "/" + p + "." + rest
+}
+
+func TestEdgesFileReadDetectorFindsEveryShape(t *testing.T) {
+ l := loadAudited(t)
+ _, fixtures := splitAudited(l.pkgs)
+ var steering *packages.Package
+ for _, p := range l.pkgs {
+ if p.PkgPath == auditModule+"/internal/steering" {
+ steering = p
+ }
+ }
+ if steering == nil || len(fixtures) != len(auditFixturePackages) {
+ t.Fatalf("loaded %d fixture packages (want %d) and steering %v", len(fixtures), len(auditFixturePackages), steering != nil)
+ }
+ pkgs := append(fixtures, steering)
+ fx := auditFixtureDir + "/"
+ want := []string{
+ fixtureKey("alias", "ViaAlias -> json decode into "+fx+"alias.view"),
+ fixtureKey("alias", "ViaEmbedded -> json decode into "+fx+"alias.outer"),
+ fixtureKey("alias", "ViaNamed -> json decode into "+fx+"alias.named"),
+ fixtureKey("alias", "ViaSlice -> json decode into "+fx+"alias.view"),
+ fixtureKey("boxid", "Names -> json decode into an anonymous struct"),
+ fixtureKey("helper", "Generic -> json decode via "+fx+"helper.decodeAs into "+fx+"helper.edges"),
+ fixtureKey("helper", "Through -> json decode via "+fx+"helper.decodeInto into "+fx+"helper.edges"),
+ fixtureKey("holds", "Count -> uses "+fx+"holds.file.offline_moves"),
+ fixtureKey("holds", "Top -> via "+fx+"holds.pass"),
+ fixtureKey("holds", "pass -> via "+fx+"holds.read"),
+ fixtureKey("holds", "read -> json decode into "+fx+"holds.file"),
+ fixtureKey("unrelated", "ReadRoster -> json decode into "+fx+"unrelated.Roster"),
+ fixtureKey("unrelated", "Served -> edgesfile.Parse"),
+ fixtureKey("wrapper", "Chooser -> edgesfile.LiveSnapshot"),
+ fixtureKey("wrapper", "Taken -> via "+fx+"wrapper.routes"),
+ fixtureKey("wrapper", "routes -> edgesfile.DeploymentRoutes"),
+ "internal/steering.ConvergeDeployment -> via internal/steering.parseEdgesFileView",
+ "internal/steering.parseEdgesFileView -> json decode into internal/steering.edgesFileView",
+ }
+ sort.Strings(want)
+
+ // With nothing inventoried, every shape is flagged.
+ a := runEdgesAudit(pkgs, nil)
+ var got []string
+ for _, s := range a.sites {
+ got = append(got, s.key)
+ }
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("flagged:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+
+ // The unrelated hosts struct beside an inventoried read is flagged until
+ // its type is excluded; then it is not, and the exclusion is used.
+ inv := map[string]string{fixtureKey("unrelated", "Served -> edgesfile.Parse"): "fixture"}
+ unrelated := []*packages.Package{}
+ for _, p := range fixtures {
+ if strings.HasSuffix(p.PkgPath, "/unrelated") {
+ unrelated = append(unrelated, p)
+ }
+ }
+ missing, stale := checkInventory(l.root, runEdgesAudit(unrelated, nil), inv, nil)
+ if len(missing) != 1 || !strings.Contains(missing[0], "ReadRoster") || len(stale) != 0 {
+ t.Fatalf("unexcluded: missing %v, stale %v; want ReadRoster flagged", missing, stale)
+ }
+ excl := map[string]string{fx + "unrelated.Roster": "a host list, not an edges file"}
+ missing, stale = checkInventory(l.root, runEdgesAudit(unrelated, excl), inv, excl)
+ if len(missing) != 0 || len(stale) != 0 {
+ t.Fatalf("excluded: missing %v, stale %v; want neither", missing, stale)
+ }
+
+ // Both lists are pinned: an entry the code does not produce is stale.
+ inv[fixtureKey("unrelated", "Gone -> edgesfile.Parse")] = "fixture"
+ excl[fx+"unrelated.Gone"] = "no such type"
+ _, stale = checkInventory(l.root, runEdgesAudit(unrelated, excl), inv, excl)
+ if strings.Join(stale, "|") != "exclusion "+fx+"unrelated.Gone|"+fixtureKey("unrelated", "Gone -> edgesfile.Parse") {
+ t.Fatalf("stale = %v, want the removed inventory entry and exclusion", stale)
+ }
+}
diff --git a/internal/edgesfile/offline_moves_test.go b/internal/edgesfile/offline_moves_test.go
new file mode 100644
index 000000000..325f3fc01
--- /dev/null
+++ b/internal/edgesfile/offline_moves_test.go
@@ -0,0 +1,233 @@
+package edgesfile
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "slices"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// Self-serve moves plan 3, Task 4: offline_moves is never a reason to count
+// a host as served. Each reader below is asserted over
+// testdata/offline-moves.json (the writer's own output: one served route,
+// one holding entry for another host with one alias, one certificate-only
+// wildcard entry) three ways, with identical liveness and capability
+// evidence: as written, without offline_moves, and with the held host and
+// its alias published as plain served routes (what counting the hold would
+// mean). A reader that ignores the hold gives the first two the same answer;
+// where the reader's answer depends on hosts at all, the third differs, so
+// the assertion would fail if the hold were counted.
+
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxCertOnly = "*.certonly.example.net"
+ fxBox, fxEIP = "annie", "1.2.3.4"
+ fixturePathFx = "testdata/offline-moves.json"
+)
+
+// holdVariants is the fixture as written (with), without offline_moves, and
+// with every holding entry's host and aliases published as plain home routes
+// (served).
+type holdVariants struct{ with, without, served []byte }
+
+// fixtureVariants reads the fixture, sets edit's top-level fields on all
+// three variants alike (box-level evidence: a placement, a region), and
+// returns them.
+func fixtureVariants(t *testing.T, path string, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m[k] = b
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ !slices.Equal(holds[0].Aliases, []string{fxHeldAlias}) {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ b, _ := json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ hosts[name] = b
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// liveStore is a store holding body as the box's edges file and a fresh
+// status snapshot for the box.
+func liveStore(t *testing.T, body []byte) release.Store {
+ t.Helper()
+ st := release.NewDirStore(t.TempDir())
+ if _, err := st.Put(context.Background(), Key(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(context.Background(), st, status.Box{GeneratedAt: time.Now(), Label: fxBox}); err != nil {
+ t.Fatal(err)
+ }
+ return st
+}
+
+func hostKeys[V any](m map[string]V) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// wantServedSet checks one reader's host set: the served route and the
+// certificate-only entry as the reader keeps them (keep), never the hold or
+// its alias.
+func wantServedSet(t *testing.T, what string, got []string, keep ...string) {
+ t.Helper()
+ sort.Strings(keep)
+ if !slices.Equal(got, keep) {
+ t.Fatalf("%s = %v, want %v (the hold %s and its alias %s are never served)", what, got, keep, fxHeld, fxHeldAlias)
+ }
+}
+
+func TestParseLeavesTheHoldOutOfHosts(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, nil)
+ with, err := Parse(v.with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ without, _ := Parse(v.without)
+ served, _ := Parse(v.served)
+ wantServedSet(t, "File.Hosts", hostKeys(with.Hosts), fxServed, fxCertOnly)
+ if !reflect.DeepEqual(with, without) {
+ t.Fatalf("Parse with offline_moves = %+v, without = %+v", with, without)
+ }
+ if reflect.DeepEqual(with, served) {
+ t.Fatal("test bug: the served variant must read differently")
+ }
+}
+
+func TestReadSnapshotLeavesTheHoldOutOfHosts(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, nil)
+ read := func(body []byte) Snapshot {
+ snap, err := ReadSnapshot(context.Background(), liveStore(t, body))
+ if err != nil || !snap.Complete || len(snap.Files) != 1 {
+ t.Fatalf("ReadSnapshot = %+v, %v", snap, err)
+ }
+ return snap
+ }
+ with, without, served := read(v.with), read(v.without), read(v.served)
+ wantServedSet(t, "PlacementView.Hosts", hostKeys(with.Files[0].Hosts), fxServed, fxCertOnly)
+ if !reflect.DeepEqual(with, without) {
+ t.Fatalf("ReadSnapshot with offline_moves = %+v, without = %+v", with, without)
+ }
+ if reflect.DeepEqual(hostKeys(with.Files[0].Hosts), hostKeys(served.Files[0].Hosts)) {
+ t.Fatal("test bug: the served variant must read differently")
+ }
+}
+
+func TestReadLiveSnapshotLeavesTheHoldOutOfHosts(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, nil)
+ read := func(body []byte) Snapshot {
+ snap, err := ReadLiveSnapshot(context.Background(), liveStore(t, body), time.Now())
+ if err != nil || !snap.EvidenceComplete() || len(snap.Files) != 1 || !snap.Files[0].Fresh {
+ t.Fatalf("ReadLiveSnapshot = %+v, %v", snap, err)
+ }
+ return snap
+ }
+ with, without, served := read(v.with), read(v.without), read(v.served)
+ wantServedSet(t, "PlacementView.Hosts", hostKeys(with.Files[0].Hosts), fxServed, fxCertOnly)
+ if !reflect.DeepEqual(with, without) {
+ t.Fatalf("ReadLiveSnapshot with offline_moves = %+v, without = %+v", with, without)
+ }
+ if reflect.DeepEqual(hostKeys(with.Files[0].Hosts), hostKeys(served.Files[0].Hosts)) {
+ t.Fatal("test bug: the served variant must read differently")
+ }
+}
+
+// LiveSnapshot is classified unaffected: boxchoice's snapshot carries no
+// host at all, so it is the same whether the hold is absent, present or
+// even counted.
+func TestLiveSnapshotIsUnchangedByOfflineMoves(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, map[string]any{"placement": "uk", "region": "eu-west-2", "capabilities": []string{"region-ha-1"}})
+ read := func(body []byte) any {
+ snap, err := LiveSnapshot(context.Background(), liveStore(t, body), time.Now())
+ if err != nil || !snap.Complete || len(snap.Boxes) != 1 || snap.Boxes[0].BoxID != fxBox || !snap.Boxes[0].Fresh {
+ t.Fatalf("LiveSnapshot = %+v, %v", snap, err)
+ }
+ return snap
+ }
+ with := read(v.with)
+ if !reflect.DeepEqual(with, read(v.without)) || !reflect.DeepEqual(with, read(v.served)) {
+ t.Fatal("LiveSnapshot changed with offline_moves")
+ }
+}
+
+// EdgeIPs is classified unaffected: a live box's address, whatever it
+// hosts.
+func TestEdgeIPsIsUnchangedByOfflineMoves(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, nil)
+ for name, body := range map[string][]byte{"with": v.with, "without": v.without, "served": v.served} {
+ got := EdgeIPs(context.Background(), liveStore(t, body), []string{"5.6.7.8"}, time.Now())
+ if !slices.Equal(got, []string{"5.6.7.8", fxEIP}) {
+ t.Fatalf("%s: EdgeIPs = %v, want the configured address and the live box's", name, got)
+ }
+ }
+}
+
+// DeploymentRoutes, with fresh status evidence: the served route is a
+// deployment route, the hold and its alias are not, so a claim on the held
+// alias is not refused as another tenant's route.
+func TestDeploymentRoutesLeavesTheHoldOut(t *testing.T) {
+ v := fixtureVariants(t, fixturePathFx, nil)
+ with := DeploymentRoutes(context.Background(), liveStore(t, v.with), time.Now())
+ without := DeploymentRoutes(context.Background(), liveStore(t, v.without), time.Now())
+ served := DeploymentRoutes(context.Background(), liveStore(t, v.served), time.Now())
+ wantServedSet(t, "DeploymentRoutes", hostKeys(with), fxServed)
+ if !reflect.DeepEqual(with, without) {
+ t.Fatalf("DeploymentRoutes with offline_moves = %v, without = %v", with, without)
+ }
+ if !served[fxHeld] || !served[fxHeldAlias] {
+ t.Fatalf("test bug: the served variant must make both names routes: %v", served)
+ }
+}
diff --git a/internal/edgesfile/testdata/audit/alias/alias.go b/internal/edgesfile/testdata/audit/alias/alias.go
new file mode 100644
index 000000000..acf2ccd8c
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/alias/alias.go
@@ -0,0 +1,50 @@
+// Package alias is a fixture of the edges-file read audit: decodes whose
+// target reaches a hosts field through a type alias, a named wrapper, an
+// embedded struct and a slice.
+package alias
+
+import (
+ "encoding/json"
+)
+
+type view struct {
+ V int `json:"v"`
+ Hosts map[string]json.RawMessage `json:"hosts"`
+}
+
+type aliased = view
+
+type named view
+
+type outer struct {
+ view
+ Extra string `json:"extra"`
+}
+
+// ViaAlias decodes through the alias.
+func ViaAlias(body []byte) int {
+ var f aliased
+ _ = json.Unmarshal(body, &f)
+ return len(f.Hosts)
+}
+
+// ViaNamed decodes through a named type over the struct.
+func ViaNamed(body []byte) int {
+ f := new(named)
+ _ = json.Unmarshal(body, f)
+ return len(f.Hosts)
+}
+
+// ViaEmbedded decodes a struct that embeds it.
+func ViaEmbedded(body []byte) int {
+ var f outer
+ _ = json.Unmarshal(body, &f)
+ return len(f.Hosts)
+}
+
+// ViaSlice decodes a list of them.
+func ViaSlice(body []byte) int {
+ var fs []*view
+ _ = json.Unmarshal(body, &fs)
+ return len(fs)
+}
diff --git a/internal/edgesfile/testdata/audit/boxid/boxid.go b/internal/edgesfile/testdata/audit/boxid/boxid.go
new file mode 100644
index 000000000..cdef3a657
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/boxid/boxid.go
@@ -0,0 +1,19 @@
+// Package boxid is a fixture of the edges-file read audit: an anonymous
+// struct that reads only box_id, as internal/hostactors/references.go does.
+package boxid
+
+import (
+ "bytes"
+ "encoding/json"
+)
+
+// Names reports whether body names reference as its box id.
+func Names(body []byte, reference string) bool {
+ var edge struct {
+ BoxID string `json:"box_id"`
+ }
+ if json.NewDecoder(bytes.NewReader(body)).Decode(&edge) != nil {
+ return false
+ }
+ return edge.BoxID == reference
+}
diff --git a/internal/edgesfile/testdata/audit/helper/helper.go b/internal/edgesfile/testdata/audit/helper/helper.go
new file mode 100644
index 000000000..0c2b5e9c6
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/helper/helper.go
@@ -0,0 +1,45 @@
+// Package helper is a fixture of the edges-file read audit: a decode helper
+// whose target is its own parameter, judged at its callers.
+package helper
+
+import (
+ "encoding/json"
+)
+
+type edges struct {
+ Box string `json:"box"`
+ Hosts map[string]string `json:"hosts"`
+}
+
+type other struct {
+ Names []string `json:"names"`
+}
+
+func decodeInto(body []byte, v any) error { return json.Unmarshal(body, v) }
+
+func decodeAs[T any](body []byte) (T, error) {
+ var v T
+ err := json.Unmarshal(body, &v)
+ return v, err
+}
+
+// Through calls the parameter helper with an edges-shaped target.
+func Through(body []byte) string {
+ var f edges
+ _ = decodeInto(body, &f)
+ return f.Box
+}
+
+// Generic calls the type-parameter helper with one.
+func Generic(body []byte) string {
+ f, _ := decodeAs[edges](body)
+ return f.Box
+}
+
+// Unrelated calls both with a type that is not edges-shaped: no site.
+func Unrelated(body []byte) int {
+ var o other
+ _ = decodeInto(body, &o)
+ p, _ := decodeAs[other](body)
+ return len(o.Names) + len(p.Names)
+}
diff --git a/internal/edgesfile/testdata/audit/holds/holds.go b/internal/edgesfile/testdata/audit/holds/holds.go
new file mode 100644
index 000000000..6d492a3cc
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/holds/holds.go
@@ -0,0 +1,30 @@
+// Package holds is a fixture of the edges-file read audit: a type that
+// carries offline_moves, passed up through a wrapper and read by a field.
+package holds
+
+import (
+ "encoding/json"
+)
+
+type file struct {
+ Hosts map[string]json.RawMessage `json:"hosts"`
+ Holds []json.RawMessage `json:"offline_moves"`
+}
+
+func read(body []byte) (file, error) {
+ var f file
+ err := json.Unmarshal(body, &f)
+ return f, err
+}
+
+// pass returns what read returned, offline_moves included.
+func pass(body []byte) (file, error) { return read(body) }
+
+// Top holds the holds two levels above the decode.
+func Top(body []byte) int {
+ f, _ := pass(body)
+ return len(f.Hosts)
+}
+
+// Count reads the field itself.
+func Count(f file) int { return len(f.Holds) }
diff --git a/internal/edgesfile/testdata/audit/unrelated/unrelated.go b/internal/edgesfile/testdata/audit/unrelated/unrelated.go
new file mode 100644
index 000000000..21264c2d1
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/unrelated/unrelated.go
@@ -0,0 +1,33 @@
+// Package unrelated is a fixture of the edges-file read audit
+// (internal/edgesfile/audit_test.go): an inventoried read beside an
+// unrelated struct with a hosts field, which must be flagged until its type
+// is excluded.
+package unrelated
+
+import (
+ "encoding/json"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+)
+
+// Served is the inventoried read.
+func Served(body []byte, host string) bool {
+ f, err := edgesfile.Parse(body)
+ if err != nil {
+ return false
+ }
+ _, ok := f.Hosts[host]
+ return ok
+}
+
+// Roster is not an edges file: a list of hosts some other object names.
+type Roster struct {
+ Hosts []string `json:"hosts"`
+}
+
+// ReadRoster decodes one.
+func ReadRoster(body []byte) (Roster, error) {
+ var r Roster
+ err := json.Unmarshal(body, &r)
+ return r, err
+}
diff --git a/internal/edgesfile/testdata/audit/wrapper/wrapper.go b/internal/edgesfile/testdata/audit/wrapper/wrapper.go
new file mode 100644
index 000000000..ea3f3d27b
--- /dev/null
+++ b/internal/edgesfile/testdata/audit/wrapper/wrapper.go
@@ -0,0 +1,23 @@
+// Package wrapper is a fixture of the edges-file read audit: a function that
+// wraps an edgesfile reader and is called elsewhere.
+package wrapper
+
+import (
+ "context"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func routes(ctx context.Context, s release.Store) map[string]bool {
+ return edgesfile.DeploymentRoutes(ctx, s, time.Now())
+}
+
+// Taken is the wrapper's caller.
+func Taken(ctx context.Context, s release.Store, host string) bool {
+ return routes(ctx, s)[host]
+}
+
+// Chooser takes the reader as a function value.
+var Chooser = edgesfile.LiveSnapshot
diff --git a/internal/host/edgesfile_decoders_test.go b/internal/host/edgesfile_decoders_test.go
new file mode 100644
index 000000000..9d1063297
--- /dev/null
+++ b/internal/host/edgesfile_decoders_test.go
@@ -0,0 +1,243 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The fixture three ways (self-serve moves plan 3, Task 4): as written,
+// without offline_moves, and with each holding entry's host and aliases
+// published as plain home routes (what counting the hold as served would
+// mean). edit sets box-level fields on all three alike, so the evidence
+// varies only in offline_moves. internal/edgesfile/offline_moves_test.go
+// holds the reference copy of this helper.
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxBox = "annie"
+ fxPath = "../edgesfile/testdata/offline-moves.json"
+)
+
+type holdVariants struct{ with, without, served []byte }
+
+func fixtureVariants(t *testing.T, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(fxPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ m[k], _ = json.Marshal(v)
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ len(holds[0].Aliases) != 1 || holds[0].Aliases[0] != fxHeldAlias {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ hosts[name], _ = json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// variantsByName is the three variants keyed for a loop.
+func (v holdVariants) byName() map[string][]byte {
+ return map[string][]byte{"with": v.with, "without": v.without, "served": v.served}
+}
+
+// putCounter counts the store's Puts.
+type putCounter struct {
+ release.Store
+ puts int
+}
+
+func (p *putCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ p.puts++
+ return p.Store.Put(ctx, key, body, ifMatch)
+}
+
+// The change gate round-trips offline_moves: the writer publishes the hold,
+// seedEdgesFileGate reads the published file back into exactly the gate the
+// writer kept, and the next pass with the same inputs writes nothing. When
+// the hold ends the gate differs and the file is rewritten without it.
+func TestEdgesFileGateRoundTripsOfflineMoves(t *testing.T) {
+ ctx := context.Background()
+ reg := holdingRegistry(t)
+ view := viewOf(map[string]provision.MoveState{fxHeld: selfServeMove(holdMoveA, provision.OfflineSealing)})
+ st := &putCounter{Store: release.NewDirStore(t.TempDir())}
+ ident := edgesFile{Box: fxBox, BoxID: fxBox}
+ var last []byte
+ if err := edgesFileWriteClaims(ctx, st, reg, ident, "1.2.3.4", &last, false, nil, true, view.holding); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := st.Get(ctx, edgesFileKey(fxBox))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(body, &f); err != nil || len(f.OfflineMoves) != 1 || f.OfflineMoves[0].Host != fxHeld {
+ t.Fatalf("published %s (%v), want the hold of %s", body, err, fxHeld)
+ }
+ if _, ok := f.Hosts[fxHeld]; ok {
+ t.Fatalf("the writer published the held row as a host: %s", body)
+ }
+ seeded := seedEdgesFileGate(ctx, st, fxBox)
+ if string(seeded) != string(last) {
+ t.Fatalf("seeded gate %s, want the writer's %s", seeded, last)
+ }
+ if err := edgesFileWriteClaims(ctx, st, reg, ident, "1.2.3.4", &seeded, false, nil, true, view.holding); err != nil {
+ t.Fatal(err)
+ }
+ if st.puts != 1 {
+ t.Fatalf("an unchanged file with offline_moves was rewritten (%d puts)", st.puts)
+ }
+ if err := edgesFileWriteClaims(ctx, st, reg, ident, "1.2.3.4", &seeded, false, nil, true, viewOf(nil).holding); err != nil {
+ t.Fatal(err)
+ }
+ body, _, _ = st.Get(ctx, edgesFileKey(fxBox))
+ f = edgesFile{}
+ if err := json.Unmarshal(body, &f); err != nil || st.puts != 2 || len(f.OfflineMoves) != 0 {
+ t.Fatalf("after the hold ended: %d puts, %s", st.puts, body)
+ }
+}
+
+// The writer's prior-publication recovery (an EIP probe that came back
+// blank, region HA off) takes the prior file's address and nothing else:
+// the hosts it publishes are the registry's, whatever the prior file's
+// hosts or offline_moves said, and a prior hold is never republished.
+func TestWriterPriorPublicationRecoveryIgnoresTheHold(t *testing.T) {
+ ctx := context.Background()
+ var first edgesFile
+ for name, prior := range fixtureVariants(t, nil).byName() {
+ reg := edgesTestRegistry(t)
+ lifetimeRow(t, reg, fxServed, 2, 3)
+ st := release.NewDirStore(t.TempDir())
+ last := append([]byte(nil), prior...)
+ if err := edgesFileWriteClaims(ctx, st, reg, edgesFile{Box: fxBox, BoxID: fxBox}, "", &last, false, nil, true, nil); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := st.Get(ctx, edgesFileKey(fxBox))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(body, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.EIP != "1.2.3.4" || len(f.OfflineMoves) != 0 {
+ t.Fatalf("%s: published %s, want the prior address and no holds", name, body)
+ }
+ var hosts []string
+ for h := range f.Hosts {
+ hosts = append(hosts, h)
+ }
+ sort.Strings(hosts)
+ if !reflect.DeepEqual(hosts, []string{fxServed}) {
+ t.Fatalf("%s: published hosts %v, want the registry's own route only", name, hosts)
+ }
+ f.Generation = ""
+ if first.Box == "" {
+ first = f
+ } else if !reflect.DeepEqual(f, first) {
+ t.Fatalf("%s: published %+v, another prior published %+v", name, f, first)
+ }
+ }
+}
+
+// The home map's decoder, through the exported fold (FoldedHome folds as
+// refreshHosts folds one poll): an edge labelled betty dials annie for the
+// served route and has no dial target for the held host or its alias.
+// Counted as served, the hold would be dialled. That the hold IS a
+// candidate (the page) is internal/edge's own test of the same fixture
+// (TestHoldingViewDecodesTheWriterFixture and the rank tests).
+func TestHomeMapFoldServesTheRouteNotTheHold(t *testing.T) {
+ v := fixtureVariants(t, nil)
+ fold := func(body []byte, host string) (string, bool) {
+ box, ok, err := edge.FoldedHome(map[string][]byte{edgesFileKey(fxBox): body}, "betty", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return box, ok
+ }
+ for name, body := range map[string][]byte{"with": v.with, "without": v.without} {
+ if box, ok := fold(body, fxServed); !ok || box != fxBox {
+ t.Fatalf("%s: %s dials %q (%v), want %s", name, fxServed, box, ok, fxBox)
+ }
+ for _, h := range []string{fxHeld, fxHeldAlias} {
+ if box, ok := fold(body, h); ok {
+ t.Fatalf("%s: the held name %s dials %q; a hold is never served", name, h, box)
+ }
+ }
+ }
+ if box, ok := fold(v.served, fxHeld); !ok || box != fxBox {
+ t.Fatal("test bug: counted as served, the hold must be dialled")
+ }
+}
+
+// offlineTargetReady reads this box's own edges file for its capabilities
+// and key fingerprint only: the report is the same with and without
+// offline_moves, and with the hold counted as served. offlineReadyReport and
+// writeReturnReport publish this report.
+func TestOfflineTargetReadyReadsOnlyBoxFields(t *testing.T) {
+ ctx := context.Background()
+ dataDir := t.TempDir()
+ w := &movesWorker{cfg: moveCfg{Box: fxBox, DataDir: dataDir, OptDir: t.TempDir()}}
+ m := selfServeMove(holdMoveA, provision.OfflineSealing)
+ rec := provision.Record{Host: fxHeld, Account: "bab", App: "shop", Move: &m}
+ got := map[string]any{}
+ for name, body := range fixtureVariants(t, map[string]any{"capabilities": []string{"offline-move-1"}, "secrets_key_fp": "sha256:abc"}).byName() {
+ st := release.NewDirStore(t.TempDir())
+ if _, err := st.Put(ctx, edgesFileKey(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ r, err := w.offlineTargetReady(ctx, st, rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(r.Capabilities, []string{"offline-move-1"}) || r.SecretsKeyFP != "sha256:abc" {
+ t.Fatalf("%s: capabilities %v, key %q", name, r.Capabilities, r.SecretsKeyFP)
+ }
+ got[name] = r
+ }
+ if !reflect.DeepEqual(got["with"], got["without"]) || !reflect.DeepEqual(got["with"], got["served"]) {
+ t.Fatalf("the target-ready report changed with offline_moves:\nwith %+v\nwithout %+v", got["with"], got["without"])
+ }
+}
diff --git a/internal/host/movespass_offline_moves_test.go b/internal/host/movespass_offline_moves_test.go
new file mode 100644
index 000000000..5b314c7e9
--- /dev/null
+++ b/internal/host/movespass_offline_moves_test.go
@@ -0,0 +1,31 @@
+package host
+
+import (
+ "context"
+ "reflect"
+ "testing"
+)
+
+// moveTargets (the legacy round's placement -> coordinates fold) is
+// box-level: the same target with and without offline_moves, and with the
+// hold counted as served. Fixture helpers: edgesfile_decoders_test.go and
+// movesreport_offline_moves_test.go.
+func TestMoveTargetsAreUnchangedByOfflineMoves(t *testing.T) {
+ got := map[string]map[string]edgesFile{}
+ for name, body := range fixtureVariants(t, fleetEvidence).byName() {
+ out, err := moveTargets(context.Background(), edgesStore(t, body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got[name] = map[string]edgesFile{}
+ for k, f := range out {
+ got[name][k] = boxFields(f)
+ }
+ }
+ if got["with"]["uk"].Box != fxBox || got["with"]["uk"].DataBucket != "carlos-uk-data" {
+ t.Fatalf("moveTargets = %+v", got["with"])
+ }
+ if !reflect.DeepEqual(got["with"], got["without"]) || !reflect.DeepEqual(got["with"], got["served"]) {
+ t.Fatalf("moveTargets changed with offline_moves: %+v, %+v, %+v", got["with"], got["without"], got["served"])
+ }
+}
diff --git a/internal/host/movesreport_offline_moves_test.go b/internal/host/movesreport_offline_moves_test.go
new file mode 100644
index 000000000..9c40c494f
--- /dev/null
+++ b/internal/host/movesreport_offline_moves_test.go
@@ -0,0 +1,85 @@
+package host
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// fleetEvidence is annie as a move target: placement uk with a data bucket,
+// box id box-annie. The fixture helper is edgesfile_decoders_test.go's.
+var fleetEvidence = map[string]any{"placement": "uk", "data_bucket": "carlos-uk-data", "data_bucket_region": "eu-west-2", "box_id": "box-annie"}
+
+// boxFields is an edges file with its host-level halves cleared: what a
+// move's fleet read may act on.
+func boxFields(f edgesFile) edgesFile {
+ f.Hosts, f.OfflineMoves = nil, nil
+ return f
+}
+
+func edgesStore(t *testing.T, body []byte) release.Store {
+ t.Helper()
+ st := release.NewDirStore(t.TempDir())
+ if _, err := st.Put(context.Background(), edgesFileKey(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ return st
+}
+
+// loadMoveFleet decodes the writer's own shape, offline_moves included, but
+// every fleet decision is box-level: the target per placement, the boxes
+// that publish it, the file by box id. Those are the same with and without
+// offline_moves and with the hold counted as served, and offlineTarget (the
+// self-serve move's pinned target, and a move by region) answers the same
+// box. The callers listed in the inventory (movesPass, the seal, stage and
+// revert steps, the rollback's return) act on these answers; that no code
+// but the writer and the home map reads the offline_moves field itself is
+// the audit's kind (iv).
+func TestMoveFleetIsUnchangedByOfflineMoves(t *testing.T) {
+ ctx := context.Background()
+ pinned := selfServeMove(holdMoveA, provision.OfflineSealing)
+ pinned.Participants = &provision.Participants{Target: provision.Participant{BoxID: "box-annie", Label: fxBox}}
+ byRegion := provision.MoveState{ID: holdMoveA, Kind: provision.MoveKindOffline, To: "uk"}
+ w := &movesWorker{}
+ type read struct {
+ targets, byBox map[string]edgesFile
+ boxes map[string][]string
+ pinned, region edgesFile
+ }
+ got := map[string]read{}
+ for name, body := range fixtureVariants(t, fleetEvidence).byName() {
+ st := edgesStore(t, body)
+ fleet, err := loadMoveFleet(ctx, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := read{targets: map[string]edgesFile{}, byBox: map[string]edgesFile{}, boxes: fleet.boxes}
+ for k, f := range fleet.targets {
+ r.targets[k] = boxFields(f)
+ }
+ for k, f := range fleet.byBox {
+ r.byBox[k] = boxFields(f)
+ }
+ p, err := w.offlineTarget(ctx, st, &pinned)
+ if err != nil {
+ t.Fatalf("%s: pinned target: %v", name, err)
+ }
+ g, err := w.offlineTarget(ctx, st, &byRegion)
+ if err != nil {
+ t.Fatalf("%s: target by region: %v", name, err)
+ }
+ r.pinned, r.region = boxFields(p), boxFields(g)
+ got[name] = r
+ }
+ with := got["with"]
+ if with.targets["uk"].Box != fxBox || with.byBox["box-annie"].DataBucket != "carlos-uk-data" ||
+ !reflect.DeepEqual(with.boxes, map[string][]string{"uk": {fxBox}}) || with.pinned.Box != fxBox || with.region.Box != fxBox {
+ t.Fatalf("fleet = %+v", with)
+ }
+ if !reflect.DeepEqual(with, got["without"]) || !reflect.DeepEqual(with, got["served"]) {
+ t.Fatalf("the move fleet changed with offline_moves:\nwith %+v\nwithout %+v\nserved %+v", with, got["without"], got["served"])
+ }
+}
diff --git a/internal/hostactors/references_offline_moves_test.go b/internal/hostactors/references_offline_moves_test.go
new file mode 100644
index 000000000..7e21aa249
--- /dev/null
+++ b/internal/hostactors/references_offline_moves_test.go
@@ -0,0 +1,100 @@
+package hostactors
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The fixture three ways (self-serve moves plan 3, Task 4): as written,
+// without offline_moves, and with each holding entry's host and aliases
+// published as plain home routes (what counting the hold as served would
+// mean). edit sets box-level fields on all three alike, so the evidence
+// varies only in offline_moves. internal/edgesfile/offline_moves_test.go
+// holds the reference copy of this helper.
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxBox = "annie"
+ fxPath = "../edgesfile/testdata/offline-moves.json"
+)
+
+type holdVariants struct{ with, without, served []byte }
+
+func fixtureVariants(t *testing.T, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(fxPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ m[k], _ = json.Marshal(v)
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ len(holds[0].Aliases) != 1 || holds[0].Aliases[0] != fxHeldAlias {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ hosts[name], _ = json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// Referenced's edges-file half reads only box_id (an anonymous struct) and
+// the key: a box id or label is referenced, and nothing a holding entry
+// names (its host, alias, move id or owner) is, with or without
+// offline_moves and even with the hold counted as served. enroll consumes
+// only this answer.
+func TestReferencedIgnoresOfflineMoves(t *testing.T) {
+ v := fixtureVariants(t, map[string]any{"box_id": "box-annie"})
+ for name, body := range map[string][]byte{"with": v.with, "without": v.without, "served": v.served} {
+ d := Directory{Store: release.NewDirStore(t.TempDir())}
+ if _, err := d.Store.Put(context.Background(), edgesfile.Key(fxBox), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ for ref, want := range map[string]bool{
+ "box-annie": true, fxBox: true,
+ fxHeld: false, fxHeldAlias: false, fxServed: false, "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa": false, "bab": false, "shop": false,
+ } {
+ got, err := d.Referenced(context.Background(), ref)
+ if err != nil || got != want {
+ t.Fatalf("%s: Referenced(%q) = %v, %v; want %v", name, ref, got, err, want)
+ }
+ }
+ }
+}
diff --git a/internal/steering/offline_moves_test.go b/internal/steering/offline_moves_test.go
new file mode 100644
index 000000000..df64dbb14
--- /dev/null
+++ b/internal/steering/offline_moves_test.go
@@ -0,0 +1,123 @@
+package steering
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The fixture three ways (self-serve moves plan 3, Task 4): as written,
+// without offline_moves, and with each holding entry's host and aliases
+// published as plain home routes (what counting the hold as served would
+// mean). edit sets box-level fields on all three alike, so the evidence
+// varies only in offline_moves. internal/edgesfile/offline_moves_test.go
+// holds the reference copy of this helper.
+const (
+ fxServed = "shop.example.com"
+ fxHeld = "cart.bab.example.net"
+ fxHeldAlias = "cart.example.com"
+ fxBox = "annie"
+ fxPath = "../edgesfile/testdata/offline-moves.json"
+)
+
+type holdVariants struct{ with, without, served []byte }
+
+func fixtureVariants(t *testing.T, edit map[string]any) holdVariants {
+ t.Helper()
+ raw, err := os.ReadFile(fxPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range edit {
+ m[k], _ = json.Marshal(v)
+ }
+ enc := func() []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ var out holdVariants
+ out.with = enc()
+ var holds []struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ }
+ if err := json.Unmarshal(m["offline_moves"], &holds); err != nil || len(holds) != 1 || holds[0].Host != fxHeld ||
+ len(holds[0].Aliases) != 1 || holds[0].Aliases[0] != fxHeldAlias {
+ t.Fatalf("fixture: offline_moves %s (%v), want one hold of %s with alias %s", m["offline_moves"], err, fxHeld, fxHeldAlias)
+ }
+ delete(m, "offline_moves")
+ out.without = enc()
+ var hosts map[string]json.RawMessage
+ if err := json.Unmarshal(m["hosts"], &hosts); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range holds {
+ for i, name := range append([]string{h.Host}, h.Aliases...) {
+ hosts[name], _ = json.Marshal(map[string]any{"role": "home", "platform": h.Platform && i == 0, "account": h.Account, "app": h.App})
+ }
+ }
+ m["hosts"], _ = json.Marshal(hosts)
+ out.served = enc()
+ return out
+}
+
+// parseEdgesFileView keeps its own decoder (internal/edgesfile's doc names
+// it), and since #162 it reads the per-host replica mark from Hosts: the one
+// way a host's entry changes steering. The holding entries never reach
+// Hosts, so the desired state, and what ConvergeDeployment writes, are the
+// same with and without offline_moves for a steered route and for the held
+// alias steered as a route.
+func TestSteeringDesiredStateIgnoresTheHold(t *testing.T) {
+ captureLog(t)
+ v := fixtureVariants(t, map[string]any{"region": "eu-west-2"})
+ with, err := parseEdgesFileView(v.with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for h := range with.Hosts {
+ names = append(names, h)
+ }
+ sort.Strings(names)
+ if !reflect.DeepEqual(names, []string{"*.certonly.example.net", fxServed}) {
+ t.Fatalf("edgesFileView.Hosts = %v, want the served route and the certificate-only entry (the hold %s and its alias %s are never hosts)", names, fxHeld, fxHeldAlias)
+ }
+ without, _ := parseEdgesFileView(v.without)
+ if !reflect.DeepEqual(with, without) {
+ t.Fatalf("parse with offline_moves = %+v, without = %+v", with, without)
+ }
+ routes := []registry.Route{steeredRoute(fxServed), steeredRoute(fxHeldAlias)}
+ other := edgesBody(t, "betty", "5.6.7.8", "us-west-2")
+ dw := desiredState(routes, []edgesFileView{with}, "example.com")
+ if len(dw) != 3 || !reflect.DeepEqual(dw, desiredState(routes, []edgesFileView{without}, "example.com")) {
+ t.Fatalf("desired state with offline_moves = %+v, want three records and the same without", dw)
+ }
+ converged := func(body []byte) []string {
+ f := newFake()
+ if err := ConvergeDeployment(context.Background(), f, testZone, routes, [][]byte{body, other}, "example.com"); err != nil {
+ t.Fatal(err)
+ }
+ return recordNames(f.records)
+ }
+ cw := converged(v.with)
+ want := []string{"cart.example.com.|A|annie", "cart.example.com.|A|betty", "edge.example.com.|A|annie", "edge.example.com.|A|betty",
+ "shop.example.com.|A|annie", "shop.example.com.|A|betty"}
+ if !reflect.DeepEqual(cw, want) || !reflect.DeepEqual(cw, converged(v.without)) {
+ t.Fatalf("converged with offline_moves %v, want %v and the same without", cw, want)
+ }
+}