| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "reflect" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | ) |
| 10 | |
| 11 | // settings-5 task 8 (ruling R26): a hostname that moves to another tenant on |
| 12 | // one box meets the old tenant's consumed file, keyed by hostname only. A |
| 13 | // file tagged for another tenant is absent to root's decision and to the |
| 14 | // next file root writes; an untagged file from before the tag is read as |
| 15 | // today, so its completions and resolutions still count for the tenant that |
| 16 | // has the route. |
| 17 | |
| 18 | // writeForeignConsumed is tenant A's record for host: a manual decision, a |
| 19 | // resolved request, a failed completion watermark and a baking head. |
| 20 | func writeForeignConsumed(t *testing.T, opt, host string) { |
| 21 | t.Helper() |
| 22 | a := Consumed{ |
| 23 | Host: host, Account: "cdf", App: "km", |
| 24 | Resolved: &ResolvedRequest{ID: 7, Target: "v0"}, Completed: 6, CompletedFailed: true, |
| 25 | Desired: "v0", Reason: string(ReasonManual), Detail: "v0", Baking: "v9", |
| 26 | Settings: SettingsDigest(UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}.Column()), |
| 27 | } |
| 28 | if err := WriteConsumed(opt, a); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestAForeignConsumedFileIsAbsentForTheNewTenant(t *testing.T) { |
| 34 | s := NewDirStore(t.TempDir()) |
| 35 | ctx := context.Background() |
| 36 | priv, pub := testKeys(t) |
| 37 | for _, ta := range [][2]string{{"cdf", "km"}, {"dee", "blog"}} { |
| 38 | shipRelease(t, s, ta[0], ta[1], "v1", adoptPlatform, []byte("v1")) |
| 39 | if err := Promote(ctx, s, priv, ta[0], ta[1], "canary/x", "v1"); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | t.Run("default settings: a fresh rule 4 decision with B's identity", func(t *testing.T) { |
| 45 | opt, reg := t.TempDir(), testRegistry(t) |
| 46 | const host = "a.example.com" |
| 47 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Channel: "canary/x", Hibernate: true}); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | writeForeignConsumed(t, opt, host) |
| 51 | passWithSettings(t, reg, s, pub, opt) |
| 52 | if v, _ := LiveVersion(opt, host); v != "v1" { |
| 53 | t.Fatalf("live %q, want B's head v1 (today's adoption)", v) |
| 54 | } |
| 55 | c, err := ReadConsumed(opt, host) |
| 56 | if err != nil || c == nil { |
| 57 | t.Fatalf("consumed = %+v, %v", c, err) |
| 58 | } |
| 59 | want := Consumed{Schema: 1, Host: host, Account: "dee", App: "blog", |
| 60 | Desired: "v1", Reason: string(ReasonAdopt), Detail: "v1", Settings: SettingsDigest(""), At: c.At} |
| 61 | if !reflect.DeepEqual(*c, want) { |
| 62 | t.Fatalf("B's consumed file:\n got %+v\nwant %+v (B's identity, none of A's bookkeeping)", *c, want) |
| 63 | } |
| 64 | }) |
| 65 | |
| 66 | t.Run("an install request: A's watermark never swallows B's id", func(t *testing.T) { |
| 67 | opt, reg := t.TempDir(), testRegistry(t) |
| 68 | const host = "b.example.com" |
| 69 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Channel: "canary/x", Hibernate: true}); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if err := reg.SetUpdateSettings(host, UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 3}.Column()); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if err := reg.SetBuildState(host, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v0"}}.Column()); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | writeForeignConsumed(t, opt, host) |
| 79 | passWithSettings(t, reg, s, pub, opt) |
| 80 | if v, _ := LiveVersion(opt, host); v != "v1" { |
| 81 | t.Fatalf("live %q, want v1: B's request 3 resolved onto B's head", v) |
| 82 | } |
| 83 | c, _ := ReadConsumed(opt, host) |
| 84 | if c == nil || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 3, Target: "v1"}) || |
| 85 | c.Completed != 0 || c.CompletedFailed || c.Account != "dee" || c.App != "blog" { |
| 86 | t.Fatalf("B's consumed file = %+v (resolved %+v)", c, c.Resolved) |
| 87 | } |
| 88 | }) |
| 89 | } |
| 90 | |
| 91 | // An untagged file (root wrote it before ruling R22) is the route's own |
| 92 | // record: read as today, it keeps a failed completion and a resolution that |
| 93 | // only it holds, and root's next write tags it with the route's tenant. |
| 94 | func TestAnUntaggedConsumedFileKeepsItsBookkeepingForTheSameTenant(t *testing.T) { |
| 95 | s := NewDirStore(t.TempDir()) |
| 96 | ctx := context.Background() |
| 97 | priv, pub := testKeys(t) |
| 98 | for _, v := range []string{"v1", "v2", "v3"} { |
| 99 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 100 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | } |
| 104 | manual5 := UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 5} |
| 105 | state := BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1", Completed: 4}} |
| 106 | |
| 107 | t.Run("a failed completion only the file holds is not re-resolved", func(t *testing.T) { |
| 108 | opt, reg := t.TempDir(), testRegistry(t) |
| 109 | const host = "a.example.com" |
| 110 | settingsRoute(t, reg, host, "km", "canary/x", manual5, state) |
| 111 | legacy := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 5, Target: "vbad"}, Completed: 5, CompletedFailed: true, |
| 112 | Desired: "v1", Reason: string(ReasonManual), Detail: "v1"} |
| 113 | if err := WriteConsumed(opt, legacy); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | passWithSettings(t, reg, s, pub, opt) |
| 117 | if v, _ := LiveVersion(opt, host); v != "v1" { |
| 118 | t.Fatalf("live %q, want v1: request 5 is completed (failed), the instance is manual", v) |
| 119 | } |
| 120 | c, _ := ReadConsumed(opt, host) |
| 121 | if c == nil || c.Completed != 5 || !c.CompletedFailed || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 5, Target: "vbad"}) { |
| 122 | t.Fatalf("consumed = %+v (resolved %+v): the file's completion was lost", c, c.Resolved) |
| 123 | } |
| 124 | if c.Account != OpsAccount || c.App != "km" || c.Reason != string(ReasonManual) { |
| 125 | t.Fatalf("consumed names %q/%q, reason %q", c.Account, c.App, c.Reason) |
| 126 | } |
| 127 | }) |
| 128 | |
| 129 | t.Run("a resolution only the file holds is followed", func(t *testing.T) { |
| 130 | opt, reg := t.TempDir(), testRegistry(t) |
| 131 | const host = "b.example.com" |
| 132 | settingsRoute(t, reg, host, "km", "canary/x", manual5, state) |
| 133 | legacy := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 5, Target: "v2"}, Completed: 4, |
| 134 | Desired: "v2", Reason: string(ReasonRequest), Detail: "v2"} |
| 135 | if err := WriteConsumed(opt, legacy); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | passWithSettings(t, reg, s, pub, opt) |
| 139 | if v, _ := LiveVersion(opt, host); v != "v2" { |
| 140 | t.Fatalf("live %q, want v2: request 5 was resolved to v2, not re-resolved onto the head", v) |
| 141 | } |
| 142 | c, _ := ReadConsumed(opt, host) |
| 143 | if c == nil || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 5, Target: "v2"}) { |
| 144 | t.Fatalf("consumed = %+v: the resolution was lost", c) |
| 145 | } |
| 146 | }) |
| 147 | } |
| 148 | |
| 149 | // NextConsumed itself never copies another tenant's bookkeeping, whatever |
| 150 | // its caller read: a tagged foreign file starts the next record empty, and |
| 151 | // an untagged file is carried as today. |
| 152 | func TestNextConsumedStartsEmptyOverAForeignFile(t *testing.T) { |
| 153 | const host = "a.example.com" |
| 154 | route := registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog"} |
| 155 | known := BuildStateCopy{Known: true, Replica: true} |
| 156 | dec := TargetDecision{Version: "v1", Reason: ReasonAdopt, Detail: "v1"} |
| 157 | bookkeeping := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 7, Target: "v0"}, Completed: 6, CompletedFailed: true, Baking: "v9"} |
| 158 | |
| 159 | foreign := bookkeeping |
| 160 | foreign.Account, foreign.App = "cdf", "km" |
| 161 | got := NextConsumed(TargetInput{Route: route, Build: known, Consumed: &foreign}, dec) |
| 162 | want := Consumed{Host: host, Account: "dee", App: "blog", Desired: "v1", Reason: "adopt", Detail: "v1", Settings: SettingsDigest("")} |
| 163 | if !reflect.DeepEqual(got, want) { |
| 164 | t.Fatalf("over A's file:\n got %+v\nwant %+v", got, want) |
| 165 | } |
| 166 | |
| 167 | sameAccount := bookkeeping |
| 168 | sameAccount.Account, sameAccount.App = "dee", "km" |
| 169 | if got := NextConsumed(TargetInput{Route: route, Build: known, Consumed: &sameAccount}, dec); got.Completed != 0 || got.Resolved != nil { |
| 170 | t.Fatalf("another app on the same account passed its bookkeeping on: %+v", got) |
| 171 | } |
| 172 | |
| 173 | legacy := bookkeeping |
| 174 | got = NextConsumed(TargetInput{Route: route, Build: known, Consumed: &legacy}, dec) |
| 175 | if got.Completed != 6 || !got.CompletedFailed || got.Resolved == nil || *got.Resolved != *legacy.Resolved { |
| 176 | t.Fatalf("an untagged file lost its bookkeeping: %+v", got) |
| 177 | } |
| 178 | if got.Account != "dee" || got.App != "blog" { |
| 179 | t.Fatalf("the untagged file was not tagged with the route's tenant: %q/%q", got.Account, got.App) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // ForeignTo is the rule every reader shares: tagged, and for another tenant. |
| 184 | func TestConsumedForeignTo(t *testing.T) { |
| 185 | rt := registry.Route{Host: "a.example.com", Account: "dee", App: "blog"} |
| 186 | cases := []struct { |
| 187 | name string |
| 188 | c *Consumed |
| 189 | want bool |
| 190 | }{ |
| 191 | {"no file", nil, false}, |
| 192 | {"untagged", &Consumed{}, false}, |
| 193 | {"the route's tenant", &Consumed{Account: "dee", App: "blog"}, false}, |
| 194 | {"another app", &Consumed{Account: "dee", App: "km"}, true}, |
| 195 | {"another account", &Consumed{Account: "cdf", App: "blog"}, true}, |
| 196 | {"half tagged, another account", &Consumed{Account: "cdf"}, true}, |
| 197 | {"half tagged, the route's app", &Consumed{App: "blog"}, true}, |
| 198 | } |
| 199 | for _, c := range cases { |
| 200 | if got := c.c.ForeignTo(rt); got != c.want { |
| 201 | t.Errorf("%s: ForeignTo = %v, want %v", c.name, got, c.want) |
| 202 | } |
| 203 | } |
| 204 | // A route with no valid account is the ops account's, as root tags it. |
| 205 | ops := registry.Route{Host: "a.example.com", App: "km"} |
| 206 | if (&Consumed{Account: OpsAccount, App: "km"}).ForeignTo(ops) { |
| 207 | t.Error("the ops account's own file is foreign to its route") |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // The registry's Upsert compares tenants by registry.EffectiveAccount (Astra |
| 212 | // r4 finding 2). It must be RouteAccount on the account, or the registry |
| 213 | // keeps one tenant's settings columns for a route root reads as another's, |
| 214 | // or drops them for one root reads as the same. |
| 215 | func TestRegistryEffectiveAccountIsRouteAccount(t *testing.T) { |
| 216 | for _, a := range []string{"", OpsAccount, "bes", "cdf", "ab", "ABC", "ab1", "abcdefghijklmnopq", "abcdefghijklmnop"} { |
| 217 | if got, want := registry.EffectiveAccount(a), RouteAccount(registry.Route{Account: a}); got != want { |
| 218 | t.Fatalf("account %q: registry.EffectiveAccount %q, RouteAccount %q", a, got, want) |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |