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>
15 files changed,
+2390
−0
internal/console/edges_offline_moves_test.go+278 −0internal/domains/aliases_offline_moves_test.go+134 −0internal/edgesfile/audit_test.go+963 −0internal/edgesfile/offline_moves_test.go+233 −0internal/edgesfile/testdata/audit/alias/alias.go+50 −0internal/edgesfile/testdata/audit/boxid/boxid.go+19 −0internal/edgesfile/testdata/audit/helper/helper.go+45 −0internal/edgesfile/testdata/audit/holds/holds.go+30 −0internal/edgesfile/testdata/audit/unrelated/unrelated.go+33 −0internal/edgesfile/testdata/audit/wrapper/wrapper.go+23 −0internal/host/edgesfile_decoders_test.go+243 −0internal/host/movespass_offline_moves_test.go+31 −0internal/host/movesreport_offline_moves_test.go+85 −0internal/hostactors/references_offline_moves_test.go+100 −0internal/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) |
| + } |
| +} |