| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | // reuseFixture: h1 follows canary/x and serves v1. Then v2 is shipped as |
| 18 | // "new v2" while a stale copy of v2 ("old v2", from an earlier release under |
| 19 | // the same name that retention has since pruned) is already staged, and the |
| 20 | // channel moves to v2. |
| 21 | type reuseFixture struct { |
| 22 | s Store |
| 23 | priv ed25519.PrivateKey |
| 24 | pub ed25519.PublicKey |
| 25 | opt string |
| 26 | reg *registry.Registry |
| 27 | } |
| 28 | |
| 29 | func newReuseFixture(t *testing.T) reuseFixture { |
| 30 | t.Helper() |
| 31 | return newReuseFixtureAt(t, t.TempDir()) |
| 32 | } |
| 33 | |
| 34 | // newReuseFixtureAt is newReuseFixture with the opt dir given. |
| 35 | func newReuseFixtureAt(t *testing.T, opt string) reuseFixture { |
| 36 | t.Helper() |
| 37 | ctx := context.Background() |
| 38 | f := reuseFixture{s: NewDirStore(t.TempDir()), opt: opt, reg: testRegistry(t)} |
| 39 | f.priv, f.pub = testKeys(t) |
| 40 | shipRelease(t, f.s, "bab", "myapp", "v1", adoptPlatform, []byte("v1")) |
| 41 | if err := Promote(ctx, f.s, f.priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | addRoute(t, f.reg, "h1.example.com", "myapp", "canary/x", false) |
| 45 | if err := Pass(ctx, f.reg, f.s, f.pub, f.opt, func(string) error { return nil }); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | if v, _ := LiveVersion(f.opt, "h1.example.com"); v != "v1" { |
| 49 | t.Fatalf("setup: live = %q, want v1", v) |
| 50 | } |
| 51 | if err := StageArtifact(f.opt, "bab", "v2", "myapp", []byte("old v2")); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | shipRelease(t, f.s, "bab", "myapp", "v2", adoptPlatform, []byte("new v2")) |
| 55 | if err := Promote(ctx, f.s, f.priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | return f |
| 59 | } |
| 60 | |
| 61 | func (f reuseFixture) pass(t *testing.T, opts ...PassOption) string { |
| 62 | t.Helper() |
| 63 | var out bytes.Buffer |
| 64 | withCapturedStdout(t, &out, func() { |
| 65 | if err := Pass(context.Background(), f.reg, f.s, f.pub, f.opt, func(string) error { return nil }, opts...); err != nil { |
| 66 | t.Fatalf("Pass: %v", err) |
| 67 | } |
| 68 | }) |
| 69 | return out.String() |
| 70 | } |
| 71 | |
| 72 | func (f reuseFixture) stagedV2(t *testing.T) string { |
| 73 | t.Helper() |
| 74 | b, err := os.ReadFile(VersionPath(f.opt, "bab", "v2", "myapp")) |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | return string(b) |
| 79 | } |
| 80 | |
| 81 | // assertRefused: h1 stays on v1, the stale copy is untouched, and the pass |
| 82 | // said why. |
| 83 | func (f reuseFixture) assertRefused(t *testing.T, out, why string) { |
| 84 | t.Helper() |
| 85 | if got := f.stagedV2(t); got != "old v2" { |
| 86 | t.Fatalf("the referenced copy was overwritten: it now holds %q", got) |
| 87 | } |
| 88 | if v, _ := LiveVersion(f.opt, "h1.example.com"); v != "v1" { |
| 89 | t.Fatalf("h1 live = %q, want v1: the reused name must not install", v) |
| 90 | } |
| 91 | if rt, _ := f.reg.Get("h1.example.com"); rt == nil || rt.Version != "v1" { |
| 92 | t.Fatalf("h1 row = %+v, want Version v1", rt) |
| 93 | } |
| 94 | if !strings.Contains(out, why) { |
| 95 | t.Fatalf("pass printed %q, want it to contain %q", out, why) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // Spec §12 "Restage refusal": an unreferenced stale copy is restaged. |
| 100 | func TestUnreferencedStaleCopyIsRestaged(t *testing.T) { |
| 101 | f := newReuseFixture(t) |
| 102 | out := f.pass(t) |
| 103 | if got := f.stagedV2(t); got != "new v2" { |
| 104 | t.Fatalf("staged v2 = %q, want the canonical bytes", got) |
| 105 | } |
| 106 | if v, _ := LiveVersion(f.opt, "h1.example.com"); v != "v2" { |
| 107 | t.Fatalf("h1 live = %q, want v2", v) |
| 108 | } |
| 109 | raw, _, err := f.s.Get(context.Background(), ManifestKey("bab", "myapp", "v2")) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | sib, err := os.ReadFile(StagedManifestPath(f.opt, "bab", "v2", "myapp")) |
| 114 | if err != nil || !bytes.Equal(sib, raw) { |
| 115 | t.Fatalf("sibling = %s, %v; want the canonical manifest bytes", sib, err) |
| 116 | } |
| 117 | if !strings.Contains(out, "restaging") { |
| 118 | t.Fatalf("pass printed %q; want a line saying the copy was restaged", out) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // A row's PrevVersion is a reference (spec §4.2's pin set). |
| 123 | func TestStaleCopyARegistryRowNamesIsNotOverwritten(t *testing.T) { |
| 124 | f := newReuseFixture(t) |
| 125 | // h2 is mid-provision, so no pass adopts it, but its row still names v2. |
| 126 | if err := f.reg.Upsert(registry.Route{Host: "h2.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Status: "creating"}); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if err := f.reg.SetVersions("h2.example.com", "v1", "v2"); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| 133 | } |
| 134 | |
| 135 | func TestStaleCopyALiveLinkNamesIsNotOverwritten(t *testing.T) { |
| 136 | f := newReuseFixture(t) |
| 137 | if err := PointLive(f.opt, "h2.example.com", "bab", "v2", "myapp"); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| 141 | } |
| 142 | |
| 143 | // A live link that reaches the stale copy through another link still refers |
| 144 | // to it: every hop of the chain is a reference. |
| 145 | func TestStaleCopyAChainedLiveLinkNamesIsNotOverwritten(t *testing.T) { |
| 146 | f := newReuseFixture(t) |
| 147 | current := filepath.Join(t.TempDir(), "operator-current") |
| 148 | if err := os.Symlink(VersionPath(f.opt, "bab", "v2", "myapp"), current); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if err := os.Symlink(current, LiveLink(f.opt, "h2.example.com")); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| 155 | } |
| 156 | |
| 157 | // GoodVersion and live generations arrive through a PinSource (plan 5). |
| 158 | func TestStaleCopyAPinSourceNamesIsNotOverwritten(t *testing.T) { |
| 159 | f := newReuseFixture(t) |
| 160 | src := PinSourceFunc(func(context.Context, string) (StagedPaths, error) { |
| 161 | return StagedPaths{VersionPath(f.opt, "bab", "v2", "myapp"): true}, nil |
| 162 | }) |
| 163 | f.assertRefused(t, f.pass(t, WithPinSource(src)), ErrVersionReused.Error()) |
| 164 | } |
| 165 | |
| 166 | // A generation running v2 on another host, as plan 5's agent records it. |
| 167 | func TestAGenerationsFileEntryProtectsAStagedCopy(t *testing.T) { |
| 168 | f := newReuseFixture(t) |
| 169 | if err := f.reg.Upsert(registry.Route{Host: "h2.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Status: "creating"}); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | dataDir := t.TempDir() |
| 173 | if err := WriteGenerations(GenerationsPath(dataDir), Generations{Hosts: map[string][]string{"h2.example.com": {"v2"}}}); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | f.assertRefused(t, f.pass(t, BakeDir(dataDir)), ErrVersionReused.Error()) |
| 177 | } |
| 178 | |
| 179 | // Review Focus 5: a generations file root cannot read is an incomplete pin |
| 180 | // set, and no restage happens that pass. |
| 181 | func TestAnUnreadableGenerationsFileNeverPermitsARestage(t *testing.T) { |
| 182 | f := newReuseFixture(t) |
| 183 | dataDir := t.TempDir() |
| 184 | path := GenerationsPath(dataDir) |
| 185 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if err := os.WriteFile(path, []byte(`{"schema":1,"hosts":{"h2.exa`), 0o644); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | f.assertRefused(t, f.pass(t, BakeDir(dataDir)), "what refers to it is unknown") |
| 192 | } |
| 193 | |
| 194 | // Every box before plan 5: no file is no generations, and an unreferenced |
| 195 | // stale copy is restaged as usual. |
| 196 | func TestAnAbsentGenerationsFileIsNoGenerations(t *testing.T) { |
| 197 | f := newReuseFixture(t) |
| 198 | f.pass(t, BakeDir(t.TempDir())) |
| 199 | if got := f.stagedV2(t); got != "new v2" { |
| 200 | t.Fatalf("staged v2 = %q, want it restaged", got) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestUnknownReferencesNeverPermitARestage(t *testing.T) { |
| 205 | f := newReuseFixture(t) |
| 206 | src := PinSourceFunc(func(context.Context, string) (StagedPaths, error) { |
| 207 | return nil, errors.New("generation report unreadable") |
| 208 | }) |
| 209 | f.assertRefused(t, f.pass(t, WithPinSource(src)), "what refers to it is unknown") |
| 210 | } |
| 211 | |
| 212 | // Two hosts moving onto one stale build in one pass: restaged once, both move. |
| 213 | func TestOneRestageServesEveryHostInThePass(t *testing.T) { |
| 214 | f := newReuseFixture(t) |
| 215 | addRoute(t, f.reg, "h3.example.com", "myapp", "canary/x", false) |
| 216 | out := f.pass(t) |
| 217 | if n := strings.Count(out, "restaging"); n != 1 { |
| 218 | t.Fatalf("restaged %d times, want once:\n%s", n, out) |
| 219 | } |
| 220 | for _, h := range []string{"h1.example.com", "h3.example.com"} { |
| 221 | if v, _ := LiveVersion(f.opt, h); v != "v2" { |
| 222 | t.Fatalf("%s live = %q, want v2", h, v) |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // Unchanged behaviour: a reused copy that matches is not downloaded again. |
| 228 | func TestMatchingStagedCopyIsReusedWithoutADownload(t *testing.T) { |
| 229 | s := NewDirStore(t.TempDir()) |
| 230 | ctx := context.Background() |
| 231 | priv, pub := testKeys(t) |
| 232 | opt := t.TempDir() |
| 233 | reg := testRegistry(t) |
| 234 | shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("v2 bytes")) |
| 235 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | if err := StageArtifact(opt, "bab", "v2", "myapp", []byte("v2 bytes")); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | old := time.Unix(1_600_000_000, 0) |
| 242 | bin := VersionPath(opt, "bab", "v2", "myapp") |
| 243 | if err := os.Chtimes(bin, old, old); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | addRoute(t, reg, "h1.example.com", "myapp", "canary/x", false) |
| 247 | if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if v, _ := LiveVersion(opt, "h1.example.com"); v != "v2" { |
| 251 | t.Fatalf("live = %q, want v2", v) |
| 252 | } |
| 253 | if info, err := os.Stat(bin); err != nil || !info.ModTime().Equal(old) { |
| 254 | t.Fatalf("the matching copy was rewritten (mtime %v, %v)", info.ModTime(), err) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // Review Focus 3: when the bucket cannot answer for the manifest, a staged |
| 259 | // copy keeps today's answer. It is used, never restaged, and nothing claims a |
| 260 | // mismatch the box could not establish. |
| 261 | func TestUnreadableManifestKeepsTodaysAnswerForAStagedCopy(t *testing.T) { |
| 262 | dir := t.TempDir() |
| 263 | s := NewDirStore(dir) |
| 264 | ctx := context.Background() |
| 265 | priv, pub := testKeys(t) |
| 266 | opt := t.TempDir() |
| 267 | reg := testRegistry(t) |
| 268 | shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("new v2")) |
| 269 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if err := StageArtifact(opt, "bab", "v2", "myapp", []byte("old v2")); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | if err := os.Remove(filepath.Join(dir, filepath.FromSlash(ManifestKey("bab", "myapp", "v2")))); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | addRoute(t, reg, "h1.example.com", "myapp", "canary/x", false) |
| 279 | var out bytes.Buffer |
| 280 | withCapturedStdout(t, &out, func() { |
| 281 | if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | }) |
| 285 | if v, _ := LiveVersion(opt, "h1.example.com"); v != "v2" { |
| 286 | t.Fatalf("live = %q, want v2 (today's answer)", v) |
| 287 | } |
| 288 | b, _ := os.ReadFile(VersionPath(opt, "bab", "v2", "myapp")) |
| 289 | if string(b) != "old v2" { |
| 290 | t.Fatalf("staged v2 = %q: nothing may be restaged from a manifest the box could not read", b) |
| 291 | } |
| 292 | if strings.Contains(out.String(), "restaging") || strings.Contains(out.String(), ErrVersionReused.Error()) { |
| 293 | t.Fatalf("pass claimed a mismatch it could not establish:\n%s", out.String()) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | // The held stage has no registry rows, generations file or pin source in hand, |
| 298 | // so it cannot know what refers to a staged copy. A copy that differs from its |
| 299 | // release is refused and left as it was; only a full pass restages it. |
| 300 | func TestStageHeldNeverRestagesADifferingCopy(t *testing.T) { |
| 301 | ctx, s, opt := heldFixture(t) |
| 302 | if err := StageArtifact(opt, "bab", "v1", "console", []byte("old v1")); err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | m, err := StageHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1") |
| 306 | if err == nil || m != nil { |
| 307 | t.Fatalf("StageHeld = %+v, %v; want a refusal", m, err) |
| 308 | } |
| 309 | if !strings.Contains(err.Error(), "what refers to it is unknown") { |
| 310 | t.Fatalf("error = %q, want it to say what refers to the copy is unknown", err) |
| 311 | } |
| 312 | b, rerr := os.ReadFile(VersionPath(opt, "bab", "v1", "console")) |
| 313 | if rerr != nil || string(b) != "old v1" { |
| 314 | t.Fatalf("staged v1 = %q, %v; want it untouched", b, rerr) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // Astra r3 N4, ruling R19 item 5: the opt dir is itself a symlink |
| 319 | // (/opt/carlos -> /srv/carlos), and live/h2 reaches the stale v2 through two |
| 320 | // relative hops: live/h2 -> ../current, current -> versions/v2/bab/myapp. |
| 321 | // The hops resolve under the real dir. The pass now refuses the symlinked |
| 322 | // opt directory before writing its bake clock, preserving the staged copy. |
| 323 | func TestStaleCopyTwoRelativeHopsUnderASymlinkedOptDirNameIsNotOverwritten(t *testing.T) { |
| 324 | real := t.TempDir() |
| 325 | opt := filepath.Join(t.TempDir(), "opt") |
| 326 | if err := os.Symlink(real, opt); err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | f := newReuseFixtureAt(t, real) |
| 330 | f.opt = opt |
| 331 | rel, err := filepath.Rel(opt, VersionPath(opt, "bab", "v2", "myapp")) |
| 332 | if err != nil { |
| 333 | t.Fatal(err) |
| 334 | } |
| 335 | if err := os.Symlink(rel, filepath.Join(opt, "current")); err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | if err := os.Symlink(filepath.Join("..", "current"), LiveLink(opt, "h2.example.com")); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | pins, err := BoxPins(opt, nil) |
| 342 | if err != nil { |
| 343 | t.Fatalf("BoxPins: %v", err) |
| 344 | } |
| 345 | if pins.Has(VersionPath(opt, "bab", "v2", "myapp")) { |
| 346 | t.Fatalf("setup: BoxPins = %v already names the copy lexically; the test would not exercise the real-path check", pins) |
| 347 | } |
| 348 | err = Pass(context.Background(), f.reg, f.s, f.pub, f.opt, func(string) error { return nil }) |
| 349 | if err == nil { |
| 350 | t.Fatal("pass accepted a symlinked opt directory") |
| 351 | } |
| 352 | f.opt = real |
| 353 | f.assertRefused(t, err.Error(), "record bake clock") |
| 354 | } |
| 355 | |
| 356 | // sleeperRow is a held sleeper of app the pass does not adopt (mid |
| 357 | // provision), whose only reference to its builds is its settings and build |
| 358 | // state copy: no Version, PrevVersion, GoodVersion, live link or generation. |
| 359 | func (f reuseFixture) sleeperRow(t *testing.T, host, app string, u UpdateSettings, bs BuildStateCopy) { |
| 360 | t.Helper() |
| 361 | if err := f.reg.Upsert(registry.Route{Host: host, Kind: "instance", App: app, Channel: "canary/x", Status: "creating", |
| 362 | Hibernate: true, Socket: "/run/carlos/" + host + ".sock"}); err != nil { |
| 363 | t.Fatal(err) |
| 364 | } |
| 365 | if err := f.reg.SetUpdateSettings(host, u.Column()); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | if err := f.reg.SetBuildState(host, bs.Column()); err != nil { |
| 369 | t.Fatal(err) |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // Astra round 1 finding 2 (§4.1 retained identity): a sleeper last ran v2, |
| 374 | // then received cold installs of later builds, so only its build state names |
| 375 | // v2. Another instance adopting a promoted reuse of the name v2 must not |
| 376 | // replace that copy (executable and sibling): the restaging pin set holds |
| 377 | // every build a settings pin, a last_executed or a resolved T names. |
| 378 | func TestStaleCopyABuildStateOrPinNamesIsNotOverwritten(t *testing.T) { |
| 379 | manual := UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"} |
| 380 | pinned := UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v2"} |
| 381 | for name, c := range map[string]struct { |
| 382 | u UpdateSettings |
| 383 | bs BuildStateCopy |
| 384 | }{ |
| 385 | "last_executed": {manual, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v2"}}}, |
| 386 | "resolved T": {manual, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 3, Target: "v2"}}}}, |
| 387 | "pin": {pinned, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}}}, |
| 388 | } { |
| 389 | t.Run(name, func(t *testing.T) { |
| 390 | f := newReuseFixture(t) |
| 391 | sib, err := os.ReadFile(StagedManifestPath(f.opt, "bab", "v2", "myapp")) |
| 392 | if err != nil && !os.IsNotExist(err) { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | f.sleeperRow(t, "h2.example.com", "myapp", c.u, c.bs) |
| 396 | f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| 397 | if now, _ := os.ReadFile(StagedManifestPath(f.opt, "bab", "v2", "myapp")); !bytes.Equal(now, sib) { |
| 398 | t.Fatalf("the referenced copy's sibling was replaced: %q, was %q", now, sib) |
| 399 | } |
| 400 | }) |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // Finding 2's completeness half: a held row of the same app whose copy cannot |
| 405 | // be read completely (never read, or its last refresh failed) refuses the |
| 406 | // restage, since its last_executed may be the reused name. A row of another |
| 407 | // app with the same problem does not block this one. |
| 408 | func TestAnIncompleteBuildStateRefusesARestageOnlyForItsApp(t *testing.T) { |
| 409 | manual := UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"} |
| 410 | for name, bs := range map[string]BuildStateCopy{ |
| 411 | "never read": {}, |
| 412 | "refresh failed": {Known: true, Replica: true, ReadErr: "s3: timeout", State: &BuildState{LastExecuted: "v1"}}, |
| 413 | } { |
| 414 | t.Run(name, func(t *testing.T) { |
| 415 | f := newReuseFixture(t) |
| 416 | f.sleeperRow(t, "h2.example.com", "myapp", manual, bs) |
| 417 | f.assertRefused(t, f.pass(t), "what refers to it is unknown") |
| 418 | }) |
| 419 | t.Run(name+", another app", func(t *testing.T) { |
| 420 | f := newReuseFixture(t) |
| 421 | f.sleeperRow(t, "h3.example.com", "otherapp", manual, bs) |
| 422 | f.pass(t) |
| 423 | if got := f.stagedV2(t); got != "new v2" { |
| 424 | t.Fatalf("staged v2 = %q: another app's unreadable build state blocked this app's restage", got) |
| 425 | } |
| 426 | }) |
| 427 | } |
| 428 | } |
| 429 | |