| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func TestRungOfChannel(t *testing.T) { |
| 14 | cases := []struct { |
| 15 | channel string |
| 16 | rung string |
| 17 | ok bool |
| 18 | }{ |
| 19 | {"stable", RungStable, true}, |
| 20 | {"beta", RungBeta, true}, |
| 21 | {"edge", RungEdge, true}, |
| 22 | {"canary", RungCanary, true}, |
| 23 | {"canary/keymail", RungCanary, true}, |
| 24 | {"canary/platform-adoption", RungCanary, true}, |
| 25 | {"canary/rehearsal", RungCanary, true}, |
| 26 | {"canary/x", RungCanary, true}, |
| 27 | // Only canary nests, and only names in the frozen vocabulary are rungs. |
| 28 | {"beta/x", "", false}, |
| 29 | {"stable/x", "", false}, |
| 30 | {"dev", "", false}, |
| 31 | {"wip", "", false}, |
| 32 | {"", "", false}, |
| 33 | {"canary/", "", false}, |
| 34 | {"canary/a/b", "", false}, |
| 35 | } |
| 36 | for _, c := range cases { |
| 37 | got, ok := Rung(c.channel) |
| 38 | if got != c.rung || ok != c.ok { |
| 39 | t.Errorf("Rung(%q) = (%q, %v), want (%q, %v)", c.channel, got, ok, c.rung, c.ok) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestRungIndexOrdersTheLadder(t *testing.T) { |
| 45 | if RungIndex(RungCanary) != 0 || RungIndex(RungEdge) != 1 || |
| 46 | RungIndex(RungBeta) != 2 || RungIndex(RungStable) != 3 { |
| 47 | t.Fatalf("ladder order wrong: canary=%d edge=%d beta=%d stable=%d", |
| 48 | RungIndex(RungCanary), RungIndex(RungEdge), RungIndex(RungBeta), RungIndex(RungStable)) |
| 49 | } |
| 50 | for _, unknown := range []string{"", "dev", "prod", "canary/x"} { |
| 51 | if got := RungIndex(unknown); got != -1 { |
| 52 | t.Errorf("RungIndex(%q) = %d, want -1", unknown, got) |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestPromotionAllowedLegalTransitions(t *testing.T) { |
| 58 | legal := []struct{ from, to string }{ |
| 59 | // The two ordered steps, which is the whole ladder above edge. |
| 60 | {RungEdge, RungBeta}, |
| 61 | {RungBeta, RungStable}, |
| 62 | // A fresh build enters the ladder at edge (merging put it there). |
| 63 | {"", RungEdge}, |
| 64 | // Canary has no gate in: production runs on canary/* channels today |
| 65 | // and shipping to one is never refused, whatever the build is on. |
| 66 | {"", RungCanary}, |
| 67 | {RungCanary, RungCanary}, |
| 68 | {RungEdge, RungCanary}, |
| 69 | {RungBeta, RungCanary}, |
| 70 | {RungStable, RungCanary}, |
| 71 | } |
| 72 | for _, c := range legal { |
| 73 | if err := PromotionAllowed(c.from, c.to); err != nil { |
| 74 | t.Errorf("PromotionAllowed(%q, %q) = %v, want nil", c.from, c.to, err) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPromotionAllowedRefusesSkipsAndNamesHotfix(t *testing.T) { |
| 80 | refused := []struct{ from, to string }{ |
| 81 | {RungEdge, RungStable}, // the skip the ladder exists to stop |
| 82 | {"", RungBeta}, // straight onto beta, having been nowhere |
| 83 | {"", RungStable}, // straight onto stable, having been nowhere |
| 84 | {RungCanary, RungEdge}, // nothing is promoted from canary to edge |
| 85 | {RungCanary, RungBeta}, // ... nor past it |
| 86 | {RungCanary, RungStable}, // |
| 87 | {RungStable, RungBeta}, // no going back down the ladder |
| 88 | {RungBeta, RungEdge}, // |
| 89 | {RungEdge, RungEdge}, // same-rung re-promotion of another build |
| 90 | {RungBeta, RungBeta}, // |
| 91 | {RungStable, RungStable}, // |
| 92 | {RungEdge, "dev"}, // not a rung at all |
| 93 | {RungEdge, ""}, // |
| 94 | } |
| 95 | for _, c := range refused { |
| 96 | err := PromotionAllowed(c.from, c.to) |
| 97 | if err == nil { |
| 98 | t.Errorf("PromotionAllowed(%q, %q) = nil, want a refusal", c.from, c.to) |
| 99 | continue |
| 100 | } |
| 101 | if !strings.Contains(err.Error(), "-hotfix") { |
| 102 | t.Errorf("PromotionAllowed(%q, %q) = %q, want the refusal to name --hotfix", c.from, c.to, err) |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // --- Stable promotion cuts the version tag (release versioning spec) --- |
| 108 | |
| 109 | // shipLabelledIteration ships a real one-artifact release carrying the human |
| 110 | // half of a manifest, so a tag cut from it has a non-empty changelog. It goes |
| 111 | // through Ship (not a hand-written manifest) so the artifact sha256s are the |
| 112 | // real thing and validateManifest is exercised. |
| 113 | func shipLabelledIteration(t *testing.T, s Store, acct, app, version, label, notes string) { |
| 114 | t.Helper() |
| 115 | m := Manifest{ |
| 116 | App: app, Version: version, Kind: "binary", |
| 117 | Commit: "c-" + version, Label: label, Notes: notes, |
| 118 | Artifacts: map[string]Artifact{"linux-arm64": {}}, |
| 119 | } |
| 120 | files := map[string][]byte{"linux-arm64": []byte("build " + version)} |
| 121 | if err := Ship(context.Background(), s, acct, m, files); err != nil { |
| 122 | t.Fatalf("Ship %s/%s: %v", app, version, err) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // climbToBeta walks a version up the ladder the way an operator does, so the |
| 127 | // stable promote under test meets a real edge->beta->stable history. |
| 128 | func climbToBeta(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, version string) { |
| 129 | t.Helper() |
| 130 | for _, ch := range []string{"edge", "beta"} { |
| 131 | if _, err := PromoteOnLadder(ctx, s, priv, acct, app, ch, version, PromoteOptions{}); err != nil { |
| 132 | t.Fatalf("promote %s to %s: %v", version, ch, err) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // A promotion to stable cuts the iteration's tag, reports it, and stays |
| 138 | // re-runnable: the repeat is a no-op that still names the tag. |
| 139 | func TestStablePromoteCutsTag(t *testing.T) { |
| 140 | s := NewDirStore(t.TempDir()) |
| 141 | ctx := context.Background() |
| 142 | priv, _ := testKeys(t) |
| 143 | |
| 144 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "fixing modals", "- focus trap fixed") |
| 145 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "polish", "- rounded corners") |
| 146 | climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-2") |
| 147 | |
| 148 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", |
| 149 | PromoteOptions{Actor: "paul@example.com"}) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if res.Tag != "0.5.0" || res.TagWarning != "" { |
| 154 | t.Fatalf("res.Tag=%q TagWarning=%q; want 0.5.0, empty", res.Tag, res.TagWarning) |
| 155 | } |
| 156 | tag, err := GetTag(ctx, s, "bab", "km", "0.5.0") |
| 157 | if err != nil || tag.Release != "0.5.0-2" { |
| 158 | t.Fatalf("tag after promote: %+v, %v", tag, err) |
| 159 | } |
| 160 | if tag.Actor != "paul@example.com" || tag.Commit != "c-0.5.0-2" { |
| 161 | t.Fatalf("tag fields: %+v", tag) |
| 162 | } |
| 163 | // The changelog is compiled from BOTH iterations, oldest first — proof the |
| 164 | // hook ran with the whole line in view, not just the promoted build. |
| 165 | if !strings.Contains(tag.Changelog, "0.5.0-1") || !strings.Contains(tag.Changelog, "rounded corners") { |
| 166 | t.Fatalf("changelog does not carry the line: %q", tag.Changelog) |
| 167 | } |
| 168 | |
| 169 | // Repeat promote: NoOp, and the tag is still reported. |
| 170 | res2, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", |
| 171 | PromoteOptions{Actor: "paul@example.com"}) |
| 172 | if err != nil || !res2.NoOp || res2.Tag != "0.5.0" { |
| 173 | t.Fatalf("repeat: %+v, %v", res2, err) |
| 174 | } |
| 175 | if res2.TagWarning != "" { |
| 176 | t.Fatalf("repeat warned: %q", res2.TagWarning) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // The no-op path is a HEAL, not just a report: a promotion whose tag write |
| 181 | // failed the first time is repaired by re-running the same command. |
| 182 | // |
| 183 | // Mutation-mindedness: drop the stableTag call from the NoOp early return and |
| 184 | // this is the only test that notices — and the operator would have no way back |
| 185 | // to a tag, since the pointer never moves again. |
| 186 | func TestRepeatStablePromoteHealsAMissingTag(t *testing.T) { |
| 187 | s := NewDirStore(t.TempDir()) |
| 188 | ctx := context.Background() |
| 189 | priv, _ := testKeys(t) |
| 190 | |
| 191 | shipLabelledIteration(t, s, "bab", "km", "0.6.0-1", "first", "- one") |
| 192 | climbToBeta(t, ctx, s, priv, "bab", "km", "0.6.0-1") |
| 193 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1", PromoteOptions{}); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | // Simulate the tag write having failed on the first attempt. |
| 197 | if err := s.Delete(ctx, TagKey("bab", "km", "0.6.0")); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | |
| 201 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1", |
| 202 | PromoteOptions{Actor: "paul@example.com"}) |
| 203 | if err != nil || !res.NoOp { |
| 204 | t.Fatalf("repeat: %+v, %v", res, err) |
| 205 | } |
| 206 | if res.Tag != "0.6.0" || res.TagWarning != "" { |
| 207 | t.Fatalf("no-op did not heal the tag: Tag=%q warning=%q", res.Tag, res.TagWarning) |
| 208 | } |
| 209 | if tag, err := GetTag(ctx, s, "bab", "km", "0.6.0"); err != nil || tag.Release != "0.6.0-1" { |
| 210 | t.Fatalf("tag after heal: %+v, %v", tag, err) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // A sha-named build reaching stable cuts nothing — no invented history — and |
| 215 | // says so, so the operator knows why the release has no name. |
| 216 | func TestStablePromoteShaVersionNoTag(t *testing.T) { |
| 217 | s := NewDirStore(t.TempDir()) |
| 218 | ctx := context.Background() |
| 219 | priv, pub := testKeys(t) |
| 220 | |
| 221 | shipRelease(t, s, "bab", "km", "de2766f8", "linux-arm64", []byte("build")) |
| 222 | climbToBeta(t, ctx, s, priv, "bab", "km", "de2766f8") |
| 223 | |
| 224 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "de2766f8", PromoteOptions{}) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if res.Tag != "" { |
| 229 | t.Fatalf("sha promote cut tag %q", res.Tag) |
| 230 | } |
| 231 | if res.TagWarning == "" { |
| 232 | t.Fatal("sha promote to stable should warn that no tag was cut") |
| 233 | } |
| 234 | if !strings.Contains(res.TagWarning, "de2766f8") { |
| 235 | t.Fatalf("warning does not name the version: %q", res.TagWarning) |
| 236 | } |
| 237 | // The promotion itself is real, warning or not. |
| 238 | p, err := Channel(ctx, s, pub, "bab", "km", "stable") |
| 239 | if err != nil || p.Version != "de2766f8" { |
| 240 | t.Fatalf("stable pointer = %+v, %v", p, err) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // Nothing below stable touches tags: edge and beta are staging rungs, and a |
| 245 | // build that never reaches stable never earns a name. |
| 246 | func TestBetaPromoteNoTag(t *testing.T) { |
| 247 | s := NewDirStore(t.TempDir()) |
| 248 | ctx := context.Background() |
| 249 | priv, _ := testKeys(t) |
| 250 | |
| 251 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one") |
| 252 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "edge", "0.5.0-1", PromoteOptions{}); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | |
| 256 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "beta", "0.5.0-1", PromoteOptions{}) |
| 257 | if err != nil || res.Tag != "" || res.TagWarning != "" { |
| 258 | t.Fatalf("beta promote touched tags: %+v, %v", res, err) |
| 259 | } |
| 260 | if _, err := GetTag(ctx, s, "bab", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) { |
| 261 | t.Fatalf("beta promote wrote a tag: %v", err) |
| 262 | } |
| 263 | // A canary promotion of the same iteration is equally tag-free. |
| 264 | res, err = PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "0.5.0-1", PromoteOptions{}) |
| 265 | if err != nil || res.Tag != "" || res.TagWarning != "" { |
| 266 | t.Fatalf("canary promote touched tags: %+v, %v", res, err) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | // tagFailStore fails every read under an app's tags/ prefix with an error that |
| 271 | // is NOT os.ErrNotExist — the shape of a bucket that is up but unhappy. |
| 272 | type tagFailStore struct { |
| 273 | Store |
| 274 | fail error |
| 275 | } |
| 276 | |
| 277 | func (s tagFailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 278 | if strings.Contains(key, "/tags/") { |
| 279 | return nil, "", s.fail |
| 280 | } |
| 281 | return s.Store.Get(ctx, key) |
| 282 | } |
| 283 | |
| 284 | // The promotion has already happened when the tag is cut, so a tag failure can |
| 285 | // only ever be a warning. Returning an error here would tell the operator the |
| 286 | // deploy failed when the fleet is already moving to the new build. |
| 287 | // |
| 288 | // Mutation-mindedness: make stableTag return its error and have PromoteOnLadder |
| 289 | // propagate it, and this is the test that fails — with a stable pointer that |
| 290 | // moved under a "promote failed" message. |
| 291 | func TestStablePromoteTagFailureIsAWarningNotAnError(t *testing.T) { |
| 292 | dir := NewDirStore(t.TempDir()) |
| 293 | s := tagFailStore{Store: dir, fail: fmt.Errorf("bucket said no")} |
| 294 | ctx := context.Background() |
| 295 | priv, pub := testKeys(t) |
| 296 | |
| 297 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one") |
| 298 | climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-1") |
| 299 | |
| 300 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1", |
| 301 | PromoteOptions{Actor: "paul@example.com"}) |
| 302 | if err != nil { |
| 303 | t.Fatalf("a failed tag write failed the promotion: %v", err) |
| 304 | } |
| 305 | if res.Tag != "" { |
| 306 | t.Fatalf("res.Tag = %q after a failed tag read", res.Tag) |
| 307 | } |
| 308 | if !strings.Contains(res.TagWarning, "bucket said no") { |
| 309 | t.Fatalf("warning does not carry the cause: %q", res.TagWarning) |
| 310 | } |
| 311 | // And the pointer really moved. |
| 312 | p, err := Channel(ctx, s, pub, "bab", "km", "stable") |
| 313 | if err != nil || p.Version != "0.5.0-1" { |
| 314 | t.Fatalf("stable pointer = %+v, %v", p, err) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // A second iteration under a target already tagged by an earlier one must not |
| 319 | // claim the earlier tag as its own. 0.5.0 got tagged at 0.5.0-1; when 0.5.0-2 |
| 320 | // later reaches stable, EnsureTag finds that existing tag (it names the same |
| 321 | // target) and hands it back unchanged — the promotion is real, but it did not |
| 322 | // cut or move the tag, so res.Tag must stay empty and the operator must be |
| 323 | // told which iteration the tag actually names. |
| 324 | // |
| 325 | // Mutation-mindedness: report res.Tag = tag.Version regardless of tag.Release |
| 326 | // and this is the test that catches it — a false "tagged 0.5.0" for a |
| 327 | // promotion the tag does not describe. |
| 328 | func TestStablePromoteAfterEarlierTagWarnsInsteadOfClaimingIt(t *testing.T) { |
| 329 | s := NewDirStore(t.TempDir()) |
| 330 | ctx := context.Background() |
| 331 | priv, pub := testKeys(t) |
| 332 | |
| 333 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one") |
| 334 | climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-1") |
| 335 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1", PromoteOptions{}); err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | |
| 339 | shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "second", "- two") |
| 340 | climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-2") |
| 341 | |
| 342 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", PromoteOptions{}) |
| 343 | if err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | if res.Tag != "" { |
| 347 | t.Fatalf("res.Tag = %q; a tag belonging to a different iteration must not be claimed", res.Tag) |
| 348 | } |
| 349 | if !strings.Contains(res.TagWarning, "already tagged at 0.5.0-1") { |
| 350 | t.Fatalf("TagWarning = %q; want it to name the iteration the tag actually carries", res.TagWarning) |
| 351 | } |
| 352 | // The promotion itself is real regardless of the tag warning. |
| 353 | p, err := Channel(ctx, s, pub, "bab", "km", "stable") |
| 354 | if err != nil || p.Version != "0.5.0-2" { |
| 355 | t.Fatalf("stable pointer = %+v, %v", p, err) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // A hotfix straight to stable — no rungs climbed — still cuts the version's |
| 360 | // tag: the ladder gate is bypassed, but stable promotion's tag-cutting is not |
| 361 | // gated on how the version got there. |
| 362 | func TestHotfixToStableCutsTag(t *testing.T) { |
| 363 | s := NewDirStore(t.TempDir()) |
| 364 | ctx := context.Background() |
| 365 | priv, _ := testKeys(t) |
| 366 | |
| 367 | shipLabelledIteration(t, s, "bab", "km", "0.7.0-1", "urgent fix", "- patched the leak") |
| 368 | |
| 369 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.7.0-1", |
| 370 | PromoteOptions{Hotfix: true, Actor: "paul@example.com"}) |
| 371 | if err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | if !res.Hotfix { |
| 375 | t.Fatalf("res.Hotfix = false for a hotfix promotion") |
| 376 | } |
| 377 | if res.Tag != "0.7.0" { |
| 378 | t.Fatalf("res.Tag = %q, TagWarning = %q; want 0.7.0 cut even via hotfix", res.Tag, res.TagWarning) |
| 379 | } |
| 380 | tag, err := GetTag(ctx, s, "bab", "km", "0.7.0") |
| 381 | if err != nil || tag.Release != "0.7.0-1" { |
| 382 | t.Fatalf("tag after hotfix promote: %+v, %v", tag, err) |
| 383 | } |
| 384 | } |
| 385 | |