| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "os" |
| 11 | "path" |
| 12 | "path/filepath" |
| 13 | "reflect" |
| 14 | "slices" |
| 15 | "strings" |
| 16 | "sync" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/hostbox" |
| 21 | "github.com/carlosframework/platform/internal/hostclaim" |
| 22 | "github.com/carlosframework/platform/internal/hostfiles" |
| 23 | "github.com/carlosframework/platform/internal/lease" |
| 24 | "github.com/carlosframework/platform/internal/moves" |
| 25 | "github.com/carlosframework/platform/internal/moves/archive" |
| 26 | "github.com/carlosframework/platform/internal/provision" |
| 27 | ) |
| 28 | |
| 29 | // The cut's files preflight (#236; Paul's ruling of 2026-10-15): a source |
| 30 | // whose files manifest a recovery could not restore, because an entry path |
| 31 | // is past the readers' cap, is refused at S1 before anything is staged, |
| 32 | // stopped or cut. |
| 33 | |
| 34 | // capPath is a clean relative path of exactly n bytes: 200-byte segments |
| 35 | // joined by "/", as hostfiles' own tests build them. |
| 36 | func capPath(n int) string { |
| 37 | var b strings.Builder |
| 38 | for b.Len() < n { |
| 39 | if b.Len() > 0 { |
| 40 | b.WriteByte('/') |
| 41 | } |
| 42 | b.WriteString(strings.Repeat("x", min(200, n-b.Len()))) |
| 43 | } |
| 44 | return b.String() |
| 45 | } |
| 46 | |
| 47 | // capCase is one manifest shape: its one entry's kind (live, tombstone or |
| 48 | // skipped) at a path of the given length. |
| 49 | type capCase struct { |
| 50 | kind string |
| 51 | n int |
| 52 | } |
| 53 | |
| 54 | // capCases are the shapes every test here runs: an ordinary manifest, a |
| 55 | // live entry at the cap, and each kind of entry one byte past it. |
| 56 | var capCases = map[string]capCase{ |
| 57 | "an ordinary manifest": {"live", len("a.txt")}, |
| 58 | "a live entry at the cap": {"live", hostfiles.MaxPathBytes}, |
| 59 | "a live entry past it": {"live", hostfiles.MaxPathBytes + 1}, |
| 60 | "a tombstone past it": {"tombstone", hostfiles.MaxPathBytes + 1}, |
| 61 | "a skipped entry past it": {"skipped", hostfiles.MaxPathBytes + 1}, |
| 62 | } |
| 63 | |
| 64 | func (c capCase) path() string { |
| 65 | if c.n == len("a.txt") { |
| 66 | return "a.txt" |
| 67 | } |
| 68 | return capPath(c.n) |
| 69 | } |
| 70 | |
| 71 | // manifest is the case's manifest for generation gen at location loc: a |
| 72 | // live entry names an object under gen's files prefix, as the publisher |
| 73 | // writes it. |
| 74 | func (c capCase) manifest(loc, gen string) hostfiles.Manifest { |
| 75 | var e hostfiles.Entry |
| 76 | switch c.kind { |
| 77 | case "live": |
| 78 | sum := sha([]byte("A")) |
| 79 | e = hostfiles.Entry{Object: movesFilesPrefix(loc, gen) + fmt.Sprintf("objects/%016d-%s", 1, sum), SHA256: sum, Size: 1, Mode: 0o600, Seq: 1} |
| 80 | case "tombstone": |
| 81 | e = hostfiles.Entry{Seq: 1, Deleted: true} |
| 82 | case "skipped": |
| 83 | e = hostfiles.Entry{Seq: 1, Skipped: "too-large"} |
| 84 | } |
| 85 | return hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 1, Entries: map[string]hostfiles.Entry{c.path(): e}, Platform: map[string]hostfiles.Entry{}} |
| 86 | } |
| 87 | |
| 88 | // readerRefuses is what the files readers do with m today: ValidateManifest |
| 89 | // (checkManifest, the establishment's reader) refuses it as ErrTooDeep. The |
| 90 | // tests hold the cut to this verdict rather than to a fixed answer per |
| 91 | // kind: today the readers cap every entry, tombstones and skipped entries |
| 92 | // included, so the cut refuses all three past the cap. If region HA |
| 93 | // relaxes the readers for tombstones and skipped entries (its part of |
| 94 | // #236), RestorableWithinCap follows, and the cut with it. |
| 95 | func readerRefuses(m hostfiles.Manifest, loc, gen string) bool { |
| 96 | return errors.Is(hostfiles.ValidateManifest(m, []string{movesFilesPrefix(loc, gen)}), hostfiles.ErrTooDeep) |
| 97 | } |
| 98 | |
| 99 | // putP1Manifest stores m as the current version of gen's files manifest in |
| 100 | // the Phase 1 harness's data bucket, written directly (a manifest from |
| 101 | // before the cap), never through the staging mirror. |
| 102 | func putP1Manifest(t *testing.T, h *phase1Harness, loc, gen string, m hostfiles.Manifest) { |
| 103 | t.Helper() |
| 104 | raw, err := json.Marshal(m) |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | key := hostfiles.ManifestKey(movesFilesPrefix(loc, gen)) |
| 109 | h.f.store("data-ie", key, fakeMoveObject{lease.MirrorObject{ETag: "manifest-" + gen, Size: int64(len(raw))}, string(raw)}) |
| 110 | h.f.setVersion("data-ie", key, "ver-manifest-"+gen) |
| 111 | } |
| 112 | |
| 113 | // A source whose files manifest has an entry past the cap is refused at S1 |
| 114 | // with MV-N501, and nothing is staged: no staging request, no touch of the |
| 115 | // target in the claim, the move still preparing. A manifest the readers |
| 116 | // accept passes, and the round stages as before. |
| 117 | func TestPreparingRefusesAFilesManifestPastTheCap(t *testing.T) { |
| 118 | t.Parallel() |
| 119 | for name, c := range capCases { |
| 120 | t.Run(name, func(t *testing.T) { |
| 121 | h := newPhase1Harness(t) |
| 122 | h.start(t, "manual") |
| 123 | loc := "instances/" + p1Host + "/" + h.inc |
| 124 | m := c.manifest(loc, "cur") |
| 125 | putP1Manifest(t, h, loc, "cur", m) |
| 126 | touched := h.claim(t).Touched |
| 127 | h.pass(t) |
| 128 | sr := h.sourceReport(t) |
| 129 | if sr == nil { |
| 130 | t.Fatal("no source report") |
| 131 | } |
| 132 | refuses := readerRefuses(m, loc, "cur") |
| 133 | if c.kind == "live" && refuses != (c.n > hostfiles.MaxPathBytes) { |
| 134 | t.Fatalf("the reader's verdict on a live entry of %d bytes is %v", c.n, refuses) |
| 135 | } |
| 136 | if !refuses { |
| 137 | if !sr.S1.OK || len(h.f.stagingCalls()) == 0 { |
| 138 | t.Fatalf("a manifest the readers accept: S1 %+v, %d staging calls", sr.S1, len(h.f.stagingCalls())) |
| 139 | } |
| 140 | return |
| 141 | } |
| 142 | if sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| 143 | t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilesPastCap(p1Host)) |
| 144 | } |
| 145 | if calls := h.f.stagingCalls(); len(calls) != 0 { |
| 146 | t.Fatalf("staged despite the refusal: %+v", calls) |
| 147 | } |
| 148 | if got := h.claim(t).Touched; !reflect.DeepEqual(got, touched) { |
| 149 | t.Fatalf("the claim was touched: %+v, was %+v", got, touched) |
| 150 | } |
| 151 | if st := h.read(t, p1Host).Move.State; st != provision.MovePreparing { |
| 152 | t.Fatalf("the move is %s", st) |
| 153 | } |
| 154 | }) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // A files manifest that cannot be read ends the round as the other read |
| 159 | // errors do: no source report, nothing staged, the reason in the log. |
| 160 | func TestPreparingEndsTheRoundWhenTheFilesManifestCannotBeRead(t *testing.T) { |
| 161 | h := newPhase1Harness(t) |
| 162 | h.start(t, "manual") |
| 163 | h.vs.deny = func(op, _, key string) error { |
| 164 | if op == "list-versions" && strings.Contains(key, "/files/g-") { |
| 165 | return errors.New("injected 403") |
| 166 | } |
| 167 | return nil |
| 168 | } |
| 169 | logs := captureLog(t) |
| 170 | h.pass(t) |
| 171 | if sr := h.sourceReport(t); sr != nil { |
| 172 | t.Fatalf("a report was written: %+v", sr) |
| 173 | } |
| 174 | if calls := h.f.stagingCalls(); len(calls) != 0 { |
| 175 | t.Fatalf("staged: %+v", calls) |
| 176 | } |
| 177 | if !strings.Contains(logs(), "read the files manifest") { |
| 178 | t.Fatalf("no log of the read:\n%s", logs()) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // stallingVersioned is the harness's versioned reader with the files |
| 183 | // manifest's download stalled: GetVersion on a manifest key blocks until |
| 184 | // its context ends, as an S3 body that stops arriving does, and answers |
| 185 | // the context's error. |
| 186 | type stallingVersioned struct { |
| 187 | *fakeVersioned |
| 188 | stalled chan struct{} // closed when the first stalled read starts |
| 189 | once sync.Once |
| 190 | } |
| 191 | |
| 192 | func (v *stallingVersioned) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) { |
| 193 | if strings.Contains(key, "/files/g-") && strings.HasSuffix(key, "/manifest.json") { |
| 194 | v.once.Do(func() { close(v.stalled) }) |
| 195 | <-ctx.Done() |
| 196 | return nil, ctx.Err() |
| 197 | } |
| 198 | return v.fakeVersioned.GetVersion(ctx, bucket, key, versionID) |
| 199 | } |
| 200 | |
| 201 | // Fix round 1, P2 1: the preflight's reads are bounded |
| 202 | // (movesFilesPreflightTimeout). A stalled manifest download is cancelled at |
| 203 | // the bound, even under a pass with no deadline of its own; the round ends |
| 204 | // with no report and nothing staged; the staging lease is released and the |
| 205 | // pass mutex is free, so the next pass runs. |
| 206 | func TestPreparingBoundsAStalledFilesManifestRead(t *testing.T) { |
| 207 | const bound = 50 * time.Millisecond |
| 208 | orig := movesFilesPreflightBound |
| 209 | movesFilesPreflightBound = bound |
| 210 | defer func() { movesFilesPreflightBound = orig }() |
| 211 | if movesFilesPreflightTimeout <= bound || movesFilesPreflightTimeout > hostbox.RequestLimit { |
| 212 | t.Fatalf("the production bound %v is not a short one inside the claim's request limit", movesFilesPreflightTimeout) |
| 213 | } |
| 214 | |
| 215 | h := newPhase1Harness(t) |
| 216 | h.start(t, "manual") |
| 217 | loc := "instances/" + p1Host + "/" + h.inc |
| 218 | putP1Manifest(t, h, loc, "cur", capCases["an ordinary manifest"].manifest(loc, "cur")) |
| 219 | sv := &stallingVersioned{fakeVersioned: h.vs, stalled: make(chan struct{})} |
| 220 | h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return sv, nil } |
| 221 | logs := captureLog(t) |
| 222 | |
| 223 | done := make(chan error, 1) |
| 224 | start := time.Now() |
| 225 | go func() { done <- movesPass(context.Background(), h.s, h.w()) }() |
| 226 | select { |
| 227 | case <-done: |
| 228 | case <-time.After(10 * time.Second): |
| 229 | t.Fatal("the pass is still blocked on the stalled read") |
| 230 | } |
| 231 | if elapsed := time.Since(start); elapsed < bound { |
| 232 | t.Fatalf("the pass ended after %v, before the bound %v", elapsed, bound) |
| 233 | } |
| 234 | select { |
| 235 | case <-sv.stalled: |
| 236 | default: |
| 237 | t.Fatal("the manifest download was never asked for") |
| 238 | } |
| 239 | if sr := h.sourceReport(t); sr != nil { |
| 240 | t.Fatalf("a report was written: %+v", sr) |
| 241 | } |
| 242 | if calls := h.f.stagingCalls(); len(calls) != 0 { |
| 243 | t.Fatalf("staged: %+v", calls) |
| 244 | } |
| 245 | if !strings.Contains(logs(), context.DeadlineExceeded.Error()) { |
| 246 | t.Fatalf("no log of the cancelled read:\n%s", logs()) |
| 247 | } |
| 248 | h.lease.mu.Lock() |
| 249 | acquired, released := h.lease.acquired, h.lease.released |
| 250 | h.lease.mu.Unlock() |
| 251 | if acquired == 0 || acquired != released { |
| 252 | t.Fatalf("staging lease acquired %d, released %d", acquired, released) |
| 253 | } |
| 254 | if !h.w().pass.TryLock() { |
| 255 | t.Fatal("the pass mutex is still held") |
| 256 | } |
| 257 | h.w().pass.Unlock() |
| 258 | |
| 259 | // The download recovers: the next pass reports and stages. |
| 260 | h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil } |
| 261 | h.pass(t) |
| 262 | if sr := h.sourceReport(t); sr == nil || !sr.S1.OK || len(h.f.stagingCalls()) == 0 { |
| 263 | t.Fatalf("the next pass: %+v, %d staging calls", sr, len(h.f.stagingCalls())) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // Fix round 1, P2 2: the files directory itself is held to the tree's cap. |
| 268 | // A file whose path inside it is 4096 bytes (the archive format's own |
| 269 | // bound, which used to pass) is refused at S1 with MV-N502 before anything |
| 270 | // is staged; one of 4095 bytes passes and its archive is staged. The |
| 271 | // files directory is under the data directory, so both absolute paths are |
| 272 | // past PATH_MAX: the archive reads through an os.Root. |
| 273 | func TestPreparingRefusesAFilePathPastTheTreeCap(t *testing.T) { |
| 274 | for _, n := range []int{hostfiles.MaxPathBytes + 1, hostfiles.MaxPathBytes} { |
| 275 | t.Run(fmt.Sprint(n), func(t *testing.T) { |
| 276 | h := newPhase1Harness(t) |
| 277 | h.start(t, "manual") |
| 278 | root, err := os.OpenRoot(filepath.Join(h.cfg.DataDir, p1Host)) |
| 279 | if err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | defer root.Close() |
| 283 | leaf := capPath(n) |
| 284 | if err := root.MkdirAll(path.Dir(leaf), 0o755); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | if err := root.WriteFile(leaf, []byte("deep"), 0o644); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | h.pass(t) |
| 291 | sr := h.sourceReport(t) |
| 292 | if sr == nil { |
| 293 | t.Fatal("no source report") |
| 294 | } |
| 295 | if n > hostfiles.MaxPathBytes { |
| 296 | if sr.S1.OK || sr.S1.Reason != s1FilePastCap(p1Host) { |
| 297 | t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilePastCap(p1Host)) |
| 298 | } |
| 299 | if calls := h.f.stagingCalls(); slices.ContainsFunc(calls, func(c stagingCall) bool { return strings.HasPrefix(c.Key, moves.FilesPrefix(p1Host, p1ID)) }) { |
| 300 | t.Fatalf("an archive was staged: %+v", calls) |
| 301 | } |
| 302 | return |
| 303 | } |
| 304 | if !sr.S1.OK || sr.Archive == nil { |
| 305 | t.Fatalf("a path at the cap: S1 %+v, archive %+v", sr.S1, sr.Archive) |
| 306 | } |
| 307 | }) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // Fix round 1, P2 2(b): a path at the tree's cap goes end to end. The |
| 312 | // source's archive carries it, the target's Extract places it, and the |
| 313 | // prepared files manifest build stages it (hostfiles.Prepared.Stage), with |
| 314 | // the extracted tree's absolute paths past PATH_MAX: a 4095-byte file, a |
| 315 | // deep tree of files near it, and an ordinary one. The build reads through |
| 316 | // an os.Root over the files directory, every name relative to it. |
| 317 | func TestATreeCapPathReachesThePreparedManifest(t *testing.T) { |
| 318 | ctx := context.Background() |
| 319 | x := newFilesFixture(t) |
| 320 | src := filepath.Join(t.TempDir(), "h") |
| 321 | if err := os.Mkdir(src, 0o755); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | root, err := os.OpenRoot(src) |
| 325 | if err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | defer root.Close() |
| 329 | files := map[string]string{"a.txt": "A"} |
| 330 | files[capPath(hostfiles.MaxPathBytes)] = "at the cap" |
| 331 | for _, n := range []int{hostfiles.MaxPathBytes - 1, 4000, 3900} { |
| 332 | files[capPath(n)+"-"+fmt.Sprint(n)] = fmt.Sprint(n) // under one deep tree |
| 333 | } |
| 334 | for rel, body := range files { |
| 335 | if len(rel) > hostfiles.MaxPathBytes { |
| 336 | rel = rel[:hostfiles.MaxPathBytes] |
| 337 | } |
| 338 | if err := root.MkdirAll(path.Dir(rel), 0o755); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | if err := root.WriteFile(rel, []byte(body), 0o644); err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | } |
| 345 | var tarz bytes.Buffer |
| 346 | if _, _, _, err := archive.Build(src, "", &tarz); err != nil { |
| 347 | t.Fatalf("the source's archive: %v", err) |
| 348 | } |
| 349 | dst := filepath.Join(x.dir, filesHost) |
| 350 | if len(dst)+1+hostfiles.MaxPathBytes <= 4096 { |
| 351 | t.Fatalf("%s does not put the tree past PATH_MAX", dst) |
| 352 | } |
| 353 | if err := archive.Extract(bytes.NewReader(tarz.Bytes()), dst, x.envPath()); err != nil { |
| 354 | t.Fatalf("the target's extract: %v", err) |
| 355 | } |
| 356 | if _, err := x.f.BuildPrepared(ctx, filesLoc, "g1", "g1", 7, dst, x.envPath(), false, hostfiles.WakeState{V: 1, Host: filesHost, Timers: []hostfiles.WakeTimer{}}); err != nil { |
| 357 | t.Fatalf("the prepared manifest: %v", err) |
| 358 | } |
| 359 | m, found := x.manifest("g1") |
| 360 | if !found { |
| 361 | t.Fatal("no prepared manifest") |
| 362 | } |
| 363 | staged := 0 |
| 364 | err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error { |
| 365 | if err != nil || d.IsDir() { |
| 366 | return err |
| 367 | } |
| 368 | body, err := root.ReadFile(rel) |
| 369 | if err != nil { |
| 370 | return err |
| 371 | } |
| 372 | if e, ok := m.Entries[rel]; !ok || e.SHA256 != sha(body) || e.Skipped != "" { |
| 373 | t.Errorf("%d-byte path: entry %+v, %v", len(rel), e, ok) |
| 374 | } |
| 375 | staged++ |
| 376 | return nil |
| 377 | }) |
| 378 | if err != nil { |
| 379 | t.Fatal(err) |
| 380 | } |
| 381 | if staged != len(files) || len(m.Entries) != len(files) { |
| 382 | t.Fatalf("%d files, %d walked, %d entries", len(files), staged, len(m.Entries)) |
| 383 | } |
| 384 | if err := movesFilesRestorable(m); err != nil { |
| 385 | t.Fatalf("the prepared manifest is not restorable: %v", err) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | // The cut's requested round runs the same source round (G13): a manifest |
| 390 | // past the cap that appeared after readiness sends the cut back to ready |
| 391 | // before anything holds, fences or stops, with MV-N501 in S1, and the move |
| 392 | // is not pinned again while it stands. Once an operator replaces the |
| 393 | // manifest, a passing round pins it again and the cut admits. |
| 394 | func TestCutRefusesAFilesManifestPastTheCap(t *testing.T) { |
| 395 | h := newCutHarness(t) |
| 396 | h.ready(t) |
| 397 | h.ev.stopped = false // stop at stopping, to look at the admission |
| 398 | past := capCases["a live entry past it"] |
| 399 | putP1Manifest(t, h.phase1Harness, cutRoot, "cur", past.manifest(cutRoot, "cur")) |
| 400 | h.cut(t) |
| 401 | h.pass(t) |
| 402 | m := h.move(t) |
| 403 | if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil { |
| 404 | t.Fatalf("not back to ready: %+v", m) |
| 405 | } |
| 406 | if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 { |
| 407 | t.Fatalf("something stopped: %d evidence calls, holds %v, %d fences", h.ev.calls, h.holds, h.fences) |
| 408 | } |
| 409 | if sr := h.sourceReport(t); sr == nil || sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| 410 | t.Fatalf("S1 = %+v, want %q", sr, s1FilesPastCap(p1Host)) |
| 411 | } |
| 412 | h.targetPass(t, h.target()) |
| 413 | h.pass(t) |
| 414 | if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil { |
| 415 | t.Fatalf("pinned again while the manifest stands: %+v", m) |
| 416 | } |
| 417 | |
| 418 | // The operator replaces the manifest. |
| 419 | putP1Manifest(t, h.phase1Harness, cutRoot, "cur", capCases["an ordinary manifest"].manifest(cutRoot, "cur")) |
| 420 | // A refused report carries no archive, so the source reports once, the |
| 421 | // target checks that report, and the next round pins. |
| 422 | h.pass(t) |
| 423 | h.targetPass(t, h.target()) |
| 424 | h.pass(t) |
| 425 | if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil { |
| 426 | t.Fatalf("not pinned again after the replacement: %+v", m) |
| 427 | } |
| 428 | h.cut(t) |
| 429 | h.pass(t) |
| 430 | if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping { |
| 431 | t.Fatalf("the cut did not admit after the replacement: %+v", m) |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // The cut's preflight and the recovery's establishment agree on every |
| 436 | // manifest shape, so they cannot drift: the source's round before the flip |
| 437 | // (sourceFilesRefusal over the holder at src) refuses exactly the |
| 438 | // manifests a recovery's Establish refuses as ErrTooDeep, through the |
| 439 | // src_gen bridge of a clean return and through a revert's own lineage, and |
| 440 | // both agree with the readers (readerRefuses). Both call |
| 441 | // movesFilesRestorable; a manifest the cut passes, the recovery |
| 442 | // establishes from. |
| 443 | func TestCutPreflightAndRecoveryAgreeOnTheCap(t *testing.T) { |
| 444 | ctx := context.Background() |
| 445 | recoveries := map[string]func(o *hostclaim.Observed){ |
| 446 | "a clean return": func(*hostclaim.Observed) {}, |
| 447 | "a revert": func(o *hostclaim.Observed) { |
| 448 | o.Claim.Op.Kind = hostclaim.KindReverting |
| 449 | delete(o.Claim.Op.Intent, "rollback") |
| 450 | o.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "src", Region: "uk", Predecessor: "@root", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}, |
| 451 | {Generation: "r1", Region: "uk", Predecessor: "src", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}} |
| 452 | }, |
| 453 | } |
| 454 | for name, c := range capCases { |
| 455 | for rname, shape := range recoveries { |
| 456 | t.Run(name+", "+rname, func(t *testing.T) { |
| 457 | x := newFilesFixture(t) |
| 458 | x.writeEnv("K=1\n") |
| 459 | m := c.manifest(filesLoc, "src") |
| 460 | raw, err := json.Marshal(m) |
| 461 | if err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "src")), raw) |
| 465 | |
| 466 | // The cut's side: the source's round before the flip, its |
| 467 | // holder at src. |
| 468 | w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| 469 | Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| 470 | pre := servingObserved("src", "src:uk:@root:uk") |
| 471 | why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder) |
| 472 | if err != nil { |
| 473 | t.Fatalf("the preflight: %v", err) |
| 474 | } |
| 475 | if why != "" && why != s1FilesPastCap(filesHost) { |
| 476 | t.Fatalf("the preflight's reason %q", why) |
| 477 | } |
| 478 | |
| 479 | // The recovery's side: the first recovery admission after |
| 480 | // the cut, whose src_gen is the holder's generation then. |
| 481 | o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| 482 | shape(&o) |
| 483 | o = x.observe(o, "src", "r1") |
| 484 | est := x.f.Establish(ctx, o, launchInputs("r1"), x) |
| 485 | if est != nil && !errors.Is(est, hostfiles.ErrTooDeep) { |
| 486 | t.Fatalf("the establishment failed for another reason: %v", est) |
| 487 | } |
| 488 | |
| 489 | cut, recovery, reader := why != "", est != nil, readerRefuses(m, filesLoc, "src") |
| 490 | if cut != recovery || cut != reader { |
| 491 | t.Fatalf("the cut refuses %v, the recovery refuses %v (%v), the readers refuse %v", cut, recovery, est, reader) |
| 492 | } |
| 493 | }) |
| 494 | } |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | // The establishment applies the cap before any origin proof: a manifest |
| 499 | // past it is ErrTooDeep with no Proven call, by the same function the |
| 500 | // cut's preflight calls. |
| 501 | func TestEstablishRefusesPastTheCapBeforeTheProof(t *testing.T) { |
| 502 | ctx := context.Background() |
| 503 | x := newFilesFixture(t) |
| 504 | x.writeEnv("K=1\n") |
| 505 | m := capCases["a live entry past it"].manifest(filesLoc, "src") |
| 506 | if err := movesFilesRestorable(m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| 507 | t.Fatalf("movesFilesRestorable: %v", err) |
| 508 | } |
| 509 | proof := &countingProof{proven: map[string]bool{"src": true, "r1": true}} |
| 510 | x.f.Proof = proof |
| 511 | if _, err := x.f.movesFilesAllowed(ctx, servingObserved("src"), launchInputs("r1").Admission, filesLoc, "r1", m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| 512 | t.Fatalf("movesFilesAllowed: %v", err) |
| 513 | } |
| 514 | if len(proof.calls) != 0 { |
| 515 | t.Fatalf("the proof was asked %v", proof.calls) |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // The preflight reads by exact version through the data bucket's versioned |
| 520 | // reader; a holder with no location, or a chain with no manifest, has |
| 521 | // nothing to check. |
| 522 | func TestSourceFilesRefusalNeedsAManifestToRefuse(t *testing.T) { |
| 523 | ctx := context.Background() |
| 524 | x := newFilesFixture(t) |
| 525 | w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| 526 | Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| 527 | pre := servingObserved("src", "src:uk:@root:uk") |
| 528 | if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); why != "" || err != nil { |
| 529 | t.Fatalf("no manifest: %q, %v", why, err) |
| 530 | } |
| 531 | noLoc := *pre.Claim.Holder |
| 532 | noLoc.Location = "" |
| 533 | if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, &noLoc); why != "" || err != nil { |
| 534 | t.Fatalf("no location: %q, %v", why, err) |
| 535 | } |
| 536 | w.cfg.Versioned = nil |
| 537 | if _, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); err == nil { |
| 538 | t.Fatal("no versioned reader passed") |
| 539 | } |
| 540 | } |
| 541 | |