carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 3ca545e39158e28053e97920fadbc0f6a41c8be8 parent 5ba2323
20 files changed, +680 −116
  • accounts_record_sequence.go +7 −0
  • accounts_record_sequence_test.go +5 −3
  • internal/console/hostreports_process_linux.go +9 −5
  • internal/console/plan9_fix2_legacy_process_linux_test.go +33 −5
  • internal/console/plan9_fix4_flagoff_test.go +20 −0
  • internal/host/claimconstruction.go +5 −6
  • internal/host/claimjournal_migration.go +9 −5
  • internal/host/claimjournal_migration_linux.go +31 −0
  • internal/host/claimjournal_migration_other.go +2 −0
  • internal/host/plan9fix4_claimjournal_migration_linux_test.go +303 −0
  • internal/hostactors/plan9_fix4_flagoff_test.go +30 −0
  • internal/hostbox/intended_recovery_test.go +35 −46
  • internal/hostbox/inventoryscope.go +4 −24
  • internal/hostbox/journalmigration_linux.go +13 −1
  • internal/hostbox/journalmigration_other.go +1 −1
  • internal/hostbox/plan9fix2_inventory_test.go +2 −18
  • internal/hostbox/plan9fix4_inventory_linux_test.go +50 −0
  • internal/hostbox/plan9fix4_journalmigration_linux_test.go +70 −0
  • internal/provision/plan9_b8_census_test.go +2 −2
  • plan9_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")
+ }
+ })
+ }
+ }
+}