| index befc34a3e..d06440caf 100644 |
| --- a/internal/host/assetpublish_test.go |
| +++ b/internal/host/assetpublish_test.go |
| @@ -225,26 +225,33 @@ func (w *assetWorld) raw() map[string]json.RawMessage { |
| } |
| |
| // hosts runs one pass and reads the written hosts back. |
| -func (w *assetWorld) hosts() map[string]edgeHost { |
| +func (w *assetWorld) hosts() map[string]edgesfile.Host { |
| w.t.Helper() |
| w.pass() |
| return w.written() |
| } |
| |
| -func (w *assetWorld) written() map[string]edgeHost { |
| +// written reads the written object back through the production reader, |
| +// internal/edgesfile, never the writer's own type. |
| +func (w *assetWorld) written() map[string]edgesfile.Host { |
| w.t.Helper() |
| - body, _, err := w.writes.Get(context.Background(), edgesFileKey("annie")) |
| + return parseWrittenHosts(w.t, w.writes) |
| +} |
| + |
| +func parseWrittenHosts(t *testing.T, s release.Store) map[string]edgesfile.Host { |
| + t.Helper() |
| + body, _, err := s.Get(context.Background(), edgesfile.Key("annie")) |
| if err != nil { |
| - w.t.Fatal(err) |
| + t.Fatal(err) |
| } |
| - var f edgesFile |
| - if err := json.Unmarshal(body, &f); err != nil { |
| - w.t.Fatal(err) |
| + f, err := edgesfile.Parse(body) |
| + if err != nil { |
| + t.Fatalf("the written file does not parse: %v", err) |
| } |
| return f.Hosts |
| } |
| |
| -func wantNoAssets(t *testing.T, hosts map[string]edgeHost, host string) { |
| +func wantNoAssets(t *testing.T, hosts map[string]edgesfile.Host, host string) { |
| t.Helper() |
| h, ok := hosts[host] |
| if !ok { |
| @@ -255,7 +262,7 @@ func wantNoAssets(t *testing.T, hosts map[string]edgeHost, host string) { |
| } |
| } |
| |
| -func wantAssets(t *testing.T, hosts map[string]edgeHost, host string, paths []string, gen string, at time.Time) { |
| +func wantAssets(t *testing.T, hosts map[string]edgesfile.Host, host string, paths []string, gen string, at time.Time) { |
| t.Helper() |
| h, ok := hosts[host] |
| if !ok { |
| @@ -271,6 +278,7 @@ func wantAssets(t *testing.T, hosts map[string]edgeHost, host string, paths []st |
| |
| // ---- the clocks ----------------------------------------------------------- |
| |
| +// Not acceptance: a unit test of the clocks, with no written object. |
| func TestAssetAuthorityHostAtIsTheOlderClock(t *testing.T) { |
| t.Parallel() |
| a := newAssetAuthority(nil) |
| @@ -286,6 +294,7 @@ func TestAssetAuthorityHostAtIsTheOlderClock(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: a unit test of the clocks, with no written object. |
| func TestAssetAuthorityIncompletePassDoesNotAdvance(t *testing.T) { |
| t.Parallel() |
| a := newAssetAuthority(nil) |
| @@ -312,6 +321,7 @@ func TestAssetAuthorityIncompletePassDoesNotAdvance(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: a unit test of the clocks, with no written object. |
| func TestAssetAuthorityUnknownUntilBothClocks(t *testing.T) { |
| t.Parallel() |
| a := newAssetAuthority(nil) |
| @@ -340,6 +350,9 @@ func TestAssetAuthorityUnknownUntilBothClocks(t *testing.T) { |
| // alias wiring with a clock that moves an hour on every read after the |
| // first: a pass that took its time at the end, or read it twice, records a |
| // later time than the one it started at. |
| +// |
| +// Not acceptance: a unit test of the clocks over the real pass wiring, with |
| +// no written object. |
| func TestAssetAuthorityRecordsTheStartTime(t *testing.T) { |
| t.Parallel() |
| b := newAuthorityBox(t) |
| @@ -378,6 +391,7 @@ func TestAssetAuthorityRecordsTheStartTime(t *testing.T) { |
| |
| // ---- the manifest cache --------------------------------------------------- |
| |
| +// Not acceptance: a unit test of the manifest cache, with no written object. |
| func TestAssetManifestCacheReadsTheBucketOnce(t *testing.T) { |
| t.Parallel() |
| s := newAssetFaultStore(release.NewDirStore(t.TempDir())) |
| @@ -404,6 +418,7 @@ func TestAssetManifestCacheReadsTheBucketOnce(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: a unit test of the manifest cache, with no written object. |
| func TestAssetManifestCacheDoesNotCacheAFailure(t *testing.T) { |
| t.Parallel() |
| s := newAssetFaultStore(release.NewDirStore(t.TempDir())) |
| @@ -432,6 +447,9 @@ func TestAssetManifestCacheDoesNotCacheAFailure(t *testing.T) { |
| // One route deployed through 50 versions, with the serving build one behind |
| // the installed one, keeps at most the two builds it references, and a |
| // rollback to a version the cache dropped reads the bucket again. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestAssetManifestCacheStaysBoundedAcrossDeploys(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -471,10 +489,13 @@ func TestAssetManifestCacheStaysBoundedAcrossDeploys(t *testing.T) { |
| |
| // ---- publication ---------------------------------------------------------- |
| |
| -// TestPublishesAssetsForAnArmedHome is the acceptance case: an armed home's |
| +// TestPublishesAssetsForAnArmedHome: an armed home's |
| // instance publishes paths, gen and at, the file still parses through |
| // internal/edgesfile, and the host entry equals the golden file Task 9's |
| // readers are tested against. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestPublishesAssetsForAnArmedHome(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -508,6 +529,8 @@ func TestPublishesAssetsForAnArmedHome(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestUnarmedBoxPublishesNoAssets(t *testing.T) { |
| t.Parallel() |
| for v, want := range map[string]bool{"1": true, "": false, "0": false, "true": false, " 1": false, "yes": false} { |
| @@ -540,6 +563,9 @@ func TestUnarmedBoxPublishesNoAssets(t *testing.T) { |
| } |
| |
| // Each §3.3 exclusion, alone, on a host that otherwise publishes. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestPublishesNothingUnderAnExclusion(t *testing.T) { |
| t.Parallel() |
| cases := []struct { |
| @@ -636,6 +662,9 @@ func TestPublishesNothingUnderAnExclusion(t *testing.T) { |
| |
| // A pooled route publishes nothing for far edges but still catches on the |
| // boxes that serve it (§3.3), so For answers and Publish does not. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestPooledRouteCatchesLocallyOnly(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -649,6 +678,8 @@ func TestPooledRouteCatchesLocallyOnly(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestPublishesTheUnionOfInstalledAndServingBuilds(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -688,6 +719,9 @@ func TestPublishesTheUnionOfInstalledAndServingBuilds(t *testing.T) { |
| |
| // A sleeping instance has no serving generation; it keeps the installed |
| // build's paths, the build a wake would start. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestSleepingInstanceKeepsTheInstalledBuildsPaths(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -697,6 +731,8 @@ func TestSleepingInstanceKeepsTheInstalledBuildsPaths(t *testing.T) { |
| wantAssets(t, w.hosts(), "shop.example.com", []string{"/assets/"}, assetGen1, assetT0) |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestAliasPublishesItsPrimarysBlock(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -734,6 +770,8 @@ func TestAliasPublishesItsPrimarysBlock(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestCatchallAliasNeverPublishes(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -764,6 +802,9 @@ func TestCatchallAliasNeverPublishes(t *testing.T) { |
| |
| // Review Focus 4: the console box homes the console's own route beside |
| // instances; only the instance route publishes. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestConsoleClassRouteDoesNotPublish(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -783,6 +824,8 @@ func TestConsoleClassRouteDoesNotPublish(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestRewritesWhenAtAdvancesTwentyMinutes(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -811,6 +854,8 @@ func TestRewritesWhenAtAdvancesTwentyMinutes(t *testing.T) { |
| } |
| } |
| |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestDoesNotRewriteForASmallerAdvance(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -846,6 +891,9 @@ func TestDoesNotRewriteForASmallerAdvance(t *testing.T) { |
| |
| // A host that becomes publishable when the clocks first become known (after |
| // a restart) does not wait for the timer. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestRewriteDueWhenTheAuthorityFirstBecomesKnown(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -868,6 +916,9 @@ func TestRewriteDueWhenTheAuthorityFirstBecomesKnown(t *testing.T) { |
| // A generation switch changes what the route answers with but not the |
| // registry: the loop's change key moves with the serving build, so the |
| // schedule runs a pass and the new build's paths are published. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestServingBuildSwitchRepublishesWithAnUnchangedFingerprint(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -930,6 +981,9 @@ func TestServingBuildSwitchRepublishesWithAnUnchangedFingerprint(t *testing.T) { |
| // TestFailedRecordReadsStopAtFromAdvancing: with the reconcile report |
| // leaving the app out for 61 minutes while alias passes and edges-file |
| // writes succeed, the published at stays at the last complete pass. |
| +// |
| +// Not acceptance: the clocks are fed reports directly (the authority table |
| +// runs the real passes); the writer and the reader are the production ones. |
| func TestFailedRecordReadsStopAtFromAdvancing(t *testing.T) { |
| t.Parallel() |
| w := newAssetWorld(t) |
| @@ -1114,19 +1168,13 @@ func (b *authorityBox) run(d time.Duration) { |
| } |
| } |
| |
| -func (b *authorityBox) published(host string) (edgeAssets, bool) { |
| +// published is host's block in the written object, read through the |
| +// production reader (internal/edgesfile). |
| +func (b *authorityBox) published(host string) (edgesfile.HostAssets, bool) { |
| b.t.Helper() |
| - body, _, err := b.base.Get(context.Background(), edgesFileKey("annie")) |
| - if err != nil { |
| - b.t.Fatal(err) |
| - } |
| - var f edgesFile |
| - if err := json.Unmarshal(body, &f); err != nil { |
| - b.t.Fatal(err) |
| - } |
| - h, ok := f.Hosts[host] |
| + h, ok := parseWrittenHosts(b.t, b.base)[host] |
| if !ok || h.Assets == nil { |
| - return edgeAssets{}, false |
| + return edgesfile.HostAssets{}, false |
| } |
| return *h.Assets, true |
| } |
| @@ -1325,5 +1373,232 @@ func TestPathRowAddedUnderAPrimaryStopsIt(t *testing.T) { |
| } |
| } |
| |
| +// ---- overlapping passes ---------------------------------------------------- |
| + |
| +// tearBox is an authority box with an alias that can move between two |
| +// primaries, each of which gets path rows when the alias points at it: |
| +// www.example.com -> blog.example.com with blog's path rows, or |
| +// www.example.com -> shop.example.com with shop's. Both settled states |
| +// exclude the alias; only a read joining one state's alias with the other's |
| +// path set lets it catch. |
| +func newTearBox(t *testing.T) *authorityBox { |
| + t.Helper() |
| + b := newAuthorityBox(t) |
| + b.route("blog.example.com", func(r *registry.Route) { r.AssetGen = assetGen2; r.Version = "v1" }) |
| + b.route("api.example.com") |
| + b.tearTo("blog.example.com") |
| + b.cycle() |
| + if err := b.reg.SetAliasDNSStatus("www.example.com", "pointing", assetT0.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.cycle() |
| + return b |
| +} |
| + |
| +// tearTo rewrites the claims so the alias and the path rows sit on primary |
| +// (no pass is run). |
| +func (b *authorityBox) tearTo(primary string) { |
| + b.t.Helper() |
| + for _, h := range []string{"www.example.com", "blog.example.com", "shop.example.com"} { |
| + if _, _, err := b.base.Get(context.Background(), domains.ClaimKey(h)); err == nil { |
| + b.unclaim(h) |
| + } |
| + } |
| + b.claim(domains.Claim{Host: "www.example.com", App: "shop", Route: primary}) |
| + b.claim(domains.Claim{Host: primary, App: "shop", Route: primary, Paths: map[string]string{"api": "api.example.com"}}) |
| +} |
| + |
| +// once runs fn the first time the policy's between-reads hook fires. |
| +func (b *authorityBox) once(fn func()) { |
| + var done bool |
| + b.policy.betweenReads = func() { |
| + if !done { |
| + done = true |
| + fn() |
| + } |
| + } |
| +} |
| + |
| +func (b *authorityBox) write() { |
| + b.t.Helper() |
| + if err := edgesFileWriteClaims(context.Background(), b.base, b.reg, edgesFile{Box: "annie"}, "1.2.3.4", &b.last, false, nil, b.policy); err != nil { |
| + b.t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestAssetDecisionOverlappingAnAliasPassWithholds: a complete alias pass |
| +// runs between a decision's path-rows read and its aliases read, moving the |
| +// alias from blog (with path rows) to shop (with path rows). Reading the old |
| +// path set with the new alias would let the alias catch with shop's block. |
| +// The writer instead republishes the blocks it last wrote, with their old |
| +// at, and retries; For and Publish answer false. Run with -race too. |
| +func TestAssetDecisionOverlappingAnAliasPassWithholds(t *testing.T) { |
| + t.Parallel() |
| + ctx := context.Background() |
| + b := newTearBox(t) |
| + if _, ok := b.published("www.example.com"); ok { |
| + t.Fatal("the alias of a primary with path rows published") |
| + } |
| + before, ok := b.published("shop.example.com") |
| + if !ok { |
| + t.Fatal("shop.example.com did not publish before the tear") |
| + } |
| + |
| + b.tearTo("shop.example.com") |
| + b.clock.advance(25 * time.Minute) |
| + b.auth.recordsPass(b.clock.now(), provision.ReconcileReport{Complete: map[string]bool{b.acct + "/shop": true}}) |
| + b.once(func() { |
| + if err := aliasPassReporting(ctx, b.doms, b.reg, nil, b.auth); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + b.write() |
| + if got, ok := b.published("www.example.com"); ok { |
| + t.Fatalf("the torn read published the alias with %+v", got) |
| + } |
| + got, ok := b.published("shop.example.com") |
| + if !ok || got.At != before.At || got.Gen != before.Gen || !slices.Equal(got.Paths, before.Paths) { |
| + t.Fatalf("the torn pass published shop as %+v %v, want its last block %+v unchanged", got, ok, before) |
| + } |
| + if !b.policy.rewriteDue() { |
| + t.Fatal("a torn pass did not ask for another") |
| + } |
| + // The next pass is settled and decides the state the alias pass left: |
| + // shop now has path rows, so neither name catches. |
| + b.policy.betweenReads = nil |
| + b.write() |
| + if _, ok := b.published("shop.example.com"); ok { |
| + t.Fatal("shop published with path rows under it") |
| + } |
| + if _, ok := b.published("www.example.com"); ok { |
| + t.Fatal("the alias published with path rows under its primary") |
| + } |
| + if b.policy.rewriteDue() { |
| + t.Fatal("still due after a settled pass") |
| + } |
| + |
| + // For and Publish: the same tear in the other direction, back to blog. |
| + // The old path set (shop's) with the new alias (to blog) would catch. |
| + b.tearTo("blog.example.com") |
| + b.once(func() { |
| + if err := aliasPassReporting(ctx, b.doms, b.reg, nil, b.auth); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if got, ok := b.policy.For("www.example.com"); ok { |
| + t.Fatalf("For answered %+v from a torn read", got) |
| + } |
| + aliases, err := b.reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.ContainsFunc(aliases, func(a registry.RouteAlias) bool { return a.Alias == "www.example.com" && a.Host == "blog.example.com" }) { |
| + t.Fatalf("the hook's pass did not move the alias: %+v", aliases) |
| + } |
| + b.tearTo("shop.example.com") |
| + b.once(func() { |
| + if err := aliasPassReporting(ctx, b.doms, b.reg, nil, b.auth); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if got, ok := b.policy.Publish("www.example.com"); ok { |
| + t.Fatalf("Publish answered %+v from a torn read", got) |
| + } |
| + // Settled again, the controls: blog has no path rows now, so blog |
| + // catches, and the alias (on shop, with path rows) does not. |
| + b.policy.betweenReads = nil |
| + if _, ok := b.policy.For("blog.example.com"); !ok { |
| + t.Fatal("control: For refused blog on a settled read") |
| + } |
| + if _, ok := b.policy.For("www.example.com"); ok { |
| + t.Fatal("For answered for the alias of a primary with path rows") |
| + } |
| +} |
| + |
| +// TestAssetDecisionOverlappingARecordsPassWithholds: the records pass (route |
| +// rows, gates, generations) tears the same way and is guarded the same way. |
| +func TestAssetDecisionOverlappingARecordsPassWithholds(t *testing.T) { |
| + t.Parallel() |
| + ctx := context.Background() |
| + b := newAuthorityBox(t) |
| + b.cycle() |
| + before, ok := b.published("shop.example.com") |
| + if !ok { |
| + t.Fatal("no block before the overlap") |
| + } |
| + b.clock.advance(25 * time.Minute) |
| + b.auth.aliasPass(b.clock.now(), domains.AliasReport{Complete: true}) |
| + b.once(func() { |
| + if err := reconcileSerializedAuth(ctx, b.recs, b.reg, b.reconcileConfig(), nil, b.auth); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + b.write() |
| + if got, ok := b.published("shop.example.com"); !ok || got.At != before.At { |
| + t.Fatalf("an overlapped pass published %+v %v, want the last block at %d", got, ok, before.At) |
| + } |
| + b.once(func() { |
| + if err := reconcileSerializedAuth(ctx, b.recs, b.reg, b.reconcileConfig(), nil, b.auth); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if _, ok := b.policy.For("shop.example.com"); ok { |
| + t.Fatal("For answered across a records pass") |
| + } |
| + b.policy.betweenReads = nil |
| + b.write() |
| + if got, ok := b.published("shop.example.com"); !ok || got.At != b.clock.now().Unix() { |
| + t.Fatalf("the settled pass published %+v %v, want at %d", got, ok, b.clock.now().Unix()) |
| + } |
| + if _, ok := b.policy.For("shop.example.com"); !ok { |
| + t.Fatal("For refused on a settled read") |
| + } |
| +} |
| + |
| +// TestAssetDecisionWithholdsWhileAPassRuns: a pass running across the whole |
| +// decision moves no sequence number between its reads, and is caught by the |
| +// in-flight count instead. |
| +// |
| +// Not acceptance: the clocks are fed reports directly; the writer and the |
| +// reader are the production ones. |
| +func TestAssetDecisionWithholdsWhileAPassRuns(t *testing.T) { |
| + t.Parallel() |
| + w := newAssetWorld(t) |
| + w.ship("v1", "/assets/") |
| + w.instance("shop.example.com", assetGen1, "v1") |
| + w.confirm() |
| + wantAssets(t, w.hosts(), "shop.example.com", []string{"/assets/"}, assetGen1, assetT0) |
| + w.clock.advance(25 * time.Minute) |
| + w.confirm() |
| + w.auth.passBegin() |
| + wantAssets(t, w.hosts(), "shop.example.com", []string{"/assets/"}, assetGen1, assetT0) |
| + if _, ok := w.policy.For("shop.example.com"); ok { |
| + t.Fatal("For answered while a pass ran") |
| + } |
| + if _, ok := w.policy.Publish("shop.example.com"); ok { |
| + t.Fatal("Publish answered while a pass ran") |
| + } |
| + if w.policy.Declared("shop.example.com", "v1", "/assets/a.js") { |
| + t.Fatal("Declared answered while a pass ran") |
| + } |
| + if !w.policy.rewriteDue() { |
| + t.Fatal("no retry asked for") |
| + } |
| + w.auth.passEnd() |
| + wantAssets(t, w.hosts(), "shop.example.com", []string{"/assets/"}, assetGen1, w.clock.now()) |
| + if _, ok := w.policy.For("shop.example.com"); !ok { |
| + t.Fatal("For refused once the pass ended") |
| + } |
| + // A restart's first pass has written nothing: an overlapped pass then |
| + // publishes no block at all, never one from the file it found. |
| + w2 := newAssetWorld(t) |
| + w2.ship("v1", "/assets/") |
| + w2.instance("shop.example.com", assetGen1, "v1") |
| + w2.last = w.last // the previous process's file, as seedEdgesFileGate reads it |
| + w2.confirm() |
| + w2.auth.passBegin() |
| + wantNoAssets(t, w2.hosts(), "shop.example.com") |
| +} |
| + |
| // Compile-time: the policy is what plan 3's edge consumes. |
| var _ edge.LocalAssets = (*assetPolicy)(nil) |