claim Plan 9 review round 4 (Astra): strict flat inventory with nested intended-state fixtures, alert-not-fatal root .hostclaims, migration mask recovery on retry, PID-namespace takeover evidence, flag-off account migration as main
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+680
−116
accounts_record_sequence.go+7 −0accounts_record_sequence_test.go+5 −3internal/console/hostreports_process_linux.go+9 −5internal/console/plan9_fix2_legacy_process_linux_test.go+33 −5internal/console/plan9_fix4_flagoff_test.go+20 −0internal/host/claimconstruction.go+5 −6internal/host/claimjournal_migration.go+9 −5internal/host/claimjournal_migration_linux.go+31 −0internal/host/claimjournal_migration_other.go+2 −0internal/host/plan9fix4_claimjournal_migration_linux_test.go+303 −0internal/hostactors/plan9_fix4_flagoff_test.go+30 −0internal/hostbox/intended_recovery_test.go+35 −46internal/hostbox/inventoryscope.go+4 −24internal/hostbox/journalmigration_linux.go+13 −1internal/hostbox/journalmigration_other.go+1 −1internal/hostbox/plan9fix2_inventory_test.go+2 −18internal/hostbox/plan9fix4_inventory_linux_test.go+50 −0internal/hostbox/plan9fix4_journalmigration_linux_test.go+70 −0internal/provision/plan9_b8_census_test.go+2 −2plan9_fix4_migration_test.go+49 −0
CI passed — run details
diff --git a/accounts_record_sequence.go b/accounts_record_sequence.go| index 0b226fc06..48e0ab861 100644 |
| --- a/accounts_record_sequence.go |
| +++ b/accounts_record_sequence.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| + "os" |
| "strings" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -14,6 +15,9 @@ import ( |
| // cannot be transplanted into another key as a new incarnation; that needs |
| // the claim create protocol, not a raw copy of archived authority. |
| func putMigrationObject(ctx context.Context, store release.Store, key string, body []byte) (string, error) { |
| + if !hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) { |
| + return store.Put(ctx, key, body, "") |
| + } |
| if provision.IsRecordKey(key) { |
| if err := migrationRecordBytes(key, body); err != nil { |
| return "", err |
| @@ -32,6 +36,9 @@ func putMigrationObject(ctx context.Context, store release.Store, key string, bo |
| // Preflight precedes every copy and also runs for --dry-run. The write helper |
| // repeats the check because a source can change after preflight. |
| func validateMigrationRecords(ctx context.Context, store release.Store, plan []copyOp) error { |
| + if !hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) { |
| + return nil |
| + } |
| for _, c := range plan { |
| if !provision.IsRecordKey(c.to) { |
| continue |
diff --git a/accounts_record_sequence_test.go b/accounts_record_sequence_test.go| index 9f0b94359..bcb3926da 100644 |
| --- a/accounts_record_sequence_test.go |
| +++ b/accounts_record_sequence_test.go |
| @@ -12,6 +12,7 @@ import ( |
| ) |
| func TestPlan9B8MigrationCannotReplaySequencedRecord(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ctx := context.Background() |
| for _, family := range []string{"instances", "sidecars"} { |
| t.Run(family, func(t *testing.T) { |
| @@ -37,6 +38,7 @@ func TestPlan9B8MigrationCannotReplaySequencedRecord(t *testing.T) { |
| } |
| func TestPlan9B8MigrationPreflightRefusesBeforeAnyCopy(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ctx := context.Background() |
| store := release.NewDirStore(t.TempDir()) |
| source := "apps/bab/app/instances/source.json" |
| @@ -59,10 +61,10 @@ func TestPlan9B8MigrationPreflightRefusesBeforeAnyCopy(t *testing.T) { |
| } |
| } |
| -// M1: flag-off, the migration copies what main copied. Only a present |
| -// record_seq refuses; bodies main tolerated (not JSON objects, duplicate |
| -// keys) pass preflight and are copied byte for byte. |
| +// Claim mode refuses a present record_seq; legacy bodies without one |
| +// still pass preflight and are copied byte for byte. |
| func TestPlan9M1MigrationToleratesBodiesMainCopied(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ctx := context.Background() |
| for name, body := range map[string]string{ |
| "malformed": `{"schema": 1`, |
diff --git a/internal/console/hostreports_process_linux.go b/internal/console/hostreports_process_linux.go| index 98acad714..16a4d0e5d 100644 |
| --- a/internal/console/hostreports_process_linux.go |
| +++ b/internal/console/hostreports_process_linux.go |
| @@ -33,7 +33,8 @@ func hostReportProcessStart(pid int) (string, error) { |
| } |
| return fields[19], nil |
| } |
| -func hostReportPIDNamespace() (string, error) { |
| + |
| +var hostReportPIDNamespace = func() (string, error) { |
| info, err := os.Stat("/proc/self/ns/pid") |
| if err != nil { |
| return "", err |
| @@ -58,9 +59,12 @@ func hostReportProcessID(boot string) string { |
| return boot + ":" + hostReportProcessIdentity() |
| } |
| -// hostReportRootPIDNamespace names the PID namespace of init. Main wrote its |
| -// two-part ProcessID from the host namespace, so a legacy report is only |
| -// provable here when we run in that same namespace. |
| +// Linux reserves PID_NS_INIT_INO (formerly PROC_PID_INIT_INO) for its initial |
| +// PID namespace. A container's own procfs also makes /proc/1 match self, so |
| +// that match alone cannot prove main's legacy boot:pid is local. |
| +// See include/uapi/linux/nsfs.h in the kernel source. |
| +const hostReportInitialPIDNamespace = "4026531836" // 0xEFFFFFFC |
| + |
| var hostReportRootPIDNamespace = func() (string, error) { |
| info, err := os.Stat("/proc/1/ns/pid") |
| if err != nil { |
| @@ -94,7 +98,7 @@ func hostReportProcessGone(previous, next string) bool { |
| return false |
| } |
| root, err := hostReportRootPIDNamespace() |
| - if err != nil || root != namespace || !hostReportBootMatches(old[0]) { |
| + if err != nil || root != hostReportInitialPIDNamespace || root != namespace || !hostReportBootMatches(old[0]) { |
| return false |
| } |
| return hostReportPIDAbsent(old[1]) |
diff --git a/internal/console/plan9_fix2_legacy_process_linux_test.go b/internal/console/plan9_fix2_legacy_process_linux_test.go| index daaf66324..7983ab332 100644 |
| --- a/internal/console/plan9_fix2_legacy_process_linux_test.go |
| +++ b/internal/console/plan9_fix2_legacy_process_linux_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "os" |
| "strings" |
| "testing" |
| + "time" |
| ) |
| func plan9Fix2Boot(t *testing.T) string { |
| @@ -18,6 +19,30 @@ func plan9Fix2Boot(t *testing.T) string { |
| return strings.TrimSpace(string(raw)) |
| } |
| +func TestPlan9Fix4ContainerProcfsDoesNotProveLegacyPIDAbsent(t *testing.T) { |
| + boot := plan9Fix2Boot(t) |
| + const ns = "4026532999" |
| + priorNamespace := hostReportPIDNamespace |
| + t.Cleanup(func() { hostReportPIDNamespace = priorNamespace }) |
| + hostReportPIDNamespace = func() (string, error) { return ns, nil } |
| + // A container with its own procfs sees itself and its init in the same |
| + // namespace, while a still-live host PID is absent from both procfs and |
| + // kill(0). That agreement is not evidence of the initial namespace. |
| + plan9Fix2RootNamespace(t, ns, nil) |
| + prior := hostReportProcReadFile |
| + t.Cleanup(func() { hostReportProcReadFile = prior }) |
| + hostReportProcReadFile = func(path string) ([]byte, error) { |
| + if path == "/proc/999999999/stat" { |
| + return nil, os.ErrNotExist |
| + } |
| + return prior(path) |
| + } |
| + now := time.Now() |
| + if err := hostReportCollision(plan9Report(boot+":999999999", now), plan9Report(hostReportProcessID(boot), now), now); err == nil { |
| + t.Fatal("container procfs allowed takeover of a live host's legacy report") |
| + } |
| +} |
| + |
| func plan9Fix2RootNamespace(t *testing.T, ns string, err error) { |
| t.Helper() |
| prior := hostReportRootPIDNamespace |
| @@ -27,10 +52,10 @@ func plan9Fix2RootNamespace(t *testing.T, ns string, err error) { |
| func TestPlan9Fix2LegacyTwoPartIDProvedGoneInHostNamespace(t *testing.T) { |
| boot := plan9Fix2Boot(t) |
| - ns, err := hostReportPIDNamespace() |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| + const ns = hostReportInitialPIDNamespace |
| + priorNamespace := hostReportPIDNamespace |
| + t.Cleanup(func() { hostReportPIDNamespace = priorNamespace }) |
| + hostReportPIDNamespace = func() (string, error) { return ns, nil } |
| plan9Fix2RootNamespace(t, ns, nil) |
| if !hostReportProcessGone(boot+":999999999", hostReportProcessID(boot)) { |
| t.Fatal("main's absent boot:pid was not accepted during the roll") |
| @@ -53,7 +78,10 @@ func TestPlan9Fix2LegacyTwoPartIDNeverProvedOutsideHostNamespace(t *testing.T) { |
| func TestPlan9Fix2LegacyTwoPartIDKeepsLivePIDAndBootChecks(t *testing.T) { |
| boot := plan9Fix2Boot(t) |
| - ns, _ := hostReportPIDNamespace() |
| + const ns = hostReportInitialPIDNamespace |
| + priorNamespace := hostReportPIDNamespace |
| + t.Cleanup(func() { hostReportPIDNamespace = priorNamespace }) |
| + hostReportPIDNamespace = func() (string, error) { return ns, nil } |
| plan9Fix2RootNamespace(t, ns, nil) |
| next := hostReportProcessID(boot) |
| if hostReportProcessGone(fmt.Sprintf("%s:%d", boot, os.Getpid()), next) { |
diff --git a/internal/console/plan9_fix4_flagoff_test.go b/internal/console/plan9_fix4_flagoff_test.go| new file mode 100644 |
| index 000000000..8768a4400 |
| --- /dev/null |
| +++ b/internal/console/plan9_fix4_flagoff_test.go |
| @@ -0,0 +1,20 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type fix4UnexpectedReportStore struct{ release.Store } |
| + |
| +func (fix4UnexpectedReportStore) Get(context.Context, string) ([]byte, string, error) { |
| + panic("host reporting without a console ID accessed the control plane") |
| +} |
| + |
| +func TestPlan9Fix4FlagOffNoConsoleIDDoesNotReportOrRelease(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + s := &Server{cfg: Config{Releases: fix4UnexpectedReportStore{}, Key: make([]byte, 64)}} |
| + s.StartHostReports(t.Context()) |
| +} |
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go| index da57a91b6..e2598dd43 100644 |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -166,8 +166,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| return nil, hostwrite.ErrUnavailable |
| } |
| local := filepath.Join(r.DataDir, ".hostclaims") |
| - if err = prepareClaimStateDirectory(local); err != nil { |
| - return nil, err |
| + if r.Mode == "agent" { |
| + if err = prepareClaimStateDirectory(local); err != nil { |
| + return nil, err |
| + } |
| } |
| var launches *hostbox.LaunchJournal |
| var inventory *hostbox.StopInventoryDB |
| @@ -178,10 +180,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| // it keeps caller cancellation rather than construction's two minutes. |
| finishStartup() |
| ctx = callerCtx |
| - if err = hostbox.PrepareRootJournalDirectory("/var/lib/carlos-claims"); err != nil { |
| - return nil, err |
| - } |
| - launches, err = hostbox.OpenLaunchJournal("/var/lib/carlos-claims/journals.db") |
| + launches, err = openClaimRootJournal() |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/host/claimjournal_migration.go b/internal/host/claimjournal_migration.go| index 4ad9948b9..98f16b82f 100644 |
| --- a/internal/host/claimjournal_migration.go |
| +++ b/internal/host/claimjournal_migration.go |
| @@ -82,10 +82,7 @@ func migrateClaimJournals(ctx context.Context, store release.Store, r ClaimRunti |
| if err != nil { |
| return err |
| } |
| - if err = hostbox.PrepareRootJournalDirectory("/var/lib/carlos-claims"); err != nil { |
| - return err |
| - } |
| - launches, err := hostbox.OpenLaunchJournal("/var/lib/carlos-claims/journals.db") |
| + launches, err := openClaimRootJournal() |
| if err != nil { |
| return err |
| } |
| @@ -94,5 +91,12 @@ func migrateClaimJournals(ctx context.Context, store release.Store, r ClaimRunti |
| if err != nil { |
| return err |
| } |
| - return journals.MigrateLegacy(ctx, filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, quiesceLegacyClaimJournals, legacyJournalProof(store, owners.RetirementAuthority.Directory)) |
| + return journals.MigrateLegacy(ctx, filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, quiesceLegacyClaimJournals, legacyJournalProof(store, owners.RetirementAuthority.Directory), recoverLegacyClaimEdgeMask) |
| +} |
| + |
| +var openClaimRootJournal = func() (*hostbox.LaunchJournal, error) { |
| + if err := hostbox.PrepareRootJournalDirectory("/var/lib/carlos-claims"); err != nil { |
| + return nil, err |
| + } |
| + return hostbox.OpenLaunchJournal("/var/lib/carlos-claims/journals.db") |
| } |
diff --git a/internal/host/claimjournal_migration_linux.go b/internal/host/claimjournal_migration_linux.go| index 44cdcfbd1..9df35410f 100644 |
| --- a/internal/host/claimjournal_migration_linux.go |
| +++ b/internal/host/claimjournal_migration_linux.go |
| @@ -12,6 +12,7 @@ import ( |
| "os/exec" |
| "strconv" |
| "strings" |
| + "time" |
| ) |
| var claimQuiesceOwner = claimStateOwner |
| @@ -117,3 +118,33 @@ func quiesceLegacyClaimJournals(ctx context.Context) (func(), error) { |
| } |
| return release, nil |
| } |
| + |
| +func recoverLegacyClaimEdgeMask(ctx context.Context) error { |
| + record, err := os.ReadFile(claimEdgeMaskOwner) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + return err |
| + } |
| + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) |
| + defer cancel() |
| + before, err := claimSystemctl(ctx, "is-enabled", "carlos-edge.service") |
| + state := strings.TrimSpace(string(before)) |
| + if err != nil && state != "masked" && state != "masked-runtime" { |
| + return err |
| + } |
| + if state == "masked-runtime" && string(record) == strings.TrimSpace(string(boot)) { |
| + if _, err = claimSystemctl(ctx, "unmask", "--runtime", "carlos-edge.service"); err != nil { |
| + return err |
| + } |
| + } |
| + if err = os.Remove(claimEdgeMaskOwner); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimjournal_migration_other.go b/internal/host/claimjournal_migration_other.go| index f4d57bc9c..2847bfe24 100644 |
| --- a/internal/host/claimjournal_migration_other.go |
| +++ b/internal/host/claimjournal_migration_other.go |
| @@ -8,3 +8,5 @@ import ( |
| ) |
| func quiesceLegacyClaimJournals(context.Context) (func(), error) { return nil, hostbox.ErrUnsupported } |
| + |
| +func recoverLegacyClaimEdgeMask(context.Context) error { return hostbox.ErrUnsupported } |
diff --git a/internal/host/plan9fix4_claimjournal_migration_linux_test.go b/internal/host/plan9fix4_claimjournal_migration_linux_test.go| new file mode 100644 |
| index 000000000..e22e78272 |
| --- /dev/null |
| +++ b/internal/host/plan9fix4_claimjournal_migration_linux_test.go |
| @@ -0,0 +1,303 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type fix4Owners struct{ ClaimOwnerBindings } |
| + |
| +func (o fix4Owners) OpenClaimOwners(context.Context, release.Store, ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| + return o.ClaimOwnerBindings, nil |
| +} |
| + |
| +type fix4UnusedDependencies struct { |
| + hostbox.LocalAuthority |
| + StopControllerReader |
| + lease.VersionedStore |
| + ClaimWakeInputs |
| + hostbox.RegionalEvidence |
| +} |
| + |
| +func fix4JournalFixture(t *testing.T) (release.Store, ClaimRuntimeResources, fix4Owners, string) { |
| + t.Helper() |
| + root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| + evidence := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)) |
| + store := release.NewDirStore(t.TempDir()) |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", ControlBucket: "deployment"}) |
| + actor, err := identity.Verify(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + row := hostactors.Box{BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: evidence.Public().(ed25519.PublicKey), State: "active"} |
| + roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: row}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + body, err := release.SignRootEnvelope(release.RootSigner(root), roster) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = store.Put(t.Context(), hostactors.Key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + directory := hostactors.Directory{Store: store, PublicKey: root.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket} |
| + unused := fix4UnusedDependencies{} |
| + owners := fix4Owners{ClaimOwnerBindings{ |
| + Identity: identity, RetirementAuthority: &hostops.ProductionAuthority{Store: store, Directory: directory, Now: time.Now}, EvidenceKey: evidence, |
| + Local: unused, Controllers: unused, Data: unused, Inputs: unused, Evidence: unused, |
| + InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, |
| + MovesPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, |
| + LeaseSet: func(context.Context, string, string) ([]string, error) { return nil, nil }, |
| + View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }, |
| + Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| + AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }, |
| + PublicationEvidence: func(context.Context, hostclaim.Observed) error { return nil }, |
| + Close: func(context.Context) error { return nil }, |
| + }} |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + resources := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: t.TempDir(), OptDir: t.TempDir(), UnitsDir: t.TempDir()} |
| + legacy := filepath.Join(resources.DataDir, ".hostclaims") |
| + if err = os.Mkdir(legacy, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + journalPath := filepath.Join(t.TempDir(), "journals.db") |
| + original := openClaimRootJournal |
| + t.Cleanup(func() { openClaimRootJournal = original }) |
| + openClaimRootJournal = func() (*hostbox.LaunchJournal, error) { return hostbox.OpenLaunchJournal(journalPath) } |
| + return store, resources, owners, legacy |
| +} |
| + |
| +func TestPlan9Fix4RootAssemblyIgnoresReplacedLegacyDirectory(t *testing.T) { |
| + for _, mode := range []string{"instance-control", "adopt"} { |
| + for _, replacement := range []string{"missing", "renamed", "symlink", "file"} { |
| + t.Run(mode+"/"+replacement, func(t *testing.T) { |
| + store, resources, owners, legacy := fix4JournalFixture(t) |
| + resources.Mode = mode |
| + if mode == "adopt" { |
| + owners.EvidenceKey = nil |
| + } |
| + journal, err := openClaimRootJournal() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + service, err := hostbox.NewJournalService(journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if alert, err := service.CheckLegacyMigration(t.Context(), legacy); alert != nil || err != nil { |
| + t.Fatalf("marker: %v %v", alert, err) |
| + } |
| + if err = journal.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + target := t.TempDir() |
| + if replacement == "renamed" { |
| + err = os.Rename(legacy, legacy+"-moved") |
| + } else { |
| + err = os.Remove(legacy) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch replacement { |
| + case "symlink": |
| + err = os.Symlink(target, legacy) |
| + case "file": |
| + err = os.WriteFile(legacy, []byte("agent replacement"), 0640) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var logs bytes.Buffer |
| + previous := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(previous) |
| + assembly, err := OpenProductionClaimAssembly(t.Context(), store, resources, owners) |
| + if err != nil { |
| + t.Fatalf("agent-controlled legacy directory prevented root startup: %v", err) |
| + } |
| + if err = assembly.Close(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(logs.String(), "ignored after migration") { |
| + t.Fatalf("missing legacy directory alert: %q", logs.String()) |
| + } |
| + if replacement == "missing" || replacement == "renamed" { |
| + if _, err = os.Lstat(legacy); !os.IsNotExist(err) { |
| + t.Fatalf("root recreated agent directory: %v", err) |
| + } |
| + } |
| + if replacement == "symlink" { |
| + if got, err := os.Readlink(legacy); err != nil || got != target { |
| + t.Fatalf("root changed symlink: %q %v", got, err) |
| + } |
| + } |
| + if replacement == "file" { |
| + if got, err := os.ReadFile(legacy); err != nil || string(got) != "agent replacement" { |
| + t.Fatalf("root changed file: %q %v", got, err) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan9Fix4MigrationRetryAfterCommitRecoversOwnedMask(t *testing.T) { |
| + for _, removeLegacy := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "unchanged", true: "removed"}[removeLegacy], func(t *testing.T) { |
| + store, resources, owners, legacy := fix4JournalFixture(t) |
| + var calls []string |
| + plan9Fix2Systemctl(t, "enabled", &calls) |
| + owner := plan9Fix2MaskOwner(t) |
| + launch, err := hostbox.OpenLaunchJournal(filepath.Join(legacy, "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = launch.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(legacy, "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = inventory.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + journal, err := openClaimRootJournal() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + service, err := hostbox.NewJournalService(journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = service.MigrateLegacy(t.Context(), legacy, "box", strings.Repeat("a", 32), func(ctx context.Context) (func(), error) { |
| + _, err := quiesceLegacyClaimJournals(ctx) |
| + return func() {}, err |
| + }, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = journal.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if raw, err := os.ReadFile(owner); err != nil || string(raw) != plan9Fix2Boot(t) { |
| + t.Fatalf("commit lost crash mask: %q %v", raw, err) |
| + } |
| + if removeLegacy { |
| + if err = os.RemoveAll(legacy); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + calls = nil |
| + plan9Fix2Systemctl(t, "masked-runtime", &calls) |
| + if err = migrateClaimJournals(t.Context(), store, resources, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.Contains(calls, "unmask --runtime carlos-edge.service") { |
| + t.Fatalf("committed retry left edge masked: %v", calls) |
| + } |
| + if _, err = os.Stat(owner); !os.IsNotExist(err) { |
| + t.Fatalf("migration mask record remains: %v", err) |
| + } |
| + for _, call := range calls { |
| + if strings.HasPrefix(call, "mask ") { |
| + t.Fatalf("completed retry masked edge again: %v", calls) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan9Fix4CompletedMigrationKeepsOperatorMask(t *testing.T) { |
| + for _, test := range []struct{ name, state, record string }{ |
| + {"unrecorded-runtime", "masked-runtime", ""}, |
| + {"different-boot", "masked-runtime", "another-boot"}, |
| + {"persistent", "masked", "same-boot"}, |
| + {"unmasked", "enabled", "same-boot"}, |
| + } { |
| + t.Run(test.name, func(t *testing.T) { |
| + store, resources, owners, _ := fix4JournalFixture(t) |
| + owner := plan9Fix2MaskOwner(t) |
| + var calls []string |
| + plan9Fix2Systemctl(t, test.state, &calls) |
| + if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + record := test.record |
| + if record == "same-boot" { |
| + record = plan9Fix2Boot(t) |
| + } |
| + if record != "" { |
| + if err := os.WriteFile(owner, []byte(record), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + calls = nil |
| + if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, call := range calls { |
| + if strings.HasPrefix(call, "unmask") || strings.HasPrefix(call, "mask") { |
| + t.Fatalf("operator state changed: %v", calls) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan9Fix4CompletedMigrationRetainsRecordOnUnmaskFailure(t *testing.T) { |
| + store, resources, owners, _ := fix4JournalFixture(t) |
| + owner := plan9Fix2MaskOwner(t) |
| + var calls []string |
| + plan9Fix2Systemctl(t, "masked-runtime", &calls) |
| + if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(owner, []byte(plan9Fix2Boot(t)), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + systemctl := claimSystemctl |
| + claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + if args[0] == "unmask" { |
| + return nil, os.ErrPermission |
| + } |
| + return systemctl(ctx, args...) |
| + } |
| + if err := migrateClaimJournals(t.Context(), store, resources, owners); err == nil { |
| + t.Fatal("unmask failure reported success") |
| + } |
| + if raw, err := os.ReadFile(owner); err != nil || string(raw) != plan9Fix2Boot(t) { |
| + t.Fatalf("failed recovery discarded mask ownership: %q %v", raw, err) |
| + } |
| + claimSystemctl = systemctl |
| + if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(owner); !os.IsNotExist(err) { |
| + t.Fatalf("successful recovery kept ownership record: %v", err) |
| + } |
| +} |
diff --git a/internal/hostactors/plan9_fix4_flagoff_test.go b/internal/hostactors/plan9_fix4_flagoff_test.go| new file mode 100644 |
| index 000000000..ad4616ac0 |
| --- /dev/null |
| +++ b/internal/hostactors/plan9_fix4_flagoff_test.go |
| @@ -0,0 +1,30 @@ |
| +package hostactors |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| +) |
| + |
| +func TestPlan9Fix4NoFloorKeepsActivationAsMainDid(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + w, _ := plan9Fixture(t, nil) |
| + w.fence(t, "b", "w", w.now, "terminated") |
| + if err := w.d.Store.Delete(t.Context(), hostfloor.Key); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, etag, err := w.d.Store.Get(t.Context(), Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = w.d.ReleaseActivation(t.Context(), w.sg, plan9Activation, w.now); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing floor did not refuse release: %v", err) |
| + } |
| + after, afterETag, err := w.d.Store.Get(t.Context(), Key) |
| + if err != nil || etag != afterETag || !bytes.Equal(before, after) { |
| + t.Fatalf("flag-off no-floor release changed roster: %v", err) |
| + } |
| +} |
diff --git a/internal/hostbox/intended_recovery_test.go b/internal/hostbox/intended_recovery_test.go| index ea7abcde7..b80bbfaa2 100644 |
| --- a/internal/hostbox/intended_recovery_test.go |
| +++ b/internal/hostbox/intended_recovery_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -27,7 +28,7 @@ type intendedFixture struct { |
| inventory *StopInventoryDB |
| reg *registry.Registry |
| data string |
| - dbPath string |
| + stateRel string |
| state string |
| id string |
| } |
| @@ -73,29 +74,28 @@ func newIntendedFixture(t *testing.T, edit func(*StopInventoryEntry)) intendedFi |
| t.Cleanup(func() { launches.Close() }) |
| owner := registry.ClaimOwnership{ClaimIncarnation: prior.Incarnation, ClaimStamp: prior.Stamp, ClaimOwner: "old-owner", ClaimRowID: "old-route"} |
| data := source.Guard.DataDir |
| - dbPath := filepath.Join(data, old.Host+".db") |
| - if err = os.WriteFile(dbPath, []byte("untrusted-local-database"), 0600); err != nil { |
| + dbPath := provision.NestedDBPath(data, old.Host) |
| + statePath := filepath.Dir(dbPath) |
| + if err = os.MkdirAll(filepath.Dir(statePath), 0700); err != nil { |
| t.Fatal(err) |
| } |
| - if err = reg.Upsert(registry.Route{Host: old.Host, Kind: "instance", Socket: "/run/example.sock", DBPath: dbPath, ClaimOwnership: owner}); err != nil { |
| + stateRel, err := filepath.Rel(data, statePath) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - if err = reg.UpsertTimer(registry.Timer{Host: old.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 123, Attempts: 2}); err != nil { |
| + if err = reg.Upsert(registry.Route{Host: old.Host, Kind: "instance", Socket: provision.NestedSocket(old.Host), DBPath: dbPath, ClaimOwnership: owner}); err != nil { |
| t.Fatal(err) |
| } |
| - parker := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: data, Incarnation: prior.Incarnation, Host: prior.Host, Retirement: "capture"} |
| - artifact, err := parker.CaptureArtifact("data", old.Host+".db", owner.ClaimOwner, ArtifactPath) |
| - if err != nil { |
| + if err = reg.UpsertTimer(registry.Timer{Host: old.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 123, Attempts: 2}); err != nil { |
| t.Fatal(err) |
| } |
| // The intent PrepareState writes before Mkdirat: typed, no identity. |
| - state := StopInventoryEntry{Kind: "path", ArtifactID: "state-dir", ManagedRoot: "data", RelativePath: old.Host, Ownership: owner.ClaimOwner, Materialization: "intended", Type: unix.S_IFDIR} |
| + state := StopInventoryEntry{Kind: "path", ArtifactID: "state-dir", ManagedRoot: "data", RelativePath: stateRel, Ownership: owner.ClaimOwner, Materialization: "intended", Type: unix.S_IFDIR} |
| if edit != nil { |
| edit(&state) |
| } |
| in := StopInventory{Complete: true, Host: old.Host, Incarnation: prior.Incarnation, InstallationStamp: prior.Stamp, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{ |
| {Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}, |
| - {Kind: "path", ArtifactID: "database", ManagedRoot: "data", RelativePath: artifact.Name, Ownership: artifact.Owner, Materialization: "materialized", Device: artifact.Dev, Inode: artifact.Ino, Type: artifact.Type}, |
| state, |
| }} |
| if err = inventory.WriteStopInventory(ctx, in); err != nil { |
| @@ -104,7 +104,7 @@ func newIntendedFixture(t *testing.T, edit func(*StopInventoryEntry)) intendedFi |
| successor := admit(old, "second") |
| 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} |
| 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 }} |
| - return intendedFixture{handover: handover, prior: prior, next: next, inventory: inventory, reg: reg, data: data, dbPath: dbPath, state: filepath.Join(data, old.Host), id: "handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box)} |
| + 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)} |
| } |
| func (f intendedFixture) parked(retirement, name string) string { |
| @@ -115,14 +115,25 @@ func (f intendedFixture) intendedJournal() string { |
| return filepath.Join(f.handover.JournalDir, "retired", f.id+"-intended.json") |
| } |
| -func (f intendedFixture) requireUnmoved(t *testing.T) { |
| +func (f intendedFixture) obstructMainRetirement(t *testing.T) string { |
| t.Helper() |
| - if body, err := os.ReadFile(f.dbPath); err != nil || string(body) != "untrusted-local-database" { |
| - t.Fatalf("refusal moved prior database: %v", err) |
| + blocked := filepath.Join(f.handover.JournalDir, "retired", f.id+"-old.json") |
| + if err := os.MkdirAll(blocked, 0700); err != nil { |
| + t.Fatal(err) |
| } |
| + return blocked |
| +} |
| + |
| +func (f intendedFixture) requirePriorRoute(t *testing.T) { |
| + t.Helper() |
| if route, err := f.reg.Get(f.prior.Host); err != nil || route == nil || route.ClaimStamp != f.prior.Stamp || route.ClaimOwner != "old-owner" { |
| t.Fatalf("refusal changed prior route: %+v %v", route, err) |
| } |
| +} |
| + |
| +func (f intendedFixture) requireUnmoved(t *testing.T) { |
| + t.Helper() |
| + f.requirePriorRoute(t) |
| if _, err := os.Lstat(f.intendedJournal()); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("refusal recorded an intended capture: %v", err) |
| } |
| @@ -130,9 +141,6 @@ func (f intendedFixture) requireUnmoved(t *testing.T) { |
| func (f intendedFixture) requireComplete(t *testing.T) { |
| t.Helper() |
| - if _, err := os.Lstat(f.dbPath); !errors.Is(err, os.ErrNotExist) { |
| - t.Fatalf("prior database not parked: %v", err) |
| - } |
| if _, err := os.Lstat(f.state); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("intended state dir remains: %v", err) |
| } |
| @@ -155,7 +163,7 @@ func TestColdHandoverResumesAbsentIntendedStateDir(t *testing.T) { |
| if _, err := os.Lstat(f.intendedJournal()); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("absent intent captured an artifact: %v", err) |
| } |
| - if _, err := os.Lstat(f.parked("-intended", f.prior.Host)); !errors.Is(err, os.ErrNotExist) { |
| + if _, err := os.Lstat(f.parked("-intended", f.stateRel)); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("absent intent parked something: %v", err) |
| } |
| if err := f.handover.Run(ctx, f.prior, f.next); err != nil { |
| @@ -182,17 +190,12 @@ func TestColdHandoverParksTypedEmptyIntendedStateDirAndReplays(t *testing.T) { |
| t.Fatal(err) |
| } |
| // Fail the main retirement after the intended park, as an interrupted run. |
| - blocked := f.parked("-old", f.prior.Host+".db") |
| - if err = os.MkdirAll(filepath.Dir(blocked), 0700); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err = os.WriteFile(blocked, []byte("obstruction"), 0600); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err = f.handover.Run(ctx, f.prior, f.next); !errors.Is(err, ErrChanged) { |
| + blocked := f.obstructMainRetirement(t) |
| + if err = f.handover.Run(ctx, f.prior, f.next); err == nil { |
| t.Fatalf("obstructed main retirement: %v", err) |
| } |
| - parked := f.parked("-intended", f.prior.Host) |
| + f.requirePriorRoute(t) |
| + parked := f.parked("-intended", f.stateRel) |
| got, err := os.Lstat(parked) |
| if err != nil || !os.SameFile(created, got) { |
| t.Fatalf("intended dir not parked by inode: %v", err) |
| @@ -200,9 +203,6 @@ func TestColdHandoverParksTypedEmptyIntendedStateDirAndReplays(t *testing.T) { |
| if _, err = os.Lstat(f.state); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("intended dir still at source: %v", err) |
| } |
| - if _, err = os.Lstat(f.dbPath); err != nil { |
| - t.Fatalf("main retirement ran past obstruction: %v", err) |
| - } |
| body, err := os.ReadFile(f.intendedJournal()) |
| if err != nil { |
| t.Fatal(err) |
| @@ -225,9 +225,6 @@ func TestColdHandoverParksTypedEmptyIntendedStateDirAndReplays(t *testing.T) { |
| if _, err = os.Lstat(f.state); err != nil { |
| t.Fatalf("late directory touched: %v", err) |
| } |
| - if _, err = os.Lstat(f.dbPath); err != nil { |
| - t.Fatalf("refusal moved prior database: %v", err) |
| - } |
| if err = os.Remove(f.state); err != nil { |
| t.Fatal(err) |
| } |
| @@ -250,9 +247,6 @@ func TestColdHandoverParksTypedEmptyIntendedStateDirAndReplays(t *testing.T) { |
| if err = decodeStrict(mainBody, &rows); err != nil || !rows.Complete || len(rows.Archives) == 0 { |
| t.Fatalf("row archives missing: %v", err) |
| } |
| - if _, err = os.Lstat(f.parked("-old", f.prior.Host+".db")); err != nil { |
| - t.Fatalf("parked prior database missing: %v", err) |
| - } |
| for range 2 { |
| if err = f.handover.Run(ctx, f.prior, f.next); err != nil { |
| @@ -353,7 +347,7 @@ func TestColdHandoverRefusesUnprovableIntendedStateDir(t *testing.T) { |
| if err := os.Mkdir(f.state, 0700); err != nil { |
| t.Fatal(err) |
| } |
| - 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.prior.Host, Ownership: "successor-owner", Materialization: "intended", Type: unix.S_IFDIR}}} |
| + 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}}} |
| if err := f.inventory.WriteStopInventory(t.Context(), successor); err != nil { |
| t.Fatal(err) |
| } |
| @@ -385,7 +379,7 @@ func TestColdHandoverRefusesUnprovableIntendedStateDir(t *testing.T) { |
| if after, err := os.Lstat(f.state); err != nil || !os.SameFile(before, after) { |
| t.Fatalf("refused intended name changed: %v", err) |
| } |
| - if _, err = os.Lstat(f.parked("-intended", f.prior.Host)); !errors.Is(err, os.ErrNotExist) { |
| + if _, err = os.Lstat(f.parked("-intended", f.stateRel)); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("refused intended name parked: %v", err) |
| } |
| } |
| @@ -399,16 +393,11 @@ func TestColdHandoverRefusesUnprovableIntendedStateDir(t *testing.T) { |
| func TestColdHandoverAbsentIntentionDecisionSurvivesInterruptedRetirement(t *testing.T) { |
| f := newIntendedFixture(t, nil) |
| ctx := t.Context() |
| - blocked := f.parked("-old", f.prior.Host+".db") |
| - if err := os.MkdirAll(filepath.Dir(blocked), 0700); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.WriteFile(blocked, []byte("obstruction"), 0600); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := f.handover.Run(ctx, f.prior, f.next); !errors.Is(err, ErrChanged) { |
| + blocked := f.obstructMainRetirement(t) |
| + if err := f.handover.Run(ctx, f.prior, f.next); err == nil { |
| t.Fatalf("first retirement: %v", err) |
| } |
| + f.requirePriorRoute(t) |
| if err := os.Remove(blocked); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/hostbox/inventoryscope.go b/internal/hostbox/inventoryscope.go| index 2a3c7d956..4f9c3f176 100644 |
| --- a/internal/hostbox/inventoryscope.go |
| +++ b/internal/hostbox/inventoryscope.go |
| @@ -56,14 +56,12 @@ func ValidateAgentInventory(in StopInventory, row *registry.Route, dataDir strin |
| if row.DBPath == filepath.Join(dataDir, "registry.db") { |
| return ErrTypedInventory |
| } |
| - // Flat legacy layouts: the database, its exact SQLite companion |
| - // names, and the host-named state directory PrepareState creates, |
| - // but only for a host name that cannot collide with a root |
| - // namespace (see flatStateNameSafe). |
| + // Flat legacy layouts: the database and its exact SQLite |
| + // companion names. Enabled claim launches require the nested |
| + // layout, so an agent has no flat state-directory authority. |
| db := filepath.Base(row.DBPath) |
| isDB := e.RelativePath == db || e.RelativePath == db+"-wal" || e.RelativePath == db+"-shm" || e.RelativePath == db+"-journal" |
| - isState := flatStateNameSafe(in.Host) && (e.RelativePath == in.Host || strings.HasPrefix(e.RelativePath, in.Host+string(filepath.Separator))) |
| - if !isDB && !isState { |
| + if !isDB { |
| return ErrTypedInventory |
| } |
| } else if e.RelativePath != parent && !strings.HasPrefix(e.RelativePath, parent+string(filepath.Separator)) { |
| @@ -120,21 +118,3 @@ func (a ReconcileAdapter) retirementRoute(row registry.Route, target hostguard.F |
| } |
| return found, nil |
| } |
| - |
| -// flatStateNameSafe reports whether a flat layout's host-named state |
| -// directory, <data>/<host>, can be named by agent inventory. A dotted host |
| -// name of valid shape can never equal a root namespace at the data root: |
| -// those are dotless or start with a dot. The remaining dotted collisions |
| -// are the registry database and another host's database or SQLite |
| -// companions, <data>/<other>.db*, which are refused by suffix. |
| -func flatStateNameSafe(host string) bool { |
| - if !strings.Contains(host, ".") || !provision.ValidHostShape(host) || strings.HasPrefix(host, "registry.db") { |
| - return false |
| - } |
| - for _, suffix := range []string{".db", ".db-wal", ".db-shm", ".db-journal"} { |
| - if strings.HasSuffix(host, suffix) { |
| - return false |
| - } |
| - } |
| - return true |
| -} |
diff --git a/internal/hostbox/journalmigration_linux.go b/internal/hostbox/journalmigration_linux.go| index 96c40cace..f7967b148 100644 |
| --- a/internal/hostbox/journalmigration_linux.go |
| +++ b/internal/hostbox/journalmigration_linux.go |
| @@ -51,6 +51,9 @@ func (s *JournalService) CheckLegacyMigration(ctx context.Context, legacy string |
| if source != legacy { |
| return nil, ErrChanged |
| } |
| + if err == nil && files == "absent" { |
| + err = os.ErrNotExist |
| + } |
| if err != nil { |
| return fmt.Errorf("legacy claim journal %s ignored after migration: %w", legacy, err), nil |
| } |
| @@ -115,7 +118,7 @@ func (s *JournalService) journalMigrationMarker(ctx context.Context) (source, fi |
| // copied legacy bytes and of the rows it imported: if a rolled-back binary |
| // later wrote rows there, this step refuses rather than silently ignoring |
| // them. Legacy originals remain untouched. |
| -func (s *JournalService) MigrateLegacy(ctx context.Context, legacy, box, enrollment string, quiesce func(context.Context) (func(), error), verify func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error) error { |
| +func (s *JournalService) MigrateLegacy(ctx context.Context, legacy, box, enrollment string, quiesce func(context.Context) (func(), error), verify func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error, onSuccess ...func(context.Context) error) (resultErr error) { |
| if s == nil || s.Launches == nil || !filepath.IsAbs(legacy) || box == "" || enrollment == "" { |
| return ErrTypedJournal |
| } |
| @@ -124,6 +127,15 @@ func (s *JournalService) MigrateLegacy(ctx context.Context, legacy, box, enrollm |
| return err |
| } |
| defer unlock() |
| + defer func() { |
| + if resultErr == nil { |
| + for _, recover := range onSuccess { |
| + if recover != nil { |
| + resultErr = errors.Join(resultErr, recover(ctx)) |
| + } |
| + } |
| + } |
| + }() |
| // These private snapshots are never authoritative before the SQL marker. |
| // A restart can discard a pre-commit copy and take a fresh frozen snapshot. |
| entries, err := os.ReadDir(filepath.Dir(s.Launches.path)) |
diff --git a/internal/hostbox/journalmigration_other.go b/internal/hostbox/journalmigration_other.go| index a1c533310..3b590e61a 100644 |
| --- a/internal/hostbox/journalmigration_other.go |
| +++ b/internal/hostbox/journalmigration_other.go |
| @@ -13,6 +13,6 @@ func (s *JournalService) CheckLegacyMigration(context.Context, string) (error, e |
| return nil, ErrUnsupported |
| } |
| -func (s *JournalService) MigrateLegacy(context.Context, string, string, string, func(context.Context) (func(), error), func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error) error { |
| +func (s *JournalService) MigrateLegacy(context.Context, string, string, string, func(context.Context) (func(), error), func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error, ...func(context.Context) error) error { |
| return ErrUnsupported |
| } |
diff --git a/internal/hostbox/plan9fix2_inventory_test.go b/internal/hostbox/plan9fix2_inventory_test.go| index abee1cfb3..064684319 100644 |
| --- a/internal/hostbox/plan9fix2_inventory_test.go |
| +++ b/internal/hostbox/plan9fix2_inventory_test.go |
| @@ -69,7 +69,7 @@ func TestPlan9Fix2InventoryNamespace(t *testing.T) { |
| t.Fatal("missing row granted path authority") |
| } |
| row.DBPath = filepath.Join(data, row.Host+".db") |
| - for _, name := range []string{row.Host + ".db", row.Host + ".db-wal", row.Host + ".db-shm", row.Host + ".db-journal", row.Host, row.Host + "/" + row.Host + ".db", row.Host + "/child"} { |
| + for _, name := range []string{row.Host + ".db", row.Host + ".db-wal", row.Host + ".db-shm", row.Host + ".db-journal"} { |
| base.Entries[0].RelativePath = name |
| if err := ValidateAgentInventory(base, &row, data); err != nil { |
| t.Fatalf("legacy %s: %v", name, err) |
| @@ -77,6 +77,7 @@ func TestPlan9Fix2InventoryNamespace(t *testing.T) { |
| } |
| for _, name := range []string{ |
| ".", ".hostclaims", |
| + row.Host, row.Host + "/" + row.Host + ".db", row.Host + "/child", |
| row.Host + "-other", row.Host + ".db-other", row.Host + ".db/", "./" + row.Host + ".db", |
| "../" + row.Host + ".db", "/" + row.Host + ".db", filepath.ToSlash(filepath.Join(data, row.Host+".db")), |
| "other.example.db", "other.example", "instances/other.example", "instances/other.example/other.example.db", |
| @@ -140,20 +141,3 @@ func TestPlan9Fix2AgentCannotChooseNamespaceThroughRegistryDBPath(t *testing.T) |
| t.Fatalf("flat hostname selected shared instances namespace: %v", err) |
| } |
| } |
| - |
| -// A flat host-named state directory is nameable only for a host whose name |
| -// cannot collide with a root namespace or another host's database file. |
| -func TestPlan9Fix3FlatStateNameMustNotCollide(t *testing.T) { |
| - data := t.TempDir() |
| - for host, want := range map[string]bool{ |
| - "app.example": true, "hostguard": false, "instances": false, "registry.db": false, |
| - "registry.db-wal.example": false, "other.db": false, "other.db-wal": false, "shop.example.db-journal": false, |
| - } { |
| - row := registry.Route{Host: host, DBPath: filepath.Join(data, host+".db"), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}} |
| - in := StopInventory{Host: host, Incarnation: "inc", InstallationStamp: 1, Entries: []StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: host, Ownership: "owner", Materialization: "intended"}}} |
| - err := ValidateAgentInventory(in, &row, data) |
| - if (err == nil) != want { |
| - t.Errorf("host %q: allowed=%v, want %v (%v)", host, err == nil, want, err) |
| - } |
| - } |
| -} |
diff --git a/internal/hostbox/plan9fix4_inventory_linux_test.go b/internal/hostbox/plan9fix4_inventory_linux_test.go| new file mode 100644 |
| index 000000000..67967e803 |
| --- /dev/null |
| +++ b/internal/hostbox/plan9fix4_inventory_linux_test.go |
| @@ -0,0 +1,50 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlan9Fix4InventoryRefusesFlatRestartMarksArtifact(t *testing.T) { |
| + data := t.TempDir() |
| + host := "restart-marks.json" |
| + owner := "owner" |
| + victim := filepath.Join(data, host) |
| + if err := os.WriteFile(victim, []byte("root-written restart state"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + parker := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: data, Incarnation: "inc", Host: host, Retirement: "capture"} |
| + artifact, err := parker.CaptureArtifact("data", host, owner, ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inventory, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { inventory.Close() }) |
| + in := StopInventory{Complete: true, Host: host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{{ |
| + Kind: "path", ArtifactID: "restart-marks", ManagedRoot: "data", RelativePath: artifact.Name, Ownership: artifact.Owner, |
| + Materialization: "materialized", Device: artifact.Dev, Inode: artifact.Ino, Type: artifact.Type, |
| + }}} |
| + if err = inventory.WriteStopInventory(t.Context(), in); err != nil { |
| + t.Fatal(err) |
| + } |
| + in, err = inventory.ReadInstallation(t.Context(), host, "inc", 1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + row := registry.Route{Host: host, DBPath: filepath.Join(data, host+".db"), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: owner, ClaimRowID: "row"}} |
| + if err = ValidateAgentInventory(in, &row, data); !errors.Is(err, ErrTypedInventory) { |
| + t.Fatalf("flat inventory accepted root restart marks with captured identity: %v", err) |
| + } |
| + if body, err := os.ReadFile(victim); err != nil || string(body) != "root-written restart state" { |
| + t.Fatalf("refusal changed restart marks: %q %v", body, err) |
| + } |
| +} |
diff --git a/internal/hostbox/plan9fix4_journalmigration_linux_test.go b/internal/hostbox/plan9fix4_journalmigration_linux_test.go| new file mode 100644 |
| index 000000000..c07d18bdd |
| --- /dev/null |
| +++ b/internal/hostbox/plan9fix4_journalmigration_linux_test.go |
| @@ -0,0 +1,70 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestPlan9Fix4MigrationSuccessRecoveryHoldsLock(t *testing.T) { |
| + for _, marked := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "commit", true: "completed"}[marked], func(t *testing.T) { |
| + service, _, _ := journalFixture(t) |
| + legacy := t.TempDir() |
| + if marked { |
| + if err := service.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + lockPath := filepath.Join(filepath.Dir(service.Launches.path), "migration.lock") |
| + called := false |
| + err := service.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil, func(ctx context.Context) error { |
| + called = true |
| + _, _, _, present, err := service.journalMigrationMarker(ctx) |
| + if err != nil || !present { |
| + t.Fatalf("recovery before commit: %v %v", present, err) |
| + } |
| + fd, err := unix.Open(lockPath, unix.O_RDWR|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unix.Close(fd) |
| + err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) |
| + if !errors.Is(err, unix.EWOULDBLOCK) { |
| + t.Fatalf("recovery outside migration lock: %v", err) |
| + } |
| + return nil |
| + }) |
| + if err != nil || !called { |
| + t.Fatalf("success recovery: called=%v err=%v", called, err) |
| + } |
| + fd, err := unix.Open(lockPath, unix.O_RDWR|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unix.Close(fd) |
| + if err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil { |
| + t.Fatalf("migration kept lock after recovery: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan9Fix4FailedMigrationDoesNotRecoverMask(t *testing.T) { |
| + service, _, _ := journalFixture(t) |
| + legacy := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(legacy, "launches.db"), []byte("partial legacy pair"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + err := service.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil, func(context.Context) error { called = true; return nil }) |
| + if err == nil || called { |
| + t.Fatalf("failed import ran success recovery: called=%v err=%v", called, err) |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 08cddbbcd..8359713c2 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -475,8 +475,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Hibernate generation markers use a different bucket and key grammar from deployment records. |
| {"internal/host/claimproduction_marker.go", "productionClaimMarkers.PutMarker", "foreign-recordkey", "lit:instances", "69301cdddce2bc61a24f0dd8297534814521adc1ddc76a7d3054ed007444a0f5"}, |
| {"accounts.go", "putOverwrite", "nonrecord", "sel:Put", "a85f380434a2d297f14c8068083f41ca7f9c9b735efd7d3abf8d91520fe6453c"}, |
| - {"accounts_record_sequence.go", "putMigrationObject", "record-writer", "key:IsRecordKey,key:WriteRecord,sel:Put", "b73e755aecd08d0d47abc96ba953d14e680412d9fdbc95fc35477b6fb821cb23"}, |
| - {"accounts_record_sequence.go", "validateMigrationRecords", "key-site", "key:IsRecordKey", "e398e210a0ae3893ce8f0f31dad6c32e8e77c3a1214652e8152e362780535a38"}, |
| + {"accounts_record_sequence.go", "putMigrationObject", "record-writer", "key:IsRecordKey,key:WriteRecord,sel:Put", "94de2240deb822b8b59e22f70733df64d42f2859150ff302d8c66f110d09cf34"}, |
| + {"accounts_record_sequence.go", "validateMigrationRecords", "key-site", "key:IsRecordKey", "5e2c29c12d21f5a2223ab844c4177557dfa2ca2c7696c5293879c32a25e4854b"}, |
| {"apimode.go", "apiSession.instanceSetChannel", "key-site", "lit:instances", "e94374a40fc4bce37470ea5cdbdf17b0ceaa2d3b09cf8e755a5ca2fc42e98418"}, |
| {"apimode.go", "apiSession.instanceSetUpstreams", "key-site", "lit:instances", "a4a3949526c5f722de61f11d000e230da4bad7e330d85df9ddb19abd6b234266"}, |
| {"apimode.go", "apiSession.instanceSetEnvironment", "key-site", "lit:instances", "aae153781a31a5fa3c80120242b3ab0c11c4ecf32f849e73f8befa34282f0044"}, |
diff --git a/plan9_fix4_migration_test.go b/plan9_fix4_migration_test.go| new file mode 100644 |
| index 000000000..3ece3c0c4 |
| --- /dev/null |
| +++ b/plan9_fix4_migration_test.go |
| @@ -0,0 +1,49 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type fix4MigrationReadTrap struct{ release.Store } |
| + |
| +func (fix4MigrationReadTrap) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errors.New("main did not read records during preflight") |
| +} |
| + |
| +func TestPlan9Fix4FlagOffMigrationMatchesMain(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + for _, family := range []string{"instances", "sidecars"} { |
| + for _, body := range []string{`{"record_seq":0}`, "{\"record_seq\":9, \"unknown\": true}\n", `{"record_seq":1,"record_seq":2}`, `{"Record_Seq":1}`, `{"record_seq":null}`, `{"record_seq":18446744073709551615}`} { |
| + t.Run(family+"/"+body, func(t *testing.T) { |
| + store := release.NewDirStore(t.TempDir()) |
| + source := "apps/bab/app/" + family + "/source.json" |
| + dest := "apps/cac/app/" + family + "/source.json" |
| + if _, err := store.Put(t.Context(), source, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := validateMigrationRecords(t.Context(), fix4MigrationReadTrap{store}, []copyOp{{from: source, to: dest}}); err != nil { |
| + t.Fatalf("flag-off preflight differs from main: %v", err) |
| + } |
| + if _, err := putMigrationObject(t.Context(), store, dest, []byte(body)); err != nil { |
| + t.Fatalf("main's create-only copy refused: %v", err) |
| + } |
| + raw, _, err := store.Get(t.Context(), dest) |
| + if err != nil || !bytes.Equal(raw, []byte(body)) { |
| + t.Fatalf("copy changed bytes: %q %v", raw, err) |
| + } |
| + if _, err := putMigrationObject(t.Context(), store, dest, []byte(`{"different":true}`)); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("copy overwrote existing destination: %v", err) |
| + } |
| + if _, _, err := store.Get(t.Context(), source); errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("copy removed source") |
| + } |
| + }) |
| + } |
| + } |
| +} |