| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "runtime" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostclaim" |
| 18 | "github.com/carlosframework/platform/internal/moves" |
| 19 | "github.com/carlosframework/platform/internal/provision" |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | const ( |
| 25 | hsHost = "held.example.com" |
| 26 | hsOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| 27 | hsOther = "mv_bcdefghijklmnopqrstuvwxyza" |
| 28 | hsBox = "uk-box" |
| 29 | ) |
| 30 | |
| 31 | // hsPriv signs the held-stage tests' pointers and promotion records, and |
| 32 | // hsPub verifies them: the release key a held stage is handed. |
| 33 | var ( |
| 34 | hsPriv = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize)) |
| 35 | hsPub = hsPriv.Public().(ed25519.PublicKey) |
| 36 | ) |
| 37 | |
| 38 | // hsRatchet is a fresh, unarmed pointer digest ratchet: today's admission. |
| 39 | func hsRatchet(t *testing.T) *release.DigestRatchet { |
| 40 | return release.NewDigestRatchet(t.TempDir(), nil, false) |
| 41 | } |
| 42 | |
| 43 | // promoteHeld promotes bab/app's version to canary/x with hsPriv, writing |
| 44 | // its signed promotion record: a held stage installs only a build §4.1 |
| 45 | // authorizes (settings spec rev 11 §4.3a). |
| 46 | func promoteHeld(t *testing.T, s release.Store, app, version string) { |
| 47 | t.Helper() |
| 48 | if err := release.Promote(context.Background(), s, hsPriv, "bab", app, "canary/x", version); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // hsClaim writes host's claim: held by bab/titogo, op as given. |
| 54 | func hsClaim(t *testing.T, s release.Store, op hostclaim.Op) { |
| 55 | t.Helper() |
| 56 | created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| 57 | inc, err := hostclaim.Incarnation("bab", "titogo", hsHost, created) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | _, err = hostclaim.Update(context.Background(), s, hostclaim.Observed{Host: hsHost, Absent: true}, func(c *hostclaim.Claim) error { |
| 62 | c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "titogo", hsHost), Account: "bab", App: "titogo", |
| 63 | Incarnation: inc, CreatedAt: created, Location: "instances/" + hsHost + "/" + inc, Stamp: 1} |
| 64 | c.Op = op |
| 65 | c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}} |
| 66 | return nil |
| 67 | }) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func movingOp() hostclaim.Op { |
| 74 | return hostclaim.Op{Kind: hostclaim.KindMoving, ID: hsOp, Exec: "ie-box", Participants: []string{"ie-box", hsBox}, |
| 75 | Intent: map[string]string{"to": "uk", "source": "ie-box", "target": hsBox}} |
| 76 | } |
| 77 | |
| 78 | func hsReport(t *testing.T, s release.Store, op, rel string) { |
| 79 | t.Helper() |
| 80 | raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: op, At: time.Now(), Release: rel}) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, op)), raw, ""); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // hsReportFor is hsReport under another app of the same account. |
| 90 | func hsReportFor(t *testing.T, s release.Store, app, op, rel string) { |
| 91 | t.Helper() |
| 92 | raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: op, At: time.Now(), Release: rel}) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", app, hsHost, op)), raw, ""); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func hsRequest() release.HeldStageRequest { |
| 102 | return release.HeldStageRequest{Account: "bab", App: "titogo", Host: hsHost, Version: "v7", Op: hsOp, Box: hsBox} |
| 103 | } |
| 104 | |
| 105 | // TestHeldStageAuthority covers each refused field of heldStageAuthority: |
| 106 | // the request's op, box and version, the registry row (absent is accepted, |
| 107 | // held by another op or naming another app is not), the claim (absent, |
| 108 | // another op, a kind that is not moving or landing, another target, |
| 109 | // another holder), and the source's report (absent, empty, another |
| 110 | // release). |
| 111 | func TestHeldStageAuthority(t *testing.T) { |
| 112 | t.Parallel() |
| 113 | type fixture struct { |
| 114 | reg *registry.Registry |
| 115 | s release.Store |
| 116 | } |
| 117 | good := func(t *testing.T) fixture { |
| 118 | s := release.NewDirStore(t.TempDir()) |
| 119 | hsClaim(t, s, movingOp()) |
| 120 | hsReport(t, s, hsOp, "v7") |
| 121 | return fixture{testRegistry(t), s} |
| 122 | } |
| 123 | t.Run("a malformed op is refused before any bucket read", func(t *testing.T) { |
| 124 | f := good(t) |
| 125 | cs := &hsCountingStore{Store: f.s} |
| 126 | req := hsRequest() |
| 127 | req.Op = "../" + hsOp |
| 128 | if err := heldStageAuthority(f.reg, cs, hsBox, t.TempDir())(req, nil); !errors.Is(err, release.ErrStaleHeldStage) { |
| 129 | t.Fatalf("authorize: %v, want ErrStaleHeldStage", err) |
| 130 | } |
| 131 | if cs.n != 0 { |
| 132 | t.Fatalf("a malformed op reached the bucket (%d calls)", cs.n) |
| 133 | } |
| 134 | }) |
| 135 | t.Run("absent row, moving claim, matching report", func(t *testing.T) { |
| 136 | f := good(t) |
| 137 | if err := heldStageAuthority(f.reg, f.s, hsBox, t.TempDir())(hsRequest(), nil); err != nil { |
| 138 | t.Fatalf("authorize: %v", err) |
| 139 | } |
| 140 | }) |
| 141 | t.Run("a row held by the op, and a landing claim", func(t *testing.T) { |
| 142 | s := release.NewDirStore(t.TempDir()) |
| 143 | op := movingOp() |
| 144 | op.Kind = hostclaim.KindLanding |
| 145 | hsClaim(t, s, op) |
| 146 | hsReport(t, s, hsOp, "v7") |
| 147 | reg := testRegistry(t) |
| 148 | if err := reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOp}); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if err := heldStageAuthority(reg, s, hsBox, t.TempDir())(hsRequest(), nil); err != nil { |
| 152 | t.Fatalf("authorize: %v", err) |
| 153 | } |
| 154 | }) |
| 155 | |
| 156 | for name, tc := range map[string]struct { |
| 157 | box string |
| 158 | setup func(*testing.T, fixture, *release.HeldStageRequest) |
| 159 | }{ |
| 160 | "malformed op": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Op = "../x" }}, |
| 161 | "another box": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Box = "ie-box" }}, |
| 162 | "the claim's target, not this box": {hsBox, func(t *testing.T, f fixture, r *release.HeldStageRequest) { |
| 163 | r.Box = "fr-box" |
| 164 | rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Intent["target"] = "fr-box" }) |
| 165 | }}, |
| 166 | "claim held by another app": {hsBox, func(t *testing.T, f fixture, r *release.HeldStageRequest) { |
| 167 | r.App = "other" |
| 168 | hsReportFor(t, f.s, "other", hsOp, "v7") |
| 169 | }}, |
| 170 | "this box is unnamed": {"", func(*testing.T, fixture, *release.HeldStageRequest) {}}, |
| 171 | "another version": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Version = "v8" }}, |
| 172 | "another account": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Account = "xyz" }}, |
| 173 | "another app": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.App = "other" }}, |
| 174 | "an op the claim drops": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Op = hsOther }}, |
| 175 | "row held by another op": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 176 | if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOther}); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | }}, |
| 180 | "row not held": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 181 | if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "ready"}); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | }}, |
| 185 | "row of another app": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 186 | if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "other", Account: "bab", Status: "landing", HoldOp: hsOp}); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | }}, |
| 190 | "no claim": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 191 | if err := f.s.Delete(context.Background(), "control/hosts/"+hsHost+".json"); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | }}, |
| 195 | "claim names another op": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 196 | rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.ID = hsOther }) |
| 197 | }}, |
| 198 | "claim is deleting": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 199 | rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Kind = hostclaim.KindDeleting }) |
| 200 | }}, |
| 201 | "claim targets another box": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 202 | rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Intent["target"] = "fr-box" }) |
| 203 | }}, |
| 204 | "no source report": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 205 | if err := f.s.Delete(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, hsOp))); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | }}, |
| 209 | "report without a release": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| 210 | if err := f.s.Delete(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, hsOp))); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | hsReport(t, f.s, hsOp, "") |
| 214 | }}, |
| 215 | } { |
| 216 | t.Run(name, func(t *testing.T) { |
| 217 | f := good(t) |
| 218 | req := hsRequest() |
| 219 | tc.setup(t, f, &req) |
| 220 | if err := heldStageAuthority(f.reg, f.s, tc.box, t.TempDir())(req, nil); !errors.Is(err, release.ErrStaleHeldStage) { |
| 221 | t.Fatalf("authorize: %v, want ErrStaleHeldStage", err) |
| 222 | } |
| 223 | }) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // rewriteClaim CASes the host's claim with its op changed by edit. |
| 228 | func rewriteClaim(t *testing.T, s release.Store, edit func(*hostclaim.Op)) { |
| 229 | t.Helper() |
| 230 | obs, err := hostclaim.Read(context.Background(), s, hsHost) |
| 231 | if err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | if _, err := hostclaim.Update(context.Background(), s, obs, func(c *hostclaim.Claim) error { edit(&c.Op); return nil }); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // hsCountingStore counts every call that reaches the store. |
| 240 | type hsCountingStore struct { |
| 241 | release.Store |
| 242 | mu sync.Mutex |
| 243 | n int |
| 244 | } |
| 245 | |
| 246 | func (c *hsCountingStore) bump() { c.mu.Lock(); c.n++; c.mu.Unlock() } |
| 247 | func (c *hsCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 248 | c.bump() |
| 249 | return c.Store.Get(ctx, key) |
| 250 | } |
| 251 | func (c *hsCountingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 252 | c.bump() |
| 253 | return c.Store.List(ctx, prefix) |
| 254 | } |
| 255 | func (c *hsCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 256 | c.bump() |
| 257 | return c.Store.Put(ctx, key, body, ifMatch) |
| 258 | } |
| 259 | |
| 260 | // TestHeldStagePassIsANoOpWithoutRequests is the switch-off guarantee for |
| 261 | // `carlos adopt`: with no held-stage request on the box (none exist while |
| 262 | // CARLOS_MOVES_PHASE1 is off, since only a move's target writes one) the |
| 263 | // pass reads nothing from the bucket and creates nothing on disk. |
| 264 | func TestHeldStagePassIsANoOpWithoutRequests(t *testing.T) { |
| 265 | dd, opt := t.TempDir(), t.TempDir() |
| 266 | s := &hsCountingStore{Store: release.NewDirStore(t.TempDir())} |
| 267 | heldStagePass(context.Background(), dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| 268 | if s.n != 0 { |
| 269 | t.Fatalf("the pass made %d store calls", s.n) |
| 270 | } |
| 271 | for _, dir := range []string{dd, opt} { |
| 272 | if ents, _ := os.ReadDir(dir); len(ents) != 0 { |
| 273 | t.Fatalf("%s gained %v", dir, ents) |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // TestHeldStagePassServesAnAuthorizedRequest: end to end through the real |
| 279 | // authority, a request for this box's move stages the source's release. |
| 280 | func TestHeldStagePassServesAnAuthorizedRequest(t *testing.T) { |
| 281 | ctx := context.Background() |
| 282 | s := release.NewDirStore(t.TempDir()) |
| 283 | hsClaim(t, s, movingOp()) |
| 284 | hsReport(t, s, hsOp, "v7") |
| 285 | shipV7(t, s) |
| 286 | dd, opt := t.TempDir(), t.TempDir() |
| 287 | reqDir := release.HeldStageDir(dd) |
| 288 | if err := os.MkdirAll(reqDir, 0o755); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | raw, _ := json.Marshal(hsRequest()) |
| 292 | if err := os.WriteFile(filepath.Join(reqDir, hsHost+".json"), raw, 0o644); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | // A stale sibling: its op is not the claim's. |
| 296 | stale := hsRequest() |
| 297 | stale.Host, stale.Op = "other.example.com", hsOther |
| 298 | raw, _ = json.Marshal(stale) |
| 299 | if err := os.WriteFile(filepath.Join(reqDir, stale.Host+".json"), raw, 0o644); err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | heldStagePass(ctx, dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| 303 | if got, err := release.LiveVersion(opt, hsHost); err != nil || got != "v7" { |
| 304 | t.Fatalf("LiveVersion = %q, %v", got, err) |
| 305 | } |
| 306 | if got, _ := release.LiveVersion(opt, stale.Host); got != "" { |
| 307 | t.Fatalf("the stale request was served: %q", got) |
| 308 | } |
| 309 | if ents, _ := os.ReadDir(reqDir); len(ents) != 0 { |
| 310 | names := []string{} |
| 311 | for _, e := range ents { |
| 312 | names = append(names, e.Name()) |
| 313 | } |
| 314 | t.Fatalf("requests left behind: %s", strings.Join(names, ", ")) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // shipV7 ships bab/titogo v7 for this platform and promotes it to |
| 319 | // canary/x, so a held stage of it is authorized. |
| 320 | func shipV7(t *testing.T, s release.Store) { |
| 321 | t.Helper() |
| 322 | plat := runtime.GOOS + "-" + runtime.GOARCH |
| 323 | m := release.Manifest{App: "titogo", Version: "v7", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| 324 | if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{plat: []byte("pinned")}); err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | promoteHeld(t, s, "titogo", "v7") |
| 328 | } |
| 329 | |
| 330 | // TestHeldPublicationIsGuardedByTheAuthority: the authority that admitted a |
| 331 | // request before staging is re-run around the publication. If the claim |
| 332 | // moved to another op, or the registry hold was released, while the release |
| 333 | // was staging, publication returns ErrStaleHeldStage and live/<host> is |
| 334 | // never written. Unchanged, the publication happens inside the guard. |
| 335 | func TestHeldPublicationIsGuardedByTheAuthority(t *testing.T) { |
| 336 | t.Parallel() |
| 337 | heldRow := registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOp} |
| 338 | for name, tc := range map[string]struct { |
| 339 | row bool |
| 340 | during func(*testing.T, *registry.Registry, release.Store) |
| 341 | stale bool |
| 342 | }{ |
| 343 | "unchanged, no row yet": {false, func(*testing.T, *registry.Registry, release.Store) {}, false}, |
| 344 | "unchanged, held row": {true, func(*testing.T, *registry.Registry, release.Store) {}, false}, |
| 345 | "the claim moves to another op": {false, func(t *testing.T, _ *registry.Registry, s release.Store) { |
| 346 | rewriteClaim(t, s, func(op *hostclaim.Op) { op.ID = hsOther }) |
| 347 | }, true}, |
| 348 | "the claim leaves the move": {true, func(t *testing.T, _ *registry.Registry, s release.Store) { |
| 349 | rewriteClaim(t, s, func(op *hostclaim.Op) { op.Kind = hostclaim.KindDeleting }) |
| 350 | }, true}, |
| 351 | "the hold is released": {true, func(t *testing.T, reg *registry.Registry, _ release.Store) { |
| 352 | if ok, err := reg.ReleaseHold(hsHost, hsOp); err != nil || !ok { |
| 353 | t.Fatalf("ReleaseHold: %v %v", ok, err) |
| 354 | } |
| 355 | }, true}, |
| 356 | "the hold passes to another op": {true, func(t *testing.T, reg *registry.Registry, _ release.Store) { |
| 357 | if ok, err := reg.ReleaseHold(hsHost, hsOp); err != nil || !ok { |
| 358 | t.Fatalf("ReleaseHold: %v %v", ok, err) |
| 359 | } |
| 360 | if err := reg.SetHold(hsHost, "landing", hsOther); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | }, true}, |
| 364 | } { |
| 365 | t.Run(name, func(t *testing.T) { |
| 366 | ctx := context.Background() |
| 367 | s := release.NewDirStore(t.TempDir()) |
| 368 | hsClaim(t, s, movingOp()) |
| 369 | hsReport(t, s, hsOp, "v7") |
| 370 | shipV7(t, s) |
| 371 | reg := testRegistry(t) |
| 372 | if tc.row { |
| 373 | if err := reg.Upsert(heldRow); err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | } |
| 377 | opt := t.TempDir() |
| 378 | auth := heldStageAuthority(reg, s, hsBox, t.TempDir()) |
| 379 | req := hsRequest() |
| 380 | if err := auth(req, nil); err != nil { |
| 381 | t.Fatalf("authority before staging: %v", err) |
| 382 | } |
| 383 | rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op} |
| 384 | if _, err := release.StageHeldVerified(ctx, s, hsPub, hsRatchet(t), opt, rt, req.Version, offProofGate(t)); err != nil { |
| 385 | t.Fatalf("StageHeld: %v", err) |
| 386 | } |
| 387 | tc.during(t, reg, s) |
| 388 | _, err := release.MaterializeHeld(ctx, s, hsPub, hsRatchet(t), opt, rt, req.Version, offProofGate(t), func(publish func() error) error { return auth(req, publish) }) |
| 389 | got, _ := release.LiveVersion(opt, hsHost) |
| 390 | if tc.stale { |
| 391 | if !errors.Is(err, release.ErrStaleHeldStage) || got != "" { |
| 392 | t.Fatalf("PublishHeld: %v, live %q; want ErrStaleHeldStage and no live link", err, got) |
| 393 | } |
| 394 | return |
| 395 | } |
| 396 | if err != nil || got != "v7" { |
| 397 | t.Fatalf("PublishHeld: %v, live %q; want v7", err, got) |
| 398 | } |
| 399 | }) |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | // TestHeldPublicationRechecksInsideTheFreeze: a change that lands after the |
| 404 | // full check and before the freeze is caught by the re-check made with the |
| 405 | // holds frozen, and the publication itself runs while they are frozen: a |
| 406 | // ReleaseHold issued during it waits until it returns. |
| 407 | func TestHeldPublicationRechecksInsideTheFreeze(t *testing.T) { |
| 408 | heldRow := registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOp} |
| 409 | for name, tc := range map[string]struct { |
| 410 | row bool |
| 411 | window func(*testing.T, *registry.Registry, release.Store) |
| 412 | }{ |
| 413 | "the hold is released": {true, func(t *testing.T, reg *registry.Registry, _ release.Store) { |
| 414 | if ok, err := reg.ReleaseHold(hsHost, hsOp); err != nil || !ok { |
| 415 | t.Fatalf("ReleaseHold: %v %v", ok, err) |
| 416 | } |
| 417 | }}, |
| 418 | "a row appears held by another op": {false, func(t *testing.T, reg *registry.Registry, _ release.Store) { |
| 419 | r := heldRow |
| 420 | r.HoldOp = hsOther |
| 421 | if err := reg.Upsert(r); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | }}, |
| 425 | "the claim moves to another op": {false, func(t *testing.T, _ *registry.Registry, s release.Store) { |
| 426 | rewriteClaim(t, s, func(op *hostclaim.Op) { op.ID = hsOther }) |
| 427 | }}, |
| 428 | "the claim retargets": {true, func(t *testing.T, _ *registry.Registry, s release.Store) { |
| 429 | rewriteClaim(t, s, func(op *hostclaim.Op) { op.Intent["target"] = "fr-box" }) |
| 430 | }}, |
| 431 | } { |
| 432 | t.Run(name, func(t *testing.T) { |
| 433 | s := release.NewDirStore(t.TempDir()) |
| 434 | hsClaim(t, s, movingOp()) |
| 435 | hsReport(t, s, hsOp, "v7") |
| 436 | reg := testRegistry(t) |
| 437 | if tc.row { |
| 438 | if err := reg.Upsert(heldRow); err != nil { |
| 439 | t.Fatal(err) |
| 440 | } |
| 441 | } |
| 442 | orig := heldStageBeforeFreeze |
| 443 | t.Cleanup(func() { heldStageBeforeFreeze = orig }) |
| 444 | heldStageBeforeFreeze = func() { tc.window(t, reg, s) } |
| 445 | published := false |
| 446 | err := heldStageAuthority(reg, s, hsBox, t.TempDir())(hsRequest(), func() error { published = true; return nil }) |
| 447 | if !errors.Is(err, release.ErrStaleHeldStage) || published { |
| 448 | t.Fatalf("authority: %v (published %v), want ErrStaleHeldStage before publishing", err, published) |
| 449 | } |
| 450 | }) |
| 451 | } |
| 452 | |
| 453 | t.Run("publication runs with the holds frozen", func(t *testing.T) { |
| 454 | s := release.NewDirStore(t.TempDir()) |
| 455 | hsClaim(t, s, movingOp()) |
| 456 | hsReport(t, s, hsOp, "v7") |
| 457 | reg := testRegistry(t) |
| 458 | if err := reg.Upsert(heldRow); err != nil { |
| 459 | t.Fatal(err) |
| 460 | } |
| 461 | released := make(chan struct{}) |
| 462 | err := heldStageAuthority(reg, s, hsBox, t.TempDir())(hsRequest(), func() error { |
| 463 | go func() { _, _ = reg.ReleaseHold(hsHost, hsOp); close(released) }() |
| 464 | select { |
| 465 | case <-released: |
| 466 | return errors.New("a ReleaseHold landed during the publication") |
| 467 | case <-time.After(300 * time.Millisecond): |
| 468 | return nil |
| 469 | } |
| 470 | }) |
| 471 | if err != nil { |
| 472 | t.Fatal(err) |
| 473 | } |
| 474 | select { |
| 475 | case <-released: |
| 476 | case <-time.After(5 * time.Second): |
| 477 | t.Fatal("the ReleaseHold never ran after the freeze") |
| 478 | } |
| 479 | }) |
| 480 | } |
| 481 | |
| 482 | // shipUnpromoted ships bab/app's version for this platform and promotes it |
| 483 | // nowhere: no promotion record and no pointer authorizes it. |
| 484 | func shipUnpromoted(t *testing.T, s release.Store, app, version string) { |
| 485 | t.Helper() |
| 486 | plat := runtime.GOOS + "-" + runtime.GOARCH |
| 487 | m := release.Manifest{App: app, Version: version, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| 488 | if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{plat: []byte("pinned")}); err != nil { |
| 489 | t.Fatal(err) |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | // Settings spec rev 11 §4.3a, root's side: a request the move's authority |
| 494 | // accepts, for a build no promotion record and no pointer authorizes, is |
| 495 | // dropped before anything is downloaded, and live/<host> is never written. |
| 496 | func TestHeldStagePassDropsAnUnauthorizedBuild(t *testing.T) { |
| 497 | ctx := context.Background() |
| 498 | s := release.NewDirStore(t.TempDir()) |
| 499 | hsClaim(t, s, movingOp()) |
| 500 | hsReport(t, s, hsOp, "v7") |
| 501 | shipUnpromoted(t, s, "titogo", "v7") |
| 502 | dd, opt := t.TempDir(), t.TempDir() |
| 503 | reqDir := release.HeldStageDir(dd) |
| 504 | if err := os.MkdirAll(reqDir, 0o755); err != nil { |
| 505 | t.Fatal(err) |
| 506 | } |
| 507 | raw, _ := json.Marshal(hsRequest()) |
| 508 | if err := os.WriteFile(filepath.Join(reqDir, hsHost+".json"), raw, 0o644); err != nil { |
| 509 | t.Fatal(err) |
| 510 | } |
| 511 | heldStagePass(ctx, dd, opt, hsPub, hsRatchet(t), testRegistry(t), s, hsBox) |
| 512 | if got, _ := release.LiveVersion(opt, hsHost); got != "" { |
| 513 | t.Fatalf("an unauthorized build went live: %q", got) |
| 514 | } |
| 515 | if _, err := os.Stat(filepath.Join(opt, "versions")); !errors.Is(err, os.ErrNotExist) { |
| 516 | t.Fatalf("an unauthorized build was staged: %v", err) |
| 517 | } |
| 518 | if _, err := os.Stat(filepath.Join(reqDir, hsHost+".json")); !errors.Is(err, os.ErrNotExist) { |
| 519 | t.Fatalf("the refused request was kept for a retry: %v", err) |
| 520 | } |
| 521 | } |
| 522 | |
| 523 | // Settings spec rev 11 §4.3a, the agent's own path (ruling R5): T7 stages |
| 524 | // only a build §4.1 authorizes. A shipped, never-promoted build, or any |
| 525 | // build when the release key did not parse (a nil pub), fails T7 with |
| 526 | // nothing staged and nothing relayed to the root pass. |
| 527 | func TestHeldReleaseReadyRefusesAnUnauthorizedBuild(t *testing.T) { |
| 528 | t.Parallel() |
| 529 | ctx := context.Background() |
| 530 | for name, tc := range map[string]struct { |
| 531 | promote bool |
| 532 | pub ed25519.PublicKey |
| 533 | }{ |
| 534 | "never promoted": {false, hsPub}, |
| 535 | "no release key": {true, nil}, |
| 536 | } { |
| 537 | t.Run(name, func(t *testing.T) { |
| 538 | h := newPhase1Harness(t) |
| 539 | rec := h.start(t, "manual") |
| 540 | shipUnpromoted(t, h.s, "app", "v7") |
| 541 | if tc.promote { |
| 542 | promoteHeld(t, h.s, "app", "v7") |
| 543 | } |
| 544 | h.pass(t) |
| 545 | data, opt := t.TempDir(), t.TempDir() |
| 546 | relayed := false |
| 547 | ready := heldReleaseReady(testRegistry(t), opt, data, "uk-box", tc.pub, hsRatchet(t), offProofGate(t), func(string, string, error) { relayed = true }) |
| 548 | if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| 549 | t.Fatalf("T7 = %+v, want %q", c, t7Failed) |
| 550 | } |
| 551 | if _, err := os.Stat(filepath.Join(opt, "versions")); !errors.Is(err, os.ErrNotExist) { |
| 552 | t.Fatalf("an unauthorized build was staged: %v", err) |
| 553 | } |
| 554 | if _, err := os.Stat(filepath.Join(release.HeldStageDir(data), p1Host+".json")); !errors.Is(err, os.ErrNotExist) || relayed { |
| 555 | t.Fatalf("an unauthorized build was relayed to the root pass (%v, %v)", err, relayed) |
| 556 | } |
| 557 | if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| 558 | t.Fatalf("live = %q, want no live link", v) |
| 559 | } |
| 560 | }) |
| 561 | } |
| 562 | } |
| 563 | |