| 1 | //go:build linux |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "golang.org/x/sys/unix" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/provision" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | ) |
| 21 | |
| 22 | // intendedFixture is a prior installation whose PrepareState persisted the |
| 23 | // state-dir intent and then failed or crashed before its final inventory write. |
| 24 | type intendedFixture struct { |
| 25 | handover ColdHandover |
| 26 | prior hostguard.FenceTarget |
| 27 | next ColdSuccessor |
| 28 | inventory *StopInventoryDB |
| 29 | reg *registry.Registry |
| 30 | data string |
| 31 | stateRel string |
| 32 | state string |
| 33 | id string |
| 34 | } |
| 35 | |
| 36 | func newIntendedFixture(t *testing.T, edit func(*StopInventoryEntry)) intendedFixture { |
| 37 | t.Helper() |
| 38 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 39 | source, store, initial := workFixture(t) |
| 40 | ctx := t.Context() |
| 41 | admit := func(old hostclaim.Observed, generation string) hostclaim.Observed { |
| 42 | next, err := hostclaim.Update(ctx, store, old, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: old.Claim.Op.ID, ObservedPhase: old.Claim.Op.Phase, ObservedSession: old.Claim.Holder.Session, ObservedGeneration: old.Claim.Holder.Generation, Box: "box", Region: "ie", Mode: "cold", NewGeneration: generation})) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return next |
| 47 | } |
| 48 | old := admit(initial, "first") |
| 49 | if err := source.Guard.Activate(ctx, old.Host, "box", nil); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | prior := hostguard.FenceTarget{FenceID: "fence", Region: "ie", Host: old.Host, Incarnation: old.Claim.Holder.Incarnation, Stamp: old.Claim.Holder.Stamp, Session: old.Claim.Holder.Session, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: old.ETag, DirectoryRevision: "1", Op: old.Claim.Op.ID, Phase: old.Claim.Op.Phase} |
| 53 | if _, err := source.Guard.BeginFence(ctx, prior); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if err := source.Guard.CompleteFence(ctx, prior, []byte("verified-root-receipt"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | base := t.TempDir() |
| 60 | reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | t.Cleanup(func() { reg.Close() }) |
| 65 | inventory, err := OpenStopInventoryDB(filepath.Join(base, "inventory.db")) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | t.Cleanup(func() { inventory.Close() }) |
| 70 | launches, err := OpenLaunchJournal(filepath.Join(base, "launches.db")) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | t.Cleanup(func() { launches.Close() }) |
| 75 | owner := registry.ClaimOwnership{ClaimIncarnation: prior.Incarnation, ClaimStamp: prior.Stamp, ClaimOwner: "old-owner", ClaimRowID: "old-route"} |
| 76 | data := source.Guard.DataDir |
| 77 | dbPath := provision.NestedDBPath(data, old.Host) |
| 78 | statePath := filepath.Dir(dbPath) |
| 79 | if err = os.MkdirAll(filepath.Dir(statePath), 0700); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | stateRel, err := filepath.Rel(data, statePath) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | if err = reg.Upsert(registry.Route{Host: old.Host, Kind: "instance", Socket: provision.NestedSocket(old.Host), DBPath: dbPath, ClaimOwnership: owner}); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if err = reg.UpsertTimer(registry.Timer{Host: old.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 123, Attempts: 2}); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | // The intent PrepareState writes before Mkdirat: typed, no identity. |
| 93 | state := StopInventoryEntry{Kind: "path", ArtifactID: "state-dir", ManagedRoot: "data", RelativePath: stateRel, Ownership: owner.ClaimOwner, Materialization: "intended", Type: unix.S_IFDIR} |
| 94 | if edit != nil { |
| 95 | edit(&state) |
| 96 | } |
| 97 | in := StopInventory{Complete: true, Host: old.Host, Incarnation: prior.Incarnation, InstallationStamp: prior.Stamp, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{ |
| 98 | {Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}, |
| 99 | state, |
| 100 | }} |
| 101 | if err = inventory.WriteStopInventory(ctx, in); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | successor := admit(old, "second") |
| 105 | next := ColdSuccessor{Host: old.Host, Incarnation: prior.Incarnation, Stamp: successor.Claim.Holder.Stamp, Session: successor.Claim.Holder.Session, Generation: successor.Claim.Holder.Generation, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", ETag: successor.ETag, Op: successor.Claim.Op.ID, Phase: successor.Claim.Op.Phase} |
| 106 | handover := ColdHandover{Guard: source.Guard, Claims: store, Registry: reg, Inventory: inventory, Launches: launches, Roots: map[string]string{"data": data}, JournalDir: filepath.Join(base, "handover"), VerifyReceipt: func(context.Context, hostguard.FenceReceipt) error { return nil }} |
| 107 | return intendedFixture{handover: handover, prior: prior, next: next, inventory: inventory, reg: reg, data: data, stateRel: stateRel, state: statePath, id: "handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box)} |
| 108 | } |
| 109 | |
| 110 | func (f intendedFixture) parked(retirement, name string) string { |
| 111 | return filepath.Join(f.data, ".parked", f.prior.Incarnation, f.prior.Host, f.id+retirement, "data", name) |
| 112 | } |
| 113 | |
| 114 | func (f intendedFixture) intendedJournal() string { |
| 115 | return filepath.Join(f.handover.JournalDir, "retired", f.id+"-intended.json") |
| 116 | } |
| 117 | |
| 118 | func (f intendedFixture) obstructMainRetirement(t *testing.T) string { |
| 119 | t.Helper() |
| 120 | blocked := filepath.Join(f.handover.JournalDir, "retired", f.id+"-old.json") |
| 121 | if err := os.MkdirAll(blocked, 0700); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | return blocked |
| 125 | } |
| 126 | |
| 127 | func (f intendedFixture) requirePriorRoute(t *testing.T) { |
| 128 | t.Helper() |
| 129 | if route, err := f.reg.Get(f.prior.Host); err != nil || route == nil || route.ClaimStamp != f.prior.Stamp || route.ClaimOwner != "old-owner" { |
| 130 | t.Fatalf("refusal changed prior route: %+v %v", route, err) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func (f intendedFixture) requireUnmoved(t *testing.T) { |
| 135 | t.Helper() |
| 136 | f.requirePriorRoute(t) |
| 137 | if _, err := os.Lstat(f.intendedJournal()); !errors.Is(err, os.ErrNotExist) { |
| 138 | t.Fatalf("refusal recorded an intended capture: %v", err) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func (f intendedFixture) requireComplete(t *testing.T) { |
| 143 | t.Helper() |
| 144 | if _, err := os.Lstat(f.state); !errors.Is(err, os.ErrNotExist) { |
| 145 | t.Fatalf("intended state dir remains: %v", err) |
| 146 | } |
| 147 | if route, err := f.reg.Get(f.prior.Host); err != nil || route == nil || route.ClaimStamp != f.next.Stamp { |
| 148 | t.Fatalf("route not handed to successor: %+v %v", route, err) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // Mkdirat failed (ENOSPC/EACCES) after the intent was durable: nothing exists |
| 153 | // to move, so the next admission's handover must complete. |
| 154 | func TestColdHandoverResumesAbsentIntendedStateDir(t *testing.T) { |
| 155 | for name, typ := range map[string]uint32{"typed": unix.S_IFDIR, "untyped": 0} { |
| 156 | t.Run(name, func(t *testing.T) { |
| 157 | f := newIntendedFixture(t, func(e *StopInventoryEntry) { e.Type = typ }) |
| 158 | ctx := t.Context() |
| 159 | if err := f.handover.Run(ctx, f.prior, f.next); err != nil { |
| 160 | t.Fatalf("absent intended path stranded handover: %v", err) |
| 161 | } |
| 162 | f.requireComplete(t) |
| 163 | if _, err := os.Lstat(f.intendedJournal()); !errors.Is(err, os.ErrNotExist) { |
| 164 | t.Fatalf("absent intent captured an artifact: %v", err) |
| 165 | } |
| 166 | if _, err := os.Lstat(f.parked("-intended", f.stateRel)); !errors.Is(err, os.ErrNotExist) { |
| 167 | t.Fatalf("absent intent parked something: %v", err) |
| 168 | } |
| 169 | if err := f.handover.Run(ctx, f.prior, f.next); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if err := f.handover.Recover(ctx); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | f.requireComplete(t) |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // Crash after Mkdirat and before the materialized write: the empty directory |
| 181 | // is parked by its captured inode, never unlinked, and replay never recaptures. |
| 182 | func TestColdHandoverParksTypedEmptyIntendedStateDirAndReplays(t *testing.T) { |
| 183 | f := newIntendedFixture(t, nil) |
| 184 | ctx := t.Context() |
| 185 | if err := os.Mkdir(f.state, 0700); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | created, err := os.Lstat(f.state) |
| 189 | if err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | // Fail the main retirement after the intended park, as an interrupted run. |
| 193 | blocked := f.obstructMainRetirement(t) |
| 194 | if err = f.handover.Run(ctx, f.prior, f.next); err == nil { |
| 195 | t.Fatalf("obstructed main retirement: %v", err) |
| 196 | } |
| 197 | f.requirePriorRoute(t) |
| 198 | parked := f.parked("-intended", f.stateRel) |
| 199 | got, err := os.Lstat(parked) |
| 200 | if err != nil || !os.SameFile(created, got) { |
| 201 | t.Fatalf("intended dir not parked by inode: %v", err) |
| 202 | } |
| 203 | if _, err = os.Lstat(f.state); !errors.Is(err, os.ErrNotExist) { |
| 204 | t.Fatalf("intended dir still at source: %v", err) |
| 205 | } |
| 206 | body, err := os.ReadFile(f.intendedJournal()) |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | var journal RetirementJournal |
| 211 | if err = decodeStrict(body, &journal); err != nil || !journal.Complete || len(journal.Inventory.Artifacts) != 1 || journal.Target == nil || *journal.Target != f.prior { |
| 212 | t.Fatalf("intended capture not journalled under the exact prior: %+v %v", journal, err) |
| 213 | } |
| 214 | |
| 215 | // A same-named directory appearing after the durable decision is unknown. |
| 216 | if err = os.Mkdir(f.state, 0700); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if err = os.Remove(blocked); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | if err = f.handover.Run(ctx, f.prior, f.next); !errors.Is(err, ErrChanged) { |
| 223 | t.Fatalf("late same-named directory accepted: %v", err) |
| 224 | } |
| 225 | if _, err = os.Lstat(f.state); err != nil { |
| 226 | t.Fatalf("late directory touched: %v", err) |
| 227 | } |
| 228 | if err = os.Remove(f.state); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | |
| 232 | if err = f.handover.Recover(ctx); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | f.requireComplete(t) |
| 236 | if got, err = os.Lstat(parked); err != nil || !os.SameFile(created, got) { |
| 237 | t.Fatalf("parked intended dir not preserved: %v", err) |
| 238 | } |
| 239 | if again, err := os.ReadFile(f.intendedJournal()); err != nil || !bytes.Equal(again, body) { |
| 240 | t.Fatalf("replay rewrote intended capture: %v", err) |
| 241 | } |
| 242 | mainBody, err := os.ReadFile(filepath.Join(f.handover.JournalDir, "retired", f.id+"-old.json")) |
| 243 | if err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | var rows RetirementJournal |
| 247 | if err = decodeStrict(mainBody, &rows); err != nil || !rows.Complete || len(rows.Archives) == 0 { |
| 248 | t.Fatalf("row archives missing: %v", err) |
| 249 | } |
| 250 | |
| 251 | for range 2 { |
| 252 | if err = f.handover.Run(ctx, f.prior, f.next); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if err = f.handover.Recover(ctx); err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | } |
| 259 | if got, err = os.Lstat(parked); err != nil || !os.SameFile(created, got) { |
| 260 | t.Fatalf("replay disturbed parked intended dir: %v", err) |
| 261 | } |
| 262 | if again, err := os.ReadFile(filepath.Join(f.handover.JournalDir, "retired", f.id+"-old.json")); err != nil || !bytes.Equal(again, mainBody) { |
| 263 | t.Fatalf("replay rewrote row archives: %v", err) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // Ownership of a present intended name is only provable for an empty |
| 268 | // directory of the recorded type with no successor claim on it. Every other |
| 269 | // shape refuses before any artifact moves, and keeps refusing. |
| 270 | func TestColdHandoverRefusesUnprovableIntendedStateDir(t *testing.T) { |
| 271 | cases := []struct { |
| 272 | name string |
| 273 | edit func(*StopInventoryEntry) |
| 274 | setup func(*testing.T, intendedFixture) |
| 275 | want error |
| 276 | check func(*testing.T, intendedFixture) |
| 277 | }{ |
| 278 | { |
| 279 | name: "nonempty", |
| 280 | setup: func(t *testing.T, f intendedFixture) { |
| 281 | if err := os.Mkdir(f.state, 0700); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if err := os.WriteFile(filepath.Join(f.state, "data.db"), []byte("unknown"), 0600); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | }, |
| 288 | want: ErrChanged, |
| 289 | check: func(t *testing.T, f intendedFixture) { |
| 290 | if body, err := os.ReadFile(filepath.Join(f.state, "data.db")); err != nil || string(body) != "unknown" { |
| 291 | t.Fatalf("nonempty intended dir touched: %v", err) |
| 292 | } |
| 293 | }, |
| 294 | }, |
| 295 | { |
| 296 | name: "symlink", |
| 297 | setup: func(t *testing.T, f intendedFixture) { |
| 298 | if err := os.Mkdir(filepath.Join(f.data, "elsewhere"), 0700); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | if err := os.Symlink("elsewhere", f.state); err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | }, |
| 305 | want: ErrChanged, |
| 306 | check: func(t *testing.T, f intendedFixture) { |
| 307 | if info, err := os.Lstat(f.state); err != nil || info.Mode()&os.ModeSymlink == 0 { |
| 308 | t.Fatalf("symlink touched: %v", err) |
| 309 | } |
| 310 | if _, err := os.Lstat(filepath.Join(f.data, "elsewhere")); err != nil { |
| 311 | t.Fatalf("symlink target touched: %v", err) |
| 312 | } |
| 313 | }, |
| 314 | }, |
| 315 | { |
| 316 | name: "regular file", |
| 317 | setup: func(t *testing.T, f intendedFixture) { |
| 318 | if err := os.WriteFile(f.state, []byte("unknown"), 0600); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | }, |
| 322 | want: ErrChanged, |
| 323 | check: func(t *testing.T, f intendedFixture) { |
| 324 | if body, err := os.ReadFile(f.state); err != nil || string(body) != "unknown" { |
| 325 | t.Fatalf("regular file touched: %v", err) |
| 326 | } |
| 327 | }, |
| 328 | }, |
| 329 | { |
| 330 | name: "untyped intent", |
| 331 | edit: func(e *StopInventoryEntry) { e.Type = 0 }, |
| 332 | want: ErrChanged, |
| 333 | }, |
| 334 | { |
| 335 | name: "wrong recorded type", |
| 336 | edit: func(e *StopInventoryEntry) { e.Type = unix.S_IFREG }, |
| 337 | want: ErrChanged, |
| 338 | }, |
| 339 | { |
| 340 | name: "intent with identity", |
| 341 | edit: func(e *StopInventoryEntry) { e.Device, e.Inode = 1, 1 }, |
| 342 | want: ErrTypedInventory, |
| 343 | }, |
| 344 | { |
| 345 | name: "successor state", |
| 346 | setup: func(t *testing.T, f intendedFixture) { |
| 347 | if err := os.Mkdir(f.state, 0700); err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | successor := StopInventory{Complete: true, Host: f.next.Host, Incarnation: f.next.Incarnation, InstallationStamp: f.next.Stamp, Box: f.next.Box, Enrollment: f.next.Enrollment, DirectoryRevision: f.next.DirectoryRevision, Entries: []StopInventoryEntry{{Kind: "path", ArtifactID: "successor-state", ManagedRoot: "data", RelativePath: f.stateRel, Ownership: "successor-owner", Materialization: "intended", Type: unix.S_IFDIR}}} |
| 351 | if err := f.inventory.WriteStopInventory(t.Context(), successor); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | }, |
| 355 | want: ErrChanged, |
| 356 | }, |
| 357 | } |
| 358 | for _, tc := range cases { |
| 359 | t.Run(tc.name, func(t *testing.T) { |
| 360 | f := newIntendedFixture(t, tc.edit) |
| 361 | setup := tc.setup |
| 362 | if setup == nil { |
| 363 | setup = func(t *testing.T, f intendedFixture) { |
| 364 | if err := os.Mkdir(f.state, 0700); err != nil { |
| 365 | t.Fatal(err) |
| 366 | } |
| 367 | } |
| 368 | } |
| 369 | setup(t, f) |
| 370 | before, err := os.Lstat(f.state) |
| 371 | if err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | for range 2 { |
| 375 | if err = f.handover.Run(t.Context(), f.prior, f.next); !errors.Is(err, tc.want) { |
| 376 | t.Fatalf("got %v, want %v", err, tc.want) |
| 377 | } |
| 378 | f.requireUnmoved(t) |
| 379 | if after, err := os.Lstat(f.state); err != nil || !os.SameFile(before, after) { |
| 380 | t.Fatalf("refused intended name changed: %v", err) |
| 381 | } |
| 382 | if _, err = os.Lstat(f.parked("-intended", f.stateRel)); !errors.Is(err, os.ErrNotExist) { |
| 383 | t.Fatalf("refused intended name parked: %v", err) |
| 384 | } |
| 385 | } |
| 386 | if tc.check != nil { |
| 387 | tc.check(t, f) |
| 388 | } |
| 389 | }) |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | func TestColdHandoverAbsentIntentionDecisionSurvivesInterruptedRetirement(t *testing.T) { |
| 394 | f := newIntendedFixture(t, nil) |
| 395 | ctx := t.Context() |
| 396 | blocked := f.obstructMainRetirement(t) |
| 397 | if err := f.handover.Run(ctx, f.prior, f.next); err == nil { |
| 398 | t.Fatalf("first retirement: %v", err) |
| 399 | } |
| 400 | f.requirePriorRoute(t) |
| 401 | if err := os.Remove(blocked); err != nil { |
| 402 | t.Fatal(err) |
| 403 | } |
| 404 | if err := os.Mkdir(f.state, 0700); err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | unknown, err := os.Lstat(f.state) |
| 408 | if err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | if err := f.handover.Recover(ctx); !errors.Is(err, ErrChanged) { |
| 412 | t.Fatalf("accepted newly appearing intended path: %v", err) |
| 413 | } |
| 414 | got, err := os.Lstat(f.state) |
| 415 | if err != nil || !os.SameFile(unknown, got) { |
| 416 | t.Fatalf("unknown path moved: %v", err) |
| 417 | } |
| 418 | f.requireUnmoved(t) |
| 419 | if err := os.Remove(f.state); err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | if err := f.handover.Recover(ctx); err != nil { |
| 423 | t.Fatal(err) |
| 424 | } |
| 425 | f.requireComplete(t) |
| 426 | } |
| 427 | |
| 428 | func TestColdHandoverEmptyDirectoryUsesCapturedIdentity(t *testing.T) { |
| 429 | root := t.TempDir() |
| 430 | p := TypedParker{Roots: map[string]string{"data": root}, ParkedRoot: root, Host: "host", Incarnation: "inc", Retirement: "retire"} |
| 431 | path := filepath.Join(root, "state") |
| 432 | if err := os.Mkdir(path, 0700); err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | a, err := p.CaptureArtifact("data", "state", "owner", ArtifactPath) |
| 436 | if err != nil { |
| 437 | t.Fatal(err) |
| 438 | } |
| 439 | if err := p.VerifyEmptyDirectory(a); err != nil { |
| 440 | t.Fatal(err) |
| 441 | } |
| 442 | if err := os.Rename(path, filepath.Join(root, "captured")); err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | if err := os.Mkdir(path, 0700); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if err := p.VerifyEmptyDirectory(a); !errors.Is(err, ErrChanged) { |
| 449 | t.Fatalf("substitute empty directory accepted: %v", err) |
| 450 | } |
| 451 | if err := os.Remove(path); err != nil { |
| 452 | t.Fatal(err) |
| 453 | } |
| 454 | if err := os.Symlink("captured", path); err != nil { |
| 455 | t.Fatal(err) |
| 456 | } |
| 457 | if err := p.VerifyEmptyDirectory(a); err == nil { |
| 458 | t.Fatal("followed symlink to captured directory") |
| 459 | } |
| 460 | if err := os.Remove(path); err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | if err := os.Rename(filepath.Join(root, "captured"), path); err != nil { |
| 464 | t.Fatal(err) |
| 465 | } |
| 466 | if err := os.WriteFile(filepath.Join(path, "unknown"), nil, 0600); err != nil { |
| 467 | t.Fatal(err) |
| 468 | } |
| 469 | if err := p.VerifyEmptyDirectory(a); !errors.Is(err, ErrChanged) { |
| 470 | t.Fatalf("nonempty directory accepted: %v", err) |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | func TestClaimReviewHandoverRefusesCompletedSuccessorProducer(t *testing.T) { |
| 475 | f := newIntendedFixture(t, nil) |
| 476 | target := f.prior |
| 477 | target.Stamp = f.next.Stamp |
| 478 | target.Session = f.next.Session |
| 479 | j := f.handover.Launches |
| 480 | if err := j.Prelaunch(t.Context(), LaunchInventory{ControllerPID: 123, ProcessStart: "456", Target: target, Groups: []LaunchGroup{{RelativePath: "host", Device: 1, Inode: 1}}, NoState: true}); err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | if err := j.RecordProducer(t.Context(), target, "delivery"); err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | if _, err := j.CompleteProducer(t.Context(), target, "delivery", ProducerCompletion{}); err != nil { |
| 487 | t.Fatal(err) |
| 488 | } |
| 489 | if err := f.handover.Run(t.Context(), f.prior, f.next); !errors.Is(err, ErrChanged) { |
| 490 | t.Fatalf("handover allowed completed successor: %v", err) |
| 491 | } |
| 492 | f.requireUnmoved(t) |
| 493 | } |
| 494 | |
| 495 | func TestPlan9Fix2ColdHandoverRefusesForeignInventory(t *testing.T) { |
| 496 | for _, root := range []string{"opt", "units", "data"} { |
| 497 | t.Run(root, func(t *testing.T) { |
| 498 | f := newIntendedFixture(t, nil) |
| 499 | victim := filepath.Join(f.data, "foreign") |
| 500 | if err := os.WriteFile(victim, []byte("root-only"), 0600); err != nil { |
| 501 | t.Fatal(err) |
| 502 | } |
| 503 | f.handover.Roots[root] = f.data |
| 504 | parker := TypedParker{Roots: f.handover.Roots, ParkedRoot: f.data, Incarnation: f.prior.Incarnation, Host: f.prior.Host, Retirement: "capture"} |
| 505 | a, err := parker.CaptureArtifact(root, "foreign", "old-owner", ArtifactPath) |
| 506 | if err != nil { |
| 507 | t.Fatal(err) |
| 508 | } |
| 509 | in, err := f.inventory.ReadExactStopInventory(t.Context(), f.prior) |
| 510 | if err != nil { |
| 511 | t.Fatal(err) |
| 512 | } |
| 513 | in.Entries = append(in.Entries, StopInventoryEntry{Kind: "path", ManagedRoot: root, RelativePath: a.Name, ArtifactID: "foreign", Ownership: a.Owner, Device: a.Dev, Inode: a.Ino, Type: a.Type, Materialization: "materialized"}) |
| 514 | if err = f.inventory.WriteStopInventory(t.Context(), in); err != nil { |
| 515 | t.Fatal(err) |
| 516 | } |
| 517 | if err = f.handover.Run(t.Context(), f.prior, f.next); !errors.Is(err, ErrTypedInventory) { |
| 518 | t.Fatalf("foreign inventory: %v", err) |
| 519 | } |
| 520 | if raw, err := os.ReadFile(victim); err != nil || string(raw) != "root-only" { |
| 521 | t.Fatalf("foreign file moved: %v", err) |
| 522 | } |
| 523 | }) |
| 524 | } |
| 525 | } |
| 526 | |