| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "reflect" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | "github.com/carlosframework/platform/internal/updatewords" |
| 18 | ) |
| 19 | |
| 20 | func settingsRoute(t *testing.T, reg *registry.Registry, host, app, channel string, u UpdateSettings, bs BuildStateCopy) { |
| 21 | t.Helper() |
| 22 | addRoute(t, reg, host, app, channel, true) |
| 23 | if err := reg.SetUpdateSettings(host, u.Column()); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if err := reg.SetBuildState(host, bs.Column()); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func passWithSettings(t *testing.T, reg *registry.Registry, s Store, pub []byte, opt string, more ...PassOption) string { |
| 32 | t.Helper() |
| 33 | var out bytes.Buffer |
| 34 | withCapturedStdout(t, &out, func() { |
| 35 | opts := append([]PassOption{WithUpdateSettings(nil, nil, true)}, more...) |
| 36 | if err := Pass(context.Background(), reg, s, pub, opt, nil, opts...); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | }) |
| 40 | return out.String() |
| 41 | } |
| 42 | |
| 43 | // Spec §8 "Root pass": a manual instance on a fresh box installs the build |
| 44 | // state's build, not the head. |
| 45 | func TestAManualInstanceOnAFreshBoxInstallsItsLastBuild(t *testing.T) { |
| 46 | s := NewDirStore(t.TempDir()) |
| 47 | ctx := context.Background() |
| 48 | priv, pub := testKeys(t) |
| 49 | opt, reg := t.TempDir(), testRegistry(t) |
| 50 | for _, v := range []string{"v1", "v2"} { |
| 51 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 52 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | } |
| 56 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}, |
| 57 | BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}) |
| 58 | passWithSettings(t, reg, s, pub, opt) |
| 59 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 60 | t.Fatalf("live %q, want v1 (the data's build), not the head", v) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // Spec §8: an existing instance with a replica but no build state is held as |
| 65 | // unknown, never moved to the head. |
| 66 | func TestAnUnknownBuildInstallsNothing(t *testing.T) { |
| 67 | s := NewDirStore(t.TempDir()) |
| 68 | ctx := context.Background() |
| 69 | priv, pub := testKeys(t) |
| 70 | opt, reg := t.TempDir(), testRegistry(t) |
| 71 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 72 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}, BuildStateCopy{Known: true, Replica: true}) |
| 76 | passWithSettings(t, reg, s, pub, opt) |
| 77 | if v, _ := LiveVersion(opt, "a.example.com"); v != "" { |
| 78 | t.Fatalf("live %q for an unknown build", v) |
| 79 | } |
| 80 | if c, _ := ReadConsumed(opt, "a.example.com"); c == nil || c.Reason != string(ReasonUnknown) { |
| 81 | t.Fatalf("consumed = %+v; want reason unknown recorded", c) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // Spec §8 "Evidence": a pin of a pruned and reused version name, with a |
| 86 | // replayed pointer, is refused for lack of a matching record. |
| 87 | func TestAPinNeedsAMatchingRecord(t *testing.T) { |
| 88 | s := NewDirStore(t.TempDir()) |
| 89 | ctx := context.Background() |
| 90 | priv, pub := testKeys(t) |
| 91 | opt, reg := t.TempDir(), testRegistry(t) |
| 92 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("promoted")) |
| 93 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2")) |
| 97 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these")) |
| 104 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 105 | out := passWithSettings(t, reg, s, pub, opt) |
| 106 | if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" { |
| 107 | t.Fatal("the pin installed a reused name's new bytes") |
| 108 | } |
| 109 | if !strings.Contains(out, updatewords.Text("settings.evidence.no-record")) { |
| 110 | t.Fatalf("no refusal:\n%s", out) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // §4.1, invariant 7: a record whose digest the reshipped manifest does not |
| 115 | // match is a refusal, never a fallback to unbound staging. |
| 116 | func TestAPinWithAMismatchedRecordIsRefused(t *testing.T) { |
| 117 | s := NewDirStore(t.TempDir()) |
| 118 | ctx := context.Background() |
| 119 | priv, pub := testKeys(t) |
| 120 | opt, reg := t.TempDir(), testRegistry(t) |
| 121 | for _, v := range []string{"v1", "v2"} { |
| 122 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 123 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | } |
| 127 | reship(t, s, "bab", "km", "v1", []byte("not what was promoted")) // the record stays |
| 128 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 129 | out := passWithSettings(t, reg, s, pub, opt) |
| 130 | if v, _ := LiveVersion(opt, "a.example.com"); v != "" { |
| 131 | t.Fatalf("live %q: the pin installed bytes its record does not bind", v) |
| 132 | } |
| 133 | if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| 134 | t.Fatal("the reshipped bytes were staged") |
| 135 | } |
| 136 | if !strings.Contains(out, "a.example.com") { |
| 137 | t.Fatalf("no refusal line:\n%s", out) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Spec §8: a build promoted before the feature is recovered from a staged |
| 142 | // copy, refused elsewhere. |
| 143 | func TestAPreFeatureBuildIsRecoveredFromAStagedCopy(t *testing.T) { |
| 144 | s := NewDirStore(t.TempDir()) |
| 145 | ctx := context.Background() |
| 146 | priv, pub := testKeys(t) |
| 147 | opt, reg := t.TempDir(), testRegistry(t) |
| 148 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 149 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // promoted "before the feature" |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2")) |
| 156 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}, |
| 160 | BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}) |
| 161 | passWithSettings(t, reg, s, pub, opt) // no staged copy: refused |
| 162 | if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" { |
| 163 | t.Fatal("recovered with neither a record nor a staged copy") |
| 164 | } |
| 165 | if _, err := ensureStaged(ctx, s, opt, "bab", "km", "v1", "seed"); err != nil { // the box already had it |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | // A staged copy whose bytes changed since it was verified recovers nothing. |
| 169 | path := VersionPath(opt, "bab", "v1", "km") |
| 170 | good, err := os.ReadFile(path) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if err := os.WriteFile(path, []byte("tampered"), 0o755); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | passWithSettings(t, reg, s, pub, opt) |
| 178 | if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" { |
| 179 | t.Fatal("recovered from a staged copy that no longer matches its manifest") |
| 180 | } |
| 181 | if err := os.WriteFile(path, good, 0o755); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | passWithSettings(t, reg, s, pub, opt) |
| 185 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 186 | t.Fatalf("live %q, want v1 recovered from the staged copy", v) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // Spec §8 "Root pass": an install request is consumed exactly once. |
| 191 | func TestAnInstallRequestResolvesOnceAndWaitsOnBake(t *testing.T) { |
| 192 | s := NewDirStore(t.TempDir()) |
| 193 | ctx := context.Background() |
| 194 | priv, pub := testKeys(t) |
| 195 | opt, reg := t.TempDir(), testRegistry(t) |
| 196 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 197 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1}, |
| 201 | BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v0"}}) |
| 202 | passWithSettings(t, reg, s, pub, opt) |
| 203 | c, _ := ReadConsumed(opt, "a.example.com") |
| 204 | if c == nil || c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v1" { |
| 205 | t.Fatalf("consumed = %+v; want {1, v1} written", c) |
| 206 | } |
| 207 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 208 | t.Fatalf("live %q, want T", v) |
| 209 | } |
| 210 | shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2")) |
| 211 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | passWithSettings(t, reg, s, pub, opt) |
| 215 | if c2, _ := ReadConsumed(opt, "a.example.com"); c2 == nil || c2.Resolved == nil || c2.Resolved.Target != "v1" { |
| 216 | t.Fatalf("the request was resolved again: %+v", c2) |
| 217 | } |
| 218 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 219 | t.Fatalf("live %q, want T (v1) kept, not the new head", v) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // Astra I1 #9: a record-authorized pin installs on a box that cannot read |
| 224 | // the channel pointer. |
| 225 | func TestAPinInstallsWithoutAChannelPointer(t *testing.T) { |
| 226 | s := NewDirStore(t.TempDir()) |
| 227 | ctx := context.Background() |
| 228 | priv, pub := testKeys(t) |
| 229 | opt, reg := t.TempDir(), testRegistry(t) |
| 230 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 231 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | if err := s.Delete(ctx, ChannelKey("bab", "km", "canary/x")); err != nil { // the pointer is gone |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 238 | passWithSettings(t, reg, s, pub, opt) |
| 239 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 240 | t.Fatalf("live %q, want the pin v1 with no pointer", v) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // Astra I1 #8: a request waiting on bake is reported beside a major hold, |
| 245 | // and never replaces it; once the window passes, root resolves it to the |
| 246 | // head and installs it. |
| 247 | func TestABakingRequestKeepsTheHeldReason(t *testing.T) { |
| 248 | s := NewDirStore(t.TempDir()) |
| 249 | ctx := context.Background() |
| 250 | priv, pub := testKeys(t) |
| 251 | opt, reg := t.TempDir(), testRegistry(t) |
| 252 | ratchetedProductionApp(t, s, ctx, priv, pub, opt, reg) // adopt_test.go: myapp's production head v2 inside its 24h window on this box |
| 253 | settingsRoute(t, reg, "held.example.com", "myapp", "production", UpdateSettings{Updates: UpdatesManual, Channel: "production", InstallRequest: 1}, |
| 254 | BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}) |
| 255 | // A new held instance's first head bakes too (§4.3 rule 3), as rule 4 |
| 256 | // does for p.example.com, the fixture's default route. |
| 257 | settingsRoute(t, reg, "new.example.com", "myapp", "production", UpdateSettings{Updates: UpdatesManual, Channel: "production"}, |
| 258 | BuildStateCopy{Known: true}) |
| 259 | passWithSettings(t, reg, s, pub, opt) |
| 260 | c, _ := ReadConsumed(opt, "held.example.com") |
| 261 | if c == nil || c.Reason != string(ReasonManual) || c.Baking != "v2" || c.Resolved != nil { |
| 262 | t.Fatalf("consumed = %+v; want reason manual, the baking head v2 beside it, nothing resolved", c) |
| 263 | } |
| 264 | if v, _ := LiveVersion(opt, "held.example.com"); v != "v1" { |
| 265 | t.Fatalf("live %q while baking, want the held build v1", v) |
| 266 | } |
| 267 | for _, h := range []string{"new.example.com", "p.example.com"} { |
| 268 | if v, _ := LiveVersion(opt, h); v == "v2" { |
| 269 | t.Fatalf("%s live on the baking head v2", h) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // The window passes on this box's own clock. |
| 274 | key := channelClockKey("bab", "myapp", "production") |
| 275 | st := LoadFirstSeen(FirstSeenPath(opt)) |
| 276 | seen, ok := st.Channels[key]["v2"] |
| 277 | if !ok { |
| 278 | t.Fatal("no first sighting recorded for v2") |
| 279 | } |
| 280 | st.Channels[key]["v2"] = seen.Add(-31 * time.Hour) |
| 281 | if err := st.Save(); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | passWithSettings(t, reg, s, pub, opt) |
| 285 | c, _ = ReadConsumed(opt, "held.example.com") |
| 286 | if c == nil || c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v2" || c.Baking != "" || c.Reason != string(ReasonRequest) { |
| 287 | t.Fatalf("consumed = %+v; want {1, v2} resolved, nothing baking, reason request", c) |
| 288 | } |
| 289 | if v, _ := LiveVersion(opt, "held.example.com"); v != "v2" { |
| 290 | t.Fatalf("live %q, want the resolved T v2", v) |
| 291 | } |
| 292 | if v, _ := LiveVersion(opt, "new.example.com"); v != "v2" { |
| 293 | t.Fatalf("new.example.com live %q, want the head v2 once eligible", v) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | func TestDefaultSettingsAreTodaysAdoption(t *testing.T) { |
| 298 | s := NewDirStore(t.TempDir()) |
| 299 | ctx := context.Background() |
| 300 | priv, pub := testKeys(t) |
| 301 | opt, reg := t.TempDir(), testRegistry(t) |
| 302 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 303 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | addRoute(t, reg, "a.example.com", "km", "canary/x", true) |
| 307 | // The same box twice: once without the option (today), once with it. The |
| 308 | // journal and the outcome must match line for line. |
| 309 | optToday := t.TempDir() |
| 310 | var today bytes.Buffer |
| 311 | withCapturedStdout(t, &today, func() { |
| 312 | if err := Pass(ctx, reg, s, pub, optToday, nil); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | }) |
| 316 | out := passWithSettings(t, reg, s, pub, opt) |
| 317 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 318 | t.Fatalf("live %q", v) |
| 319 | } |
| 320 | if c, _ := ReadConsumed(opt, "a.example.com"); c != nil { |
| 321 | t.Fatalf("a default route wrote a consumed file: %+v", c) |
| 322 | } |
| 323 | if strings.ReplaceAll(out, opt, "OPT") != strings.ReplaceAll(today.String(), optToday, "OPT") { |
| 324 | t.Fatalf("journal differs from today's:\nwith settings:\n%s\ntoday:\n%s", out, today.String()) |
| 325 | } |
| 326 | // A failure book never reaches a default unmanaged route, readable or not. |
| 327 | reg2, opt2 := testRegistry(t), t.TempDir() |
| 328 | addRoute(t, reg2, "a.example.com", "km", "canary/x", true) |
| 329 | broken := func() (FailedBuild, error) { return nil, errors.New("swap-failures.json: unreadable") } |
| 330 | var buf bytes.Buffer |
| 331 | withCapturedStdout(t, &buf, func() { |
| 332 | if err := Pass(ctx, reg2, s, pub, opt2, nil, WithUpdateSettings(broken, func(registry.Route) bool { return false }, true)); err != nil { |
| 333 | t.Fatal(err) |
| 334 | } |
| 335 | }) |
| 336 | if v, _ := LiveVersion(opt2, "a.example.com"); v != "v1" { |
| 337 | t.Fatalf("live %q: an unreadable book held a default unmanaged route", v) |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // Astra I2 #8: the proof gate checks the build the decision selected, not |
| 342 | // the channel head. Pinned to v1 with the head at v2: a proof for v2 alone |
| 343 | // installs nothing, and a proof for v1 installs v1. |
| 344 | func TestProofGateSeesTheSelectedBuild(t *testing.T) { |
| 345 | s := NewDirStore(t.TempDir()) |
| 346 | ctx := context.Background() |
| 347 | priv, pub := testKeys(t) |
| 348 | opt, reg, bake := t.TempDir(), testRegistry(t), t.TempDir() |
| 349 | for _, v := range []string{"v1", "v2"} { |
| 350 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 351 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | } |
| 355 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 356 | prover, _, proverPub := proverKey(t) // proof_test.go |
| 357 | policy := filepath.Join(t.TempDir(), "proof-policy.json") |
| 358 | if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"km","prover":"dev","key":%q}]}`, proverPub)), 0o644); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | gate := NewProofGate(policy, bake) |
| 362 | prove := func(v string) { |
| 363 | sha := etagOf([]byte(v)) |
| 364 | p, err := SignProof(prover, Proof{App: "km", Platform: adoptPlatform, SHA256: sha, Version: v, Prover: "dev", |
| 365 | ActiveSince: time.Now().Add(-time.Hour), ProvenAt: time.Now()}) |
| 366 | if err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | b, _ := json.Marshal(p) |
| 370 | if _, err := s.Put(ctx, ProofKey("bab", "km", sha, "dev"), b, ""); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | } |
| 374 | pass := func() { |
| 375 | var out bytes.Buffer |
| 376 | withCapturedStdout(t, &out, func() { |
| 377 | if err := Pass(ctx, reg, s, pub, opt, nil, BakeDir(bake), WithProofGate(gate), WithUpdateSettings(nil, nil, true)); err != nil { |
| 378 | t.Fatal(err) |
| 379 | } |
| 380 | }) |
| 381 | } |
| 382 | prove("v2") // the head is proven, the pinned build is not |
| 383 | pass() |
| 384 | if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" { |
| 385 | t.Fatal("the pinned v1 went live on the head's proof") |
| 386 | } |
| 387 | prove("v1") |
| 388 | gate.clear("a.example.com") // drop the 30-second miss cache |
| 389 | pass() |
| 390 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 391 | t.Fatalf("live %q, want the proven pin v1", v) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // §4.3 rule 2: only root resolves. An agent pass applies the decision, never |
| 396 | // writes the consumed file, and installs nothing a resolution would decide. |
| 397 | func TestAnAgentPassNeverResolves(t *testing.T) { |
| 398 | s := NewDirStore(t.TempDir()) |
| 399 | ctx := context.Background() |
| 400 | priv, pub := testKeys(t) |
| 401 | opt, reg := t.TempDir(), testRegistry(t) |
| 402 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 403 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1}, |
| 407 | BuildStateCopy{Known: true, Replica: true}) |
| 408 | var out bytes.Buffer |
| 409 | withCapturedStdout(t, &out, func() { |
| 410 | if err := Pass(ctx, reg, s, pub, opt, nil, WithUpdateSettings(nil, nil, false)); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | }) |
| 414 | if c, err := ReadConsumed(opt, "a.example.com"); c != nil || err != nil { |
| 415 | t.Fatalf("an agent pass wrote the consumed file: %+v %v", c, err) |
| 416 | } |
| 417 | if v, _ := LiveVersion(opt, "a.example.com"); v != "" { |
| 418 | t.Fatalf("live %q: an agent pass installed an unresolved request's head", v) |
| 419 | } |
| 420 | passWithSettings(t, reg, s, pub, opt) // root |
| 421 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 422 | t.Fatalf("live %q after root's pass, want T v1", v) |
| 423 | } |
| 424 | // The agent now follows root's resolution from the consumed file. |
| 425 | if err := os.Remove(LiveLink(opt, "a.example.com")); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | withCapturedStdout(t, &out, func() { |
| 429 | if err := Pass(ctx, reg, s, pub, opt, nil, WithUpdateSettings(nil, nil, false)); err != nil { |
| 430 | t.Fatal(err) |
| 431 | } |
| 432 | }) |
| 433 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 434 | t.Fatalf("live %q: the agent did not apply root's resolution", v) |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | // §4.3: the failure records outrank every rule, for settings routes. A |
| 439 | // default route keeps today's adoption, which never read the book; an |
| 440 | // unreadable book holds every settings route and every managed route. |
| 441 | func TestTheFailureBookBindsSettingsRoutes(t *testing.T) { |
| 442 | s := NewDirStore(t.TempDir()) |
| 443 | ctx := context.Background() |
| 444 | priv, pub := testKeys(t) |
| 445 | opt, reg := t.TempDir(), testRegistry(t) |
| 446 | for _, v := range []string{"v1", "v2"} { |
| 447 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 448 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | } |
| 452 | settingsRoute(t, reg, "pin.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 453 | addRoute(t, reg, "plain.example.com", "km", "canary/x", true) |
| 454 | // cleared.example.com's settings were cleared after root recorded a held |
| 455 | // decision: rule 4, byte for byte, and the record follows it. |
| 456 | addRoute(t, reg, "cleared.example.com", "km", "canary/x", true) |
| 457 | if err := WriteConsumed(opt, Consumed{Host: "cleared.example.com", Desired: "v1", Reason: string(ReasonManual), Detail: "v1"}); err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | type call struct { |
| 461 | build, host string |
| 462 | served bool |
| 463 | } |
| 464 | var calls []call |
| 465 | book := func() (FailedBuild, error) { |
| 466 | return func(acct, app, build, host string, served bool) bool { |
| 467 | calls = append(calls, call{build, host, served}) |
| 468 | return acct == "bab" && app == "km" && !served |
| 469 | }, nil |
| 470 | } |
| 471 | none := func(registry.Route) bool { return false } |
| 472 | passWithSettings(t, reg, s, pub, opt, WithUpdateSettings(book, none, true)) |
| 473 | if v, _ := LiveVersion(opt, "pin.example.com"); v != "" { |
| 474 | t.Fatalf("live %q: a pin onto a failed build installed", v) |
| 475 | } |
| 476 | if c, _ := ReadConsumed(opt, "pin.example.com"); c == nil || c.Reason != string(ReasonFailed) || c.Detail != "v1" { |
| 477 | t.Fatalf("consumed = %+v; want reason failed naming v1", c) |
| 478 | } |
| 479 | for _, h := range []string{"plain.example.com", "cleared.example.com"} { |
| 480 | if v, _ := LiveVersion(opt, h); v != "v2" { |
| 481 | t.Fatalf("%s live %q: the book reached a default route", h, v) |
| 482 | } |
| 483 | } |
| 484 | if c, _ := ReadConsumed(opt, "cleared.example.com"); c == nil || c.Reason != string(ReasonAdopt) || c.Desired != "v2" { |
| 485 | t.Fatalf("cleared consumed = %+v; want root's record to follow rule 4", c) |
| 486 | } |
| 487 | for _, c := range calls { |
| 488 | if c.host != "pin.example.com" { |
| 489 | t.Fatalf("the book was asked about %s", c.host) |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | // served is v == GoodVersion. |
| 494 | calls = nil |
| 495 | if err := reg.SetGoodBuild("pin.example.com", registry.GoodBuild{}, registry.GoodBuild{Version: "v1", Digest: etagOf([]byte("v1"))}); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | passWithSettings(t, reg, s, pub, opt, WithUpdateSettings(book, none, true)) |
| 499 | if v, _ := LiveVersion(opt, "pin.example.com"); v != "v1" { |
| 500 | t.Fatalf("live %q: a build the instance served is not blocked", v) |
| 501 | } |
| 502 | if len(calls) == 0 || !calls[0].served { |
| 503 | t.Fatalf("calls = %+v; want served for the GoodVersion build", calls) |
| 504 | } |
| 505 | |
| 506 | // An unreadable book: nothing for a settings route or a managed default |
| 507 | // route, today's adoption for an unmanaged default one; no consumed write. |
| 508 | opt2, reg2 := t.TempDir(), testRegistry(t) |
| 509 | settingsRoute(t, reg2, "pin.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 510 | addRoute(t, reg2, "managed.example.com", "km", "canary/x", true) |
| 511 | addRoute(t, reg2, "plain.example.com", "km", "canary/x", true) |
| 512 | broken := func() (FailedBuild, error) { return nil, errors.New("swap-failures.json: unreadable") } |
| 513 | managed := func(rt registry.Route) bool { return rt.Host == "managed.example.com" } |
| 514 | out := passWithSettings(t, reg2, s, pub, opt2, WithUpdateSettings(broken, managed, true)) |
| 515 | for _, h := range []string{"pin.example.com", "managed.example.com"} { |
| 516 | if v, _ := LiveVersion(opt2, h); v != "" { |
| 517 | t.Fatalf("%s live %q with an unreadable failure book", h, v) |
| 518 | } |
| 519 | if c, _ := ReadConsumed(opt2, h); c != nil { |
| 520 | t.Fatalf("%s: an unreadable book wrote a decision: %+v", h, c) |
| 521 | } |
| 522 | } |
| 523 | if v, _ := LiveVersion(opt2, "plain.example.com"); v != "v2" { |
| 524 | t.Fatalf("plain live %q, want today's adoption", v) |
| 525 | } |
| 526 | if !strings.Contains(out, "failure book unreadable") { |
| 527 | t.Fatalf("no line for the unreadable book:\n%s", out) |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | // R-MERGE: the consumed-file update is one pure function, the one root runs |
| 532 | // and the simulation runs. |
| 533 | func TestNextConsumed(t *testing.T) { |
| 534 | head := &ChannelPointer{Channel: "stable", Version: "v9"} |
| 535 | known := func(st *BuildState) BuildStateCopy { return BuildStateCopy{Known: true, Replica: true, State: st} } |
| 536 | manual := UpdateSettings{Updates: UpdatesManual} |
| 537 | req := func(id uint64) UpdateSettings { return UpdateSettings{Updates: UpdatesManual, InstallRequest: id} } |
| 538 | at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| 539 | cases := []struct { |
| 540 | name string |
| 541 | in TargetInput |
| 542 | dec TargetDecision |
| 543 | want Consumed |
| 544 | }{ |
| 545 | { |
| 546 | name: "fresh: the decision only", |
| 547 | in: TargetInput{Pointer: head, Settings: manual, Build: known(&BuildState{LastExecuted: "v1"})}, |
| 548 | dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"}, |
| 549 | want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1"}, |
| 550 | }, |
| 551 | { |
| 552 | name: "a resolution is recorded", |
| 553 | in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil)}, |
| 554 | dec: TargetDecision{Version: "v9", Reason: ReasonRequest, Resolve: &ResolvedRequest{ID: 3, Target: "v9"}, Detail: "v9"}, |
| 555 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v9"}, Desired: "v9", Reason: "request", Detail: "v9"}, |
| 556 | }, |
| 557 | { |
| 558 | name: "a failed T completes above the old watermark", |
| 559 | in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil), |
| 560 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 2}}, |
| 561 | dec: TargetDecision{Reason: ReasonManual, Fail: 3}, |
| 562 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3, CompletedFailed: true, Reason: "manual"}, |
| 563 | }, |
| 564 | { |
| 565 | name: "a completed T that failed later is marked once, not reopened", |
| 566 | in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil), |
| 567 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3}}, |
| 568 | dec: TargetDecision{Reason: ReasonFailed, Fail: 3, Detail: "v8"}, |
| 569 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3, CompletedFailed: true, Reason: "failed", Detail: "v8"}, |
| 570 | }, |
| 571 | { |
| 572 | name: "an older failure never lowers the watermark", |
| 573 | in: TargetInput{Pointer: head, Settings: req(5), Build: known(nil), |
| 574 | Consumed: &Consumed{Host: "a.example.com", Completed: 5}}, |
| 575 | dec: TargetDecision{Reason: ReasonManual, Fail: 4}, |
| 576 | want: Consumed{Host: "a.example.com", Completed: 5, Reason: "manual"}, |
| 577 | }, |
| 578 | { |
| 579 | name: "R-TARGET-FAILEDHEAD: resolved and completed failed in one write", |
| 580 | in: TargetInput{Pointer: head, Settings: req(4), Build: known(&BuildState{LastExecuted: "v1"})}, |
| 581 | dec: TargetDecision{Version: "v1", Reason: ReasonManual, Resolve: &ResolvedRequest{ID: 4, Target: "v9"}, Fail: 4, Detail: "v1"}, |
| 582 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 4, Target: "v9"}, Completed: 4, CompletedFailed: true, Desired: "v1", Reason: "manual", Detail: "v1"}, |
| 583 | }, |
| 584 | { |
| 585 | name: "baking is reported beside the held reason", |
| 586 | in: TargetInput{Pointer: head, HeadHeld: "in its supply-chain bake window", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1"})}, |
| 587 | dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"}, |
| 588 | want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1", Baking: "v9"}, |
| 589 | }, |
| 590 | { |
| 591 | name: "baking for a new request beside an older, completed resolution", |
| 592 | in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1", Completed: 1}), |
| 593 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}, Completed: 1}}, |
| 594 | dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"}, |
| 595 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}, Completed: 1, Desired: "v1", Reason: "manual", Detail: "v1", Baking: "v9"}, |
| 596 | }, |
| 597 | { |
| 598 | name: "no baking while the build state is unread", |
| 599 | in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: BuildStateCopy{}}, |
| 600 | dec: TargetDecision{Reason: ReasonUnknown}, |
| 601 | want: Consumed{Host: "a.example.com", Reason: "unknown"}, |
| 602 | }, |
| 603 | { |
| 604 | name: "no baking for a fast-tracked request", |
| 605 | in: TargetInput{Pointer: head, HeadHeld: "held", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2, InstallFastTrack: true}, Build: known(nil)}, |
| 606 | dec: TargetDecision{Version: "v9", Reason: ReasonRequest, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Detail: "v9"}, |
| 607 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Desired: "v9", Reason: "request", Detail: "v9"}, |
| 608 | }, |
| 609 | { |
| 610 | name: "no baking once R has a T", |
| 611 | in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{Resolved: &ResolvedRequest{ID: 2, Target: "v8"}}), |
| 612 | Consumed: &Consumed{Host: "a.example.com", Baking: "v8"}}, |
| 613 | dec: TargetDecision{Version: "v8", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v8"}, |
| 614 | want: Consumed{Host: "a.example.com", Desired: "v8", Reason: "request", Detail: "v8"}, |
| 615 | }, |
| 616 | { |
| 617 | name: "no baking for a completed request; a stale At is dropped", |
| 618 | in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{Completed: 2}), |
| 619 | Consumed: &Consumed{Host: "a.example.com", Baking: "v9", Desired: "v1", Reason: "manual", At: at}}, |
| 620 | dec: TargetDecision{Version: "v9", Reason: ReasonAdopt, Bake: true, Detail: "v9"}, |
| 621 | want: Consumed{Host: "a.example.com", Desired: "v9", Reason: "adopt", Detail: "v9"}, |
| 622 | }, |
| 623 | { |
| 624 | name: "R-RESOLUTION-STATE-WINS: a file disagreeing with a read build state takes its T", |
| 625 | in: TargetInput{Pointer: head, Settings: req(3), Build: known(&BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 3, Target: "v4"}}), |
| 626 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}}, |
| 627 | dec: TargetDecision{Version: "v4", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v4"}, |
| 628 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v4"}, Desired: "v4", Reason: "request", Detail: "v4"}, |
| 629 | }, |
| 630 | { |
| 631 | name: "R-RESOLUTION-STATE-WINS: an unread build state leaves the file alone", |
| 632 | in: TargetInput{Pointer: head, Settings: req(3), Build: BuildStateCopy{}, |
| 633 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}}, |
| 634 | dec: TargetDecision{Reason: ReasonUnknown}, |
| 635 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}, Reason: "unknown"}, |
| 636 | }, |
| 637 | { |
| 638 | name: "R-RESOLUTION-STATE-WINS: another request's resolution in the build state changes nothing", |
| 639 | in: TargetInput{Pointer: head, Settings: req(3), Build: known(&BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v4"}, Completed: 2}), |
| 640 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}}, |
| 641 | dec: TargetDecision{Version: "v1", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v1"}, |
| 642 | want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}, Desired: "v1", Reason: "request", Detail: "v1"}, |
| 643 | }, |
| 644 | { |
| 645 | name: "no baking with no readable head", |
| 646 | in: TargetInput{HeadHeld: "", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1"})}, |
| 647 | dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"}, |
| 648 | want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1"}, |
| 649 | }, |
| 650 | } |
| 651 | for _, tc := range cases { |
| 652 | t.Run(tc.name, func(t *testing.T) { |
| 653 | tc.in.Route = registry.Route{Host: "a.example.com", UpdateSettings: tc.in.Settings.Column()} |
| 654 | // Every decision names the column it was made for (R-HELD-PURE). |
| 655 | tc.want.Settings = SettingsDigest(tc.in.Settings.Column()) |
| 656 | // And the route's tenant (R22): a route with no account of its |
| 657 | // own is the platform's. |
| 658 | tc.want.Account = OpsAccount |
| 659 | var before *Consumed |
| 660 | if tc.in.Consumed != nil { |
| 661 | cp := *tc.in.Consumed |
| 662 | before = &cp |
| 663 | } |
| 664 | got := NextConsumed(tc.in, tc.dec) |
| 665 | if !reflect.DeepEqual(got, tc.want) { |
| 666 | t.Fatalf("NextConsumed =\n %+v\nwant\n %+v", got, tc.want) |
| 667 | } |
| 668 | if before != nil && !reflect.DeepEqual(*tc.in.Consumed, *before) { |
| 669 | t.Fatalf("NextConsumed changed its input: %+v", *tc.in.Consumed) |
| 670 | } |
| 671 | if got.Resolved != nil && tc.in.Consumed != nil && got.Resolved == tc.in.Consumed.Resolved { |
| 672 | t.Fatal("NextConsumed shares the input's Resolved") |
| 673 | } |
| 674 | }) |
| 675 | } |
| 676 | } |
| 677 | |
| 678 | // installAt stages ver of km on this box and points host at it, as an |
| 679 | // earlier pass would have. |
| 680 | func installAt(t *testing.T, s Store, opt, host, ver string) { |
| 681 | t.Helper() |
| 682 | if _, err := ensureStaged(context.Background(), s, opt, "bab", "km", ver, host); err != nil { |
| 683 | t.Fatal(err) |
| 684 | } |
| 685 | if err := PointLive(opt, host, "bab", ver, "km"); err != nil { |
| 686 | t.Fatal(err) |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | // R-SETTINGS-UNREADABLE (a): a control name this binary does not know (a |
| 691 | // newer console's) is ignored by root; the rest of the column still holds. |
| 692 | func TestAnUnknownControlNameKeepsTheHold(t *testing.T) { |
| 693 | s := NewDirStore(t.TempDir()) |
| 694 | ctx := context.Background() |
| 695 | priv, pub := testKeys(t) |
| 696 | opt, reg := t.TempDir(), testRegistry(t) |
| 697 | for _, v := range []string{"v1", "v2"} { |
| 698 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 699 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 700 | t.Fatal(err) |
| 701 | } |
| 702 | } |
| 703 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{}, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}) |
| 704 | if err := reg.SetUpdateSettings("a.example.com", `{"updates":"manual","channel":"canary/x","control":["schedule","pin","schedule"]}`); err != nil { |
| 705 | t.Fatal(err) |
| 706 | } |
| 707 | installAt(t, s, opt, "a.example.com", "v1") |
| 708 | passWithSettings(t, reg, s, pub, opt) |
| 709 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 710 | t.Fatalf("live %q: an unknown control name lifted the manual hold", v) |
| 711 | } |
| 712 | if c, _ := ReadConsumed(opt, "a.example.com"); c == nil || c.Reason != string(ReasonManual) { |
| 713 | t.Fatalf("consumed = %+v; want reason manual", c) |
| 714 | } |
| 715 | u, err := ParseUpdateSettingsLenient(`{"updates":"manual","control":["schedule","pin","pin"]}`) |
| 716 | if err != nil || u.Updates != UpdatesManual || !reflect.DeepEqual(u.Control, []string{ControlPin}) { |
| 717 | t.Fatalf("lenient parse = %+v, %v; want manual with control [pin]", u, err) |
| 718 | } |
| 719 | if _, err := ParseUpdateSettings(`{"updates":"manual","control":["schedule"]}`); err == nil { |
| 720 | t.Fatal("the strict parse (the console's) accepted an unknown control name") |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | // R-SETTINGS-UNREADABLE (b): a column that still does not parse holds the |
| 725 | // route where it is, and root records reason unknown for the wake check. |
| 726 | func TestAnUnreadableSettingsColumnHolds(t *testing.T) { |
| 727 | s := NewDirStore(t.TempDir()) |
| 728 | ctx := context.Background() |
| 729 | priv, pub := testKeys(t) |
| 730 | opt, reg := t.TempDir(), testRegistry(t) |
| 731 | for _, v := range []string{"v1", "v2"} { |
| 732 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 733 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 734 | t.Fatal(err) |
| 735 | } |
| 736 | } |
| 737 | for host, col := range map[string]string{ |
| 738 | "later.example.com": `{"updates":"scheduled","channel":"canary/x"}`, |
| 739 | "garbage.example.com": `{not json`, |
| 740 | } { |
| 741 | settingsRoute(t, reg, host, "km", "canary/x", UpdateSettings{}, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}) |
| 742 | if err := reg.SetUpdateSettings(host, col); err != nil { |
| 743 | t.Fatal(err) |
| 744 | } |
| 745 | } |
| 746 | installAt(t, s, opt, "later.example.com", "v1") |
| 747 | var agent bytes.Buffer |
| 748 | withCapturedStdout(t, &agent, func() { |
| 749 | if err := Pass(ctx, reg, s, pub, opt, nil, WithUpdateSettings(nil, nil, false)); err != nil { |
| 750 | t.Fatal(err) |
| 751 | } |
| 752 | }) |
| 753 | if strings.Contains(agent.String(), "update settings") { |
| 754 | t.Fatalf("an agent pass printed the settings line:\n%s", agent.String()) |
| 755 | } |
| 756 | if c, _ := ReadConsumed(opt, "later.example.com"); c != nil { |
| 757 | t.Fatalf("an agent pass wrote %+v", c) |
| 758 | } |
| 759 | out := passWithSettings(t, reg, s, pub, opt) |
| 760 | for host, want := range map[string]string{"later.example.com": "v1", "garbage.example.com": ""} { |
| 761 | if v, _ := LiveVersion(opt, host); v != want { |
| 762 | t.Fatalf("%s live %q, want %q kept", host, v, want) |
| 763 | } |
| 764 | c, err := ReadConsumed(opt, host) |
| 765 | if err != nil || c == nil || c.Reason != string(ReasonUnknown) || !strings.Contains(c.Detail, "update settings") || c.Desired != "" { |
| 766 | t.Fatalf("%s consumed = %+v, %v; want reason unknown naming the parse problem", host, c, err) |
| 767 | } |
| 768 | } |
| 769 | if !strings.Contains(out, "update settings unreadable") { |
| 770 | t.Fatalf("root printed no line:\n%s", out) |
| 771 | } |
| 772 | } |
| 773 | |
| 774 | // R-AGENT-RELAY: an agent pass that stops for root's resolution asks root |
| 775 | // for a pass, once, through the stage relay, with the request's sentinel. |
| 776 | func TestAnAgentPassRelaysAPendingRequestToRoot(t *testing.T) { |
| 777 | s := NewDirStore(t.TempDir()) |
| 778 | ctx := context.Background() |
| 779 | priv, pub := testKeys(t) |
| 780 | opt, reg := t.TempDir(), testRegistry(t) |
| 781 | shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 782 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 783 | t.Fatal(err) |
| 784 | } |
| 785 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1}, |
| 786 | BuildStateCopy{Known: true, Replica: true}) |
| 787 | type relay struct { |
| 788 | host, ver string |
| 789 | err error |
| 790 | } |
| 791 | var got []relay |
| 792 | var out bytes.Buffer |
| 793 | withCapturedStdout(t, &out, func() { |
| 794 | err := MaterializeHost(ctx, reg, s, pub, opt, t.TempDir(), "a.example.com", |
| 795 | OnStageBlocked(func(h, v string, err error) { got = append(got, relay{h, v, err}) }), |
| 796 | WithUpdateSettings(nil, nil, false)) |
| 797 | if err == nil { |
| 798 | t.Fatal("materialized a request root has not resolved") |
| 799 | } |
| 800 | }) |
| 801 | if len(got) != 1 || got[0].host != "a.example.com" || got[0].ver != "v1" || !errors.Is(got[0].err, ErrInstallRequestNeedsRoot) { |
| 802 | t.Fatalf("relays = %+v; want exactly one for a.example.com v1 with ErrInstallRequestNeedsRoot", got) |
| 803 | } |
| 804 | // Root's pass resolves; the relay's head is then installed. |
| 805 | passWithSettings(t, reg, s, pub, opt) |
| 806 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 807 | t.Fatalf("live %q after root's pass", v) |
| 808 | } |
| 809 | } |
| 810 | |
| 811 | // R-T6-MISC fix round 1, amended by Task 12a: the up-to-date refusal clear |
| 812 | // judges both the selected build and the head. Pinned and live on v1: a |
| 813 | // refusal of the head v2 that is still current is kept (unpinning would |
| 814 | // otherwise retry a refused promotion once); a refusal of v1 itself (its |
| 815 | // rollback failed) stays too. |
| 816 | func TestTheLiveBuildsRefusalIsJudgedOnTheSelectedBuild(t *testing.T) { |
| 817 | s := NewDirStore(t.TempDir()) |
| 818 | ctx := context.Background() |
| 819 | priv, pub := testKeys(t) |
| 820 | opt, reg, bake := t.TempDir(), testRegistry(t), t.TempDir() |
| 821 | for _, v := range []string{"v1", "v2"} { |
| 822 | shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| 823 | if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| 824 | t.Fatal(err) |
| 825 | } |
| 826 | } |
| 827 | head, err := Channel(ctx, s, pub, "bab", "km", "canary/x") |
| 828 | if err != nil { |
| 829 | t.Fatal(err) |
| 830 | } |
| 831 | settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| 832 | installAt(t, s, opt, "a.example.com", "v1") |
| 833 | pass := func() { |
| 834 | var out bytes.Buffer |
| 835 | withCapturedStdout(t, &out, func() { |
| 836 | if err := Pass(ctx, reg, s, pub, opt, nil, BakeDir(bake), WithUpdateSettings(nil, nil, true)); err != nil { |
| 837 | t.Fatal(err) |
| 838 | } |
| 839 | }) |
| 840 | } |
| 841 | if err := WriteRefusal(bake, Refusal{Host: "a.example.com", App: "km", Version: "v2", PointerPromotedAt: head.PromotedAt, Reason: "did not come up"}); err != nil { |
| 842 | t.Fatal(err) |
| 843 | } |
| 844 | pass() |
| 845 | if r, err := ReadRefusal(bake, "a.example.com"); err != nil || r == nil || r.Version != "v2" { |
| 846 | t.Fatalf("refusal = %+v, %v; the pin cleared a refusal of the head that is still current", r, err) |
| 847 | } |
| 848 | // A refusal of an earlier promotion of v2 blocks neither build: cleared. |
| 849 | if err := WriteRefusal(bake, Refusal{Host: "a.example.com", App: "km", Version: "v2", PointerPromotedAt: head.PromotedAt.Add(-time.Hour), Reason: "did not come up"}); err != nil { |
| 850 | t.Fatal(err) |
| 851 | } |
| 852 | pass() |
| 853 | if r, err := ReadRefusal(bake, "a.example.com"); err != nil || r != nil { |
| 854 | t.Fatalf("refusal = %+v, %v; a refusal that blocks nothing was kept", r, err) |
| 855 | } |
| 856 | if err := WriteRefusal(bake, Refusal{Host: "a.example.com", App: "km", Version: "v1", Reason: "did not come up"}); err != nil { |
| 857 | t.Fatal(err) |
| 858 | } |
| 859 | pass() |
| 860 | if r, err := ReadRefusal(bake, "a.example.com"); err != nil || r == nil || r.Version != "v1" { |
| 861 | t.Fatalf("refusal = %+v, %v; the live build's own refusal was cleared", r, err) |
| 862 | } |
| 863 | if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| 864 | t.Fatalf("live %q", v) |
| 865 | } |
| 866 | } |
| 867 | |
| 868 | // failingGets is a Store whose Get of any key in fail returns err, as a |
| 869 | // bucket that cannot be read at that moment. |
| 870 | type failingGets struct { |
| 871 | Store |
| 872 | fail map[string]bool |
| 873 | err error |
| 874 | } |
| 875 | |
| 876 | func (f failingGets) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 877 | if f.fail[key] { |
| 878 | return nil, "", f.err |
| 879 | } |
| 880 | return f.Store.Get(ctx, key) |
| 881 | } |
| 882 | |
| 883 | // Astra round 1 finding 7 (§4.1): this box's staged copy whose bytes still |
| 884 | // hash to its sibling manifest is recovered without remote evidence, so a |
| 885 | // promotion record GET or a manifest GET that fails does not block it. With |
| 886 | // no verified copy the same failures still refuse, and nothing is downloaded: |
| 887 | // bytes that are fetched or replaced need the record's digest. |
| 888 | func TestAVerifiedLocalCopyNeedsNoRemoteEvidence(t *testing.T) { |
| 889 | ctx := context.Background() |
| 890 | priv, pub := testKeys(t) |
| 891 | base := NewDirStore(t.TempDir()) |
| 892 | shipRelease(t, base, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| 893 | if err := Promote(ctx, base, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| 894 | t.Fatal(err) |
| 895 | } |
| 896 | blip := errors.New("s3: 503 slow down") |
| 897 | for name, fail := range map[string]map[string]bool{ |
| 898 | "record GET fails": {PromotionRecordKey("bab", "km", "v1"): true}, |
| 899 | "manifest GET fails": {ManifestKey("bab", "km", "v1"): true}, |
| 900 | } { |
| 901 | t.Run(name, func(t *testing.T) { |
| 902 | opt := t.TempDir() |
| 903 | s := failingGets{Store: base, fail: fail, err: blip} |
| 904 | // No copy on this box: refused, nothing staged. |
| 905 | if _, err := stageAuthorized(ctx, s, pub, opt, "bab", "km", "v1", "a.example.com", stageCheck{}); err == nil { |
| 906 | t.Fatal("staged with no verified copy and no readable remote evidence") |
| 907 | } |
| 908 | if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| 909 | t.Fatal("bytes were downloaded without remote evidence") |
| 910 | } |
| 911 | // The box's verified copy (staged earlier, sibling beside it). |
| 912 | if _, err := ensureStaged(ctx, base, opt, "bab", "km", "v1", "seed"); err != nil { |
| 913 | t.Fatal(err) |
| 914 | } |
| 915 | m, err := stageAuthorized(ctx, s, pub, opt, "bab", "km", "v1", "a.example.com", stageCheck{}) |
| 916 | if err != nil || m == nil || m.Version != "v1" { |
| 917 | t.Fatalf("stageAuthorized = %+v, %v; want the verified local copy recovered", m, err) |
| 918 | } |
| 919 | // A copy whose bytes no longer match its sibling is not recovered. |
| 920 | if err := os.WriteFile(VersionPath(opt, "bab", "v1", "km"), []byte("tampered"), 0o755); err != nil { |
| 921 | t.Fatal(err) |
| 922 | } |
| 923 | if _, err := stageAuthorized(ctx, s, pub, opt, "bab", "km", "v1", "a.example.com", stageCheck{}); err == nil { |
| 924 | t.Fatal("recovered a staged copy that no longer matches its sibling") |
| 925 | } |
| 926 | }) |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | // Ruling R-HELD-PURE: root's consumed decision records the settings column |
| 931 | // it was made for, so the held wake check can tell a decision for the |
| 932 | // current settings from a stale one. A different column has another digest. |
| 933 | func TestNextConsumedRecordsTheSettingsItDecidedFor(t *testing.T) { |
| 934 | one := 1 |
| 935 | u := UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: &one} |
| 936 | in := TargetInput{Route: registry.Route{Host: "a.example.com", UpdateSettings: u.Column()}, Settings: u, Build: BuildStateCopy{Known: true}} |
| 937 | got := NextConsumed(in, TargetDecision{Reason: ReasonMajor}) |
| 938 | if got.Settings == "" || got.Settings != SettingsDigest(u.Column()) { |
| 939 | t.Fatalf("Settings = %q, want the digest of %s", got.Settings, u.Column()) |
| 940 | } |
| 941 | two := 2 |
| 942 | if other := (UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: &two}).Column(); SettingsDigest(other) == got.Settings { |
| 943 | t.Fatal("two columns share a digest") |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | // Astra r2 N1 (ruling R22): root's consumed file names the tenant it decided |
| 948 | // for, at both of the pass's write sites (the decision, and the unreadable |
| 949 | // column's reason unknown), and a hostname that moves to another tenant is |
| 950 | // rewritten naming the new one, never keeping the old file's tenant. |
| 951 | func TestRootsConsumedFileNamesTheRoutesTenant(t *testing.T) { |
| 952 | s := NewDirStore(t.TempDir()) |
| 953 | ctx := context.Background() |
| 954 | priv, pub := testKeys(t) |
| 955 | opt, reg := t.TempDir(), testRegistry(t) |
| 956 | const host = "a.example.com" |
| 957 | for _, ta := range [][2]string{{"cdf", "km"}, {"dee", "blog"}} { |
| 958 | shipRelease(t, s, ta[0], ta[1], "v1", adoptPlatform, []byte("v1")) |
| 959 | if err := Promote(ctx, s, priv, ta[0], ta[1], "canary/x", "v1"); err != nil { |
| 960 | t.Fatal(err) |
| 961 | } |
| 962 | } |
| 963 | route := func(account, app string) { |
| 964 | t.Helper() |
| 965 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: account, App: app, Channel: "canary/x", Hibernate: true}); err != nil { |
| 966 | t.Fatal(err) |
| 967 | } |
| 968 | if err := reg.SetUpdateSettings(host, UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}.Column()); err != nil { |
| 969 | t.Fatal(err) |
| 970 | } |
| 971 | if err := reg.SetBuildState(host, BuildStateCopy{Known: true, Replica: true}.Column()); err != nil { |
| 972 | t.Fatal(err) |
| 973 | } |
| 974 | } |
| 975 | check := func(when, account, app string) { |
| 976 | t.Helper() |
| 977 | c, err := ReadConsumed(opt, host) |
| 978 | if err != nil || c == nil { |
| 979 | t.Fatalf("%s: consumed = %+v, %v", when, c, err) |
| 980 | } |
| 981 | if c.Account != account || c.App != app || c.Reason != string(ReasonUnknown) { |
| 982 | t.Fatalf("%s: consumed names %q/%q (reason %q), want %s/%s", when, c.Account, c.App, c.Reason, account, app) |
| 983 | } |
| 984 | } |
| 985 | route("cdf", "km") |
| 986 | passWithSettings(t, reg, s, pub, opt) |
| 987 | check("tenant A's decision", "cdf", "km") |
| 988 | // Claims off: the row is retired by deletion, and B's arrives on the same |
| 989 | // hostname. Root's next pass reads A's file and writes over it. |
| 990 | if err := reg.Delete(host); err != nil { |
| 991 | t.Fatal(err) |
| 992 | } |
| 993 | route("dee", "blog") |
| 994 | passWithSettings(t, reg, s, pub, opt) |
| 995 | check("tenant B's decision over A's file", "dee", "blog") |
| 996 | // The other write site: a column that does not parse is recorded as |
| 997 | // reason unknown, for the route's tenant too (A's again, over B's file). |
| 998 | if err := reg.Delete(host); err != nil { |
| 999 | t.Fatal(err) |
| 1000 | } |
| 1001 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "cdf", App: "km", Channel: "canary/x", Hibernate: true}); err != nil { |
| 1002 | t.Fatal(err) |
| 1003 | } |
| 1004 | if err := reg.SetUpdateSettings(host, "{not json"); err != nil { |
| 1005 | t.Fatal(err) |
| 1006 | } |
| 1007 | out := passWithSettings(t, reg, s, pub, opt) |
| 1008 | if !strings.Contains(out, "update settings unreadable") { |
| 1009 | t.Fatalf("the pass did not take the unreadable-column path:\n%s", out) |
| 1010 | } |
| 1011 | check("an unreadable column", "cdf", "km") |
| 1012 | } |
| 1013 | |