| 1 | //go:build unix |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "io/fs" |
| 9 | "maps" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostclaim" |
| 16 | "github.com/carlosframework/platform/internal/hostguard" |
| 17 | "github.com/carlosframework/platform/internal/hostwrite" |
| 18 | "github.com/carlosframework/platform/internal/park" |
| 19 | ) |
| 20 | |
| 21 | // relocationReconciler is the reconciler over workFixture's guard and |
| 22 | // store, with the real fence observer (Guard.AdmissionFenced) and a |
| 23 | // recording hold writer. The claim relocates the host off "box". |
| 24 | func relocationReconciler(t *testing.T) (Reconciler, RecordSelection, *[]string, hostguard.Guard) { |
| 25 | t.Helper() |
| 26 | source, store, obs := workFixture(t) |
| 27 | source.Policy = Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}.Check |
| 28 | // One cold writer session, so the source has a session a stop can fence. |
| 29 | obs, err := hostclaim.Update(context.Background(), store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | h := obs.Claim.Holder |
| 34 | selected := RecordSelection{Host: obs.Host, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}} |
| 35 | holds := &[]string{} |
| 36 | r := Reconciler{Claims: store, Guard: source.Guard, Local: park.Local{Guard: source.Guard, Claims: store, DataDir: source.Guard.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: func() time.Time { return time.Unix(100, 0) }, |
| 37 | CheckRecord: func(context.Context, RecordSelection) error { return nil }, |
| 38 | CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil }, |
| 39 | CheckHeld: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil }, |
| 40 | HoldRow: func(_ context.Context, _ string, op string) error { *holds = append(*holds, op); return nil }} |
| 41 | r.FenceClosed = func(_ context.Context, o hostclaim.Observed) (bool, error) { |
| 42 | captured, err := hostguard.Capture(source.Guard.DataDir, o.Host) |
| 43 | if err != nil { |
| 44 | return false, err |
| 45 | } |
| 46 | return source.Guard.AdmissionFenced(o.Host, o.Claim, captured) |
| 47 | } |
| 48 | return r, selected, holds, source.Guard |
| 49 | } |
| 50 | |
| 51 | func relocate(t *testing.T, r Reconciler, host, phase string) hostclaim.Observed { |
| 52 | t.Helper() |
| 53 | ctx := context.Background() |
| 54 | obs, err := hostclaim.Read(ctx, r.Claims, host) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| 59 | c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: phase, Exec: "box", Participants: []string{"box", "dst"}, |
| 60 | Intent: map[string]string{"region": "ie", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}} |
| 61 | return nil |
| 62 | }) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | return obs |
| 67 | } |
| 68 | |
| 69 | // The source in releasing takes the activation path (ready) until root |
| 70 | // begins its stop; then the hold-only path, under the flock, with the |
| 71 | // observer read inside the existing locked operation: a bounded context |
| 72 | // finishes both, so neither reacquires the host lock. |
| 73 | // |
| 74 | // Claim Plan 11's policy gate admits no "reconcile" action under a |
| 75 | // relocating claim (R1: only op.exec's probe), so the activation path is |
| 76 | // refused by the first check, before the guard is activated or the |
| 77 | // inventory written, dry run or not; the hold-only path, which takes no |
| 78 | // policy, still keeps the fenced source's row held. |
| 79 | func TestReconcileRelocationSourceReadyThenHeldWithTheRealFenceObserver(t *testing.T) { |
| 80 | r, selected, holds, guard := relocationReconciler(t) |
| 81 | obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| 82 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 83 | defer cancel() |
| 84 | writes := 0 |
| 85 | before := snapshotDir(t, guard.DataDir) |
| 86 | for _, dry := range []bool{true, false} { |
| 87 | sel := selected |
| 88 | sel.DryRun = dry |
| 89 | if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| 90 | t.Fatalf("the source before its fence closed (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err) |
| 91 | } |
| 92 | } |
| 93 | if writes != 0 || len(*holds) != 0 { |
| 94 | t.Fatalf("writes %d holds %v", writes, *holds) |
| 95 | } |
| 96 | if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) { |
| 97 | t.Fatal("a refused relocating reconcile changed the guard directory") |
| 98 | } |
| 99 | if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag { |
| 100 | t.Fatalf("a refused relocating reconcile wrote the claim: %v", err) |
| 101 | } |
| 102 | // Root begins the source's stop: the durable admission fence closes. |
| 103 | captured, err := hostguard.Capture(guard.DataDir, selected.Host) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | obs, err = hostclaim.Read(ctx, r.Claims, selected.Host) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session, |
| 112 | Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"} |
| 113 | if _, err := guard.BeginFence(ctx, target); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil { |
| 117 | t.Fatalf("the fenced source's hold: %v", err) |
| 118 | } |
| 119 | if writes != 0 || len(*holds) != 1 || (*holds)[0] != "rel-1" { |
| 120 | t.Fatalf("after the fence: writes %d holds %v (the guarded path must not run)", writes, *holds) |
| 121 | } |
| 122 | // A box with no role mints nothing; an observer error keeps rows and |
| 123 | // mints none. |
| 124 | r.Box = "zzz" |
| 125 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 0 { |
| 126 | t.Fatalf("no role: %v writes %d", err, writes) |
| 127 | } |
| 128 | r.Box = "box" |
| 129 | broken := errors.New("record unreadable") |
| 130 | r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) { return false, broken } |
| 131 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, broken) || writes != 0 { |
| 132 | t.Fatalf("an observer error: %v writes %d", err, writes) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // With no observer (region HA off) the reconciler is today's: the serving |
| 137 | // executor is ready; an executor of any other operation is refused by the |
| 138 | // first check, before the guard is activated or inventory recorded, dry |
| 139 | // run or not (round 5 finding 3). |
| 140 | func TestReconcileWithoutAFenceObserverIsTodays(t *testing.T) { |
| 141 | r, selected, _, guard := relocationReconciler(t) |
| 142 | r.FenceClosed, r.HoldRow = nil, nil |
| 143 | ctx := context.Background() |
| 144 | writes := 0 |
| 145 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil || writes != 1 { |
| 146 | t.Fatalf("serving: %v writes %d", err, writes) |
| 147 | } |
| 148 | moving := func() hostclaim.Observed { |
| 149 | obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| 154 | c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "copying", Exec: "box", Participants: []string{"box", "dst"}, Intent: map[string]string{"admission_id": "adm"}} |
| 155 | return nil |
| 156 | }) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | return obs |
| 161 | } |
| 162 | for _, phase := range []string{hostclaim.PhaseStarting, hostclaim.PhaseReleasing, "moving"} { |
| 163 | var obs hostclaim.Observed |
| 164 | if phase == "moving" { |
| 165 | obs = moving() // a moves executor: main's path with HA on too |
| 166 | } else { |
| 167 | obs = relocate(t, r, selected.Host, phase) // ready with HA on (RowReady) |
| 168 | } |
| 169 | before := snapshotDir(t, guard.DataDir) |
| 170 | for _, dry := range []bool{true, false} { |
| 171 | sel := selected |
| 172 | sel.DryRun = dry |
| 173 | if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 1 { |
| 174 | t.Fatalf("%s (dry run %v) with HA off: %v writes %d", phase, dry, err, writes) |
| 175 | } |
| 176 | } |
| 177 | after, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if after.ETag != obs.ETag { |
| 182 | t.Fatalf("%s: the claim (inventory) was written with HA off", phase) |
| 183 | } |
| 184 | if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) { |
| 185 | t.Fatalf("%s: the guard's local state changed with HA off", phase) |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // With the observer set (region HA on) a moves claim still takes main's |
| 191 | // path: Serving refuses it first. The target mints and holds nothing |
| 192 | // here (moves owns its holds, in provision's pass), and the executor is |
| 193 | // refused before the guard is activated or inventory recorded, dry run |
| 194 | // or not. Only relocating claims take the row decision. |
| 195 | func TestReconcileWithAFenceObserverLeavesMovesToMain(t *testing.T) { |
| 196 | for _, box := range []string{"dst", "box"} { |
| 197 | t.Run(box, func(t *testing.T) { |
| 198 | r, selected, holds, guard := relocationReconciler(t) |
| 199 | ctx := context.Background() |
| 200 | observed := 0 |
| 201 | fence := r.FenceClosed |
| 202 | r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) { |
| 203 | observed++ |
| 204 | return fence(ctx, o) |
| 205 | } |
| 206 | obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| 211 | c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "copying", Exec: "box", Participants: []string{"box", "dst"}, Intent: map[string]string{"admission_id": "adm"}} |
| 212 | return nil |
| 213 | }) |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | r.Box = box |
| 218 | before := snapshotDir(t, guard.DataDir) |
| 219 | writes := 0 |
| 220 | for _, dry := range []bool{true, false} { |
| 221 | sel := selected |
| 222 | sel.DryRun = dry |
| 223 | if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| 224 | t.Fatalf("%s (dry run %v) under a move with HA on: %v", box, dry, err) |
| 225 | } |
| 226 | } |
| 227 | if writes != 0 || len(*holds) != 0 || observed != 0 { |
| 228 | t.Fatalf("%s under a move with HA on: writes %d holds %v observer calls %d", box, writes, *holds, observed) |
| 229 | } |
| 230 | after, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| 231 | if err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | if after.ETag != obs.ETag { |
| 235 | t.Fatalf("%s: the claim (inventory) was written under a move", box) |
| 236 | } |
| 237 | if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) { |
| 238 | t.Fatalf("%s: the guard's local state changed under a move", box) |
| 239 | } |
| 240 | }) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // Install asks the fence observer once for its own decision, and its |
| 245 | // check and its path both act on that answer: the hold-only path acts on |
| 246 | // the answer the decision was made from. The source in releasing is the |
| 247 | // one row case that depends on the observer. Before the final review's |
| 248 | // fixes it was asked three times here (check, Install, holdOnly); now |
| 249 | // Install's one answer serves its check too, so it is asked twice |
| 250 | // (Install, holdOnly under the flock). A box whose case does not depend on |
| 251 | // it is never asked (TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow). |
| 252 | func TestReconcileInstallAsksTheFenceObserverOnce(t *testing.T) { |
| 253 | r, selected, holds, _ := relocationReconciler(t) |
| 254 | relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| 255 | ctx := context.Background() |
| 256 | // Install's answer is closed, and every later one open: acting on one |
| 257 | // answer, Install takes the hold path, whose decision under the flock |
| 258 | // then differs, so nothing is written and nothing held. |
| 259 | answers := []bool{true} |
| 260 | calls := 0 |
| 261 | r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) { |
| 262 | calls++ |
| 263 | if calls <= len(answers) { |
| 264 | return answers[calls-1], nil |
| 265 | } |
| 266 | return false, nil |
| 267 | } |
| 268 | writes := 0 |
| 269 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| 270 | t.Fatalf("a changed answer under the flock: %v", err) |
| 271 | } |
| 272 | if calls != 2 || writes != 0 || len(*holds) != 0 { |
| 273 | t.Fatalf("observer calls %d (Install, holdOnly) writes %d holds %v", calls, writes, *holds) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // Final review: the hold-only write is not execution, so the region's |
| 278 | // quiet window does not gate it. The activation path still waits for it. |
| 279 | func TestReconcileQuietWindowGatesExecutionNotTheHold(t *testing.T) { |
| 280 | r, selected, holds, guard := relocationReconciler(t) |
| 281 | obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| 282 | ctx := context.Background() |
| 283 | obs, err := hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| 284 | c.QuietAfter = map[string]time.Time{"ie": r.Now().Add(time.Minute)} |
| 285 | return nil |
| 286 | }) |
| 287 | if err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | writes := 0 |
| 291 | // The source before its fence closes would take the activation path, |
| 292 | // which a relocating claim now refuses before the quiet window is |
| 293 | // reached (main's policy admits only the probe; checkOn refuses first). |
| 294 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 0 || len(*holds) != 0 { |
| 295 | t.Fatalf("activation in the quiet window: %v writes %d holds %v", err, writes, *holds) |
| 296 | } |
| 297 | captured, err := hostguard.Capture(guard.DataDir, selected.Host) |
| 298 | if err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session, |
| 302 | Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"} |
| 303 | if _, err := guard.BeginFence(ctx, target); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | // The fenced source's row is held at once, inside the quiet window. |
| 307 | if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil { |
| 308 | t.Fatalf("the fenced source's hold in the quiet window: %v", err) |
| 309 | } |
| 310 | if writes != 0 || len(*holds) != 1 || (*holds)[0] != "rel-1" { |
| 311 | t.Fatalf("writes %d holds %v", writes, *holds) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // targetReconciler is relocationReconciler moved onto the target "dst": |
| 316 | // its own guard directory, with no local record for the host, and the |
| 317 | // real fence observer over it, which errors on a box with no record. |
| 318 | func targetReconciler(t *testing.T, r Reconciler) (Reconciler, hostguard.Guard, *int) { |
| 319 | t.Helper() |
| 320 | dir := t.TempDir() |
| 321 | locks := filepath.Join(dir, "locks") |
| 322 | for _, p := range []string{locks, filepath.Join(dir, "hostguard")} { |
| 323 | if err := os.Mkdir(p, 0770); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | } |
| 327 | guard := hostguard.Guard{DataDir: dir, LockDir: locks, Claims: hostguard.StoreReader{Store: r.Claims}} |
| 328 | target := WorkSource{Guard: guard, Box: "dst", Region: "ie", Now: r.Now, |
| 329 | Policy: Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}.Check, |
| 330 | Local: func(context.Context, *hostclaim.Claim, Selection) error { return nil }} |
| 331 | r.Box, r.Guard, r.Writes.Selector = "dst", guard, target |
| 332 | r.Local = park.Local{Guard: guard, Claims: r.Claims, DataDir: dir, Box: "dst"} |
| 333 | calls := new(int) |
| 334 | r.FenceClosed = func(_ context.Context, o hostclaim.Observed) (bool, error) { |
| 335 | *calls++ |
| 336 | captured, err := hostguard.Capture(dir, o.Host) |
| 337 | if err != nil { |
| 338 | return false, err |
| 339 | } |
| 340 | return guard.AdmissionFenced(o.Host, o.Claim, captured) |
| 341 | } |
| 342 | return r, guard, calls |
| 343 | } |
| 344 | |
| 345 | // Final review Important 2: with region HA on, a relocation target that |
| 346 | // has no local guard record yet decides its row (held, as op.exec and a |
| 347 | // participant) without the fence observer, which errors without a record |
| 348 | // and is never asked. Claim Plan 11's policy gate admits no "reconcile" |
| 349 | // action under a relocating claim (R1), so the activation path is refused |
| 350 | // by the first check, dry run or not: nothing is activated or written, |
| 351 | // and no row is minted (stage B settles how one is). |
| 352 | func TestReconcileRelocationTargetWithNoLocalRecordIsRefusedBeforeActivation(t *testing.T) { |
| 353 | r, selected, holds, _ := relocationReconciler(t) |
| 354 | r, guard, calls := targetReconciler(t, r) |
| 355 | ctx := context.Background() |
| 356 | obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| 357 | if err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| 361 | c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"box", "dst"}, |
| 362 | Intent: map[string]string{"region": "ie", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}} |
| 363 | return nil |
| 364 | }) |
| 365 | if err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | // The production observer cannot answer here: no record. |
| 369 | if _, err := r.FenceClosed(ctx, obs); err == nil { |
| 370 | t.Fatal("the observer answered for a box with no local record") |
| 371 | } |
| 372 | *calls = 0 |
| 373 | writes := 0 |
| 374 | before := snapshotDir(t, guard.DataDir) |
| 375 | for _, dry := range []bool{true, false} { |
| 376 | sel := selected |
| 377 | sel.DryRun = dry |
| 378 | if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| 379 | t.Fatalf("the target's first install (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err) |
| 380 | } |
| 381 | } |
| 382 | if writes != 0 || len(*holds) != 0 || *calls != 0 { |
| 383 | t.Fatalf("writes %d holds %v observer calls %d", writes, *holds, *calls) |
| 384 | } |
| 385 | if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) { |
| 386 | t.Fatal("a refused relocating reconcile changed the target's guard directory") |
| 387 | } |
| 388 | if _, err := hostguard.Capture(guard.DataDir, selected.Host); err == nil { |
| 389 | t.Fatal("the target's installation was activated") |
| 390 | } |
| 391 | if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag { |
| 392 | t.Fatalf("a refused relocating reconcile wrote the claim: %v", err) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | // snapshotDir is every regular file under dir, by relative path. |
| 397 | func snapshotDir(t *testing.T, dir string) map[string]string { |
| 398 | t.Helper() |
| 399 | out := map[string]string{} |
| 400 | err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { |
| 401 | if err != nil || !d.Type().IsRegular() { |
| 402 | return err |
| 403 | } |
| 404 | raw, err := os.ReadFile(path) |
| 405 | if err != nil { |
| 406 | return err |
| 407 | } |
| 408 | out[path] = string(raw) |
| 409 | return nil |
| 410 | }) |
| 411 | if err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | return out |
| 415 | } |
| 416 | |