WIP files seed checkpoint 29 (not for merge)
Production bug fixed: reboot can renumber the root block device (259:5 -> 259:1) and mount ID while filesystem identity is unchanged; recovery now treats device and mount numbers as boot-local, bound by persistent filesystem/handle/inode proof. Grown-disk reboot test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+451
−107
internal/host/claimfilesseed_isolated_rootrunner_linux_test.go+0 −3internal/host/claimfilesseed_reboot_rootrunner_linux_test.go+0 −76internal/host/claimservinggrow_rootrunner_linux_test.go+141 −0internal/host/claimservingidentity.go+2 −2internal/host/claimservingpark_rootrunner_linux_test.go+82 −2internal/host/claimservingreboot_binding.go+4 −4internal/host/claimservingreboot_linux.go+7 −7internal/host/claimservingrenumber_linux_test.go+201 −0internal/host/claimservingtopology_linux.go+4 −3internal/provision/testdata/plan14-root-reads-seed.tsv+10 −10
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 323.750s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.690s
ok github.com/carlosframework/platform/cmd/moveprobe 93.425s
ok github.com/carlosframework/platform/cmd/zddprobe 0.722s
ok github.com/carlosframework/platform/internal/activator 249.754s
ok github.com/carlosframework/platform/internal/agentsock 0.025s
ok github.com/carlosframework/platform/internal/alerting 0.009s
ok github.com/carlosframework/platform/internal/analytics 0.020s
ok github.com/carlosframework/platform/internal/appconfig 2.527s
ok github.com/carlosframework/platform/internal/apperr 0.087s
ok github.com/carlosframework/platform/internal/applog 0.159s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.734s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.013s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.704s
ok github.com/carlosframework/platform/internal/changelog 0.348s
ok github.com/carlosframework/platform/internal/clamd 0.057s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 400.911s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.369s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.014s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.436s
ok github.com/carlosframework/platform/internal/doorkey 0.011s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 26.759s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.142s
ok github.com/carlosframework/platform/internal/gate 1.685s
ok github.com/carlosframework/platform/internal/genselect 0.020s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.035s
ok github.com/carlosframework/platform/internal/host 168.650s
ok github.com/carlosframework/platform/internal/hostactors 0.426s
ok github.com/carlosframework/platform/internal/hostbox 2.476s
ok github.com/carlosframework/platform/internal/hostclaim 0.580s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.000s
ok github.com/carlosframework/platform/internal/hostfence 0.038s
ok github.com/carlosframework/platform/internal/hostfiles 10.542s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 0.982s
ok github.com/carlosframework/platform/internal/hostops 1.640s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.491s
ok github.com/carlosframework/platform/internal/hostresolve 0.150s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 4.181s
ok github.com/carlosframework/platform/internal/instanceexec 2.737s
ok github.com/carlosframework/platform/internal/instancefs 0.022s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.024s
ok github.com/carlosframework/platform/internal/layout 1.575s
ok github.com/carlosframework/platform/internal/lease 9.853s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.015s
ok github.com/carlosframework/platform/internal/ledger 0.026s
ok github.com/carlosframework/platform/internal/mail 0.206s
ok github.com/carlosframework/platform/internal/maintenance 0.080s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.528s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.092s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.318s
ok github.com/carlosframework/platform/internal/moves/archive 0.804s
ok github.com/carlosframework/platform/internal/objstore 0.192s
ok github.com/carlosframework/platform/internal/ops 0.174s
ok github.com/carlosframework/platform/internal/park 0.226s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 16.365s
ok github.com/carlosframework/platform/internal/publicrelease 0.009s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.993s
ok github.com/carlosframework/platform/internal/relay 7.258s
ok github.com/carlosframework/platform/internal/release 20.213s
ok github.com/carlosframework/platform/internal/rollout 3.704s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.008s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/runs 0.570s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.554s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/hostlife 12.342s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.134s
ok github.com/carlosframework/platform/internal/status 0.042s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.345s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.008s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.581s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46989474 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.025s
=== RUN TestT4StorageProbeMinio
2026/10/15 19:27:35 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DECA97CA8EDC46, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go| index eed1a8202..78608e3cf 100644 |
| --- a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go |
| @@ -95,9 +95,6 @@ func rootFilesSeedIsolatedWitness(t *testing.T, binary string, twoHosts ...bool) |
| } |
| t.Cleanup(func() { os.RemoveAll(dir) }) |
| } |
| - if t.Name() == "TestRootRebootFilesSeedTwoHostPhase1" || t.Name() == "TestRootRebootFilesSeedTwoHostPhase2" { |
| - seedRebootDataMount(t) |
| - } |
| account, err := user.Lookup("carlos") |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go| index a5664ce30..a874e603e 100644 |
| --- a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| @@ -10,7 +10,6 @@ import ( |
| "net" |
| "net/http/httptest" |
| "os" |
| - "os/exec" |
| "path/filepath" |
| "reflect" |
| "sort" |
| @@ -27,7 +26,6 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootstate" |
| - "golang.org/x/sys/unix" |
| ) |
| const seedRebootPath = "/var/lib/carlos-rootrunner-files-seed-reboot" |
| @@ -301,77 +299,3 @@ func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiv |
| p := newServingPreparation(t, f, "boot", fmt.Sprintf("reboot-%d", os.Getpid())) |
| return &seedLiveHost{p: p, endpoint: server.URL, files: opened.(*movesFiles), witness: witness} |
| } |
| - |
| -// A grown root volume can receive different partition device numbers at its |
| -// first reboot. The production contract refuses device renumbering. Give this |
| -// reboot witness its own stable filesystem instead of relaxing that contract. |
| -func seedRebootDataMount(t *testing.T) { |
| - t.Helper() |
| - var root unix.Stat_t |
| - if err := unix.Stat("/", &root); err != nil { |
| - t.Fatal(err) |
| - } |
| - t.Logf("runner root device before data mount: %d", root.Dev) |
| - image := filepath.Join(seedRebootPath, "data.ext4") |
| - deviceFile := filepath.Join(seedRebootPath, "data-device") |
| - command := func(args ...string) string { |
| - t.Helper() |
| - out, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput() |
| - if err != nil { |
| - t.Fatalf("reboot data command %q: %v %s", args, err, out) |
| - } |
| - return strings.TrimSpace(string(out)) |
| - } |
| - var device string |
| - if t.Name() == "TestRootRebootFilesSeedTwoHostPhase1" { |
| - file, err := os.OpenFile(image, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - err = file.Truncate(2 << 30) |
| - closeErr := file.Close() |
| - if err != nil || closeErr != nil { |
| - t.Fatal(err, closeErr) |
| - } |
| - command("mkfs.ext4", "-q", "-F", image) |
| - device = command("losetup", "--find", "--show", image) |
| - if err := os.WriteFile(deviceFile, []byte(device), 0600); err != nil { |
| - t.Fatal(err) |
| - } |
| - } else { |
| - body, err := os.ReadFile(deviceFile) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - device = string(body) |
| - number := strings.TrimPrefix(device, "/dev/loop") |
| - if number == device || number == "" { |
| - t.Fatal("invalid saved loop device", device) |
| - } |
| - if _, err := strconv.ParseUint(number, 10, 32); err != nil { |
| - t.Fatal(err) |
| - } |
| - // losetup refuses a device already in use; never detach someone else's loop. |
| - command("losetup", device, image) |
| - } |
| - t.Cleanup(func() { |
| - cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| - defer cancel() |
| - if out, err := exec.CommandContext(cleanup, "losetup", "--detach", device).CombinedOutput(); err != nil { |
| - t.Errorf("detach fixture loop: %v %s", err, out) |
| - } |
| - }) |
| - if err := unix.Mount(device, "/data", "ext4", unix.MS_NOSUID|unix.MS_NODEV, ""); err != nil { |
| - t.Fatal(err) |
| - } |
| - t.Cleanup(func() { |
| - if err := unix.Unmount("/data", 0); err != nil { |
| - t.Error(err) |
| - } |
| - }) |
| - var data unix.Stat_t |
| - if err := unix.Stat("/data", &data); err != nil { |
| - t.Fatal(err) |
| - } |
| - t.Logf("persistent data device: %s (%d)", device, data.Dev) |
| -} |
diff --git a/internal/host/claimservinggrow_rootrunner_linux_test.go b/internal/host/claimservinggrow_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..b61f7df3b |
| --- /dev/null |
| +++ b/internal/host/claimservinggrow_rootrunner_linux_test.go |
| @@ -0,0 +1,141 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestRootRebootServingParkGrownDiskPhase1(t *testing.T) { |
| + TestRootRebootServingParkChangedMountPhase1(t) |
| +} |
| + |
| +func TestRootRebootServingParkGrownDiskPhase2(t *testing.T) { |
| + TestRootRebootServingParkChangedMountPhase2(t) |
| +} |
| + |
| +// Capture retirement on a small ext4 filesystem, grow it online, then attach it |
| +// under a different device number after reboot. Neither identity change is a seam. |
| +func servingRunnerGrownDisk(t *testing.T, data string, first bool) func() { |
| + t.Helper() |
| + command := func(args ...string) string { |
| + t.Helper() |
| + out, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("grown disk %q: %v %s", args, err, out) |
| + } |
| + return strings.TrimSpace(string(out)) |
| + } |
| + image := filepath.Join(servingRebootRoot, "grow.ext4") |
| + deviceFile := filepath.Join(servingRebootRoot, "grow.device") |
| + uuidFile := filepath.Join(servingRebootRoot, "grow.uuid") |
| + create := func(path string, size int64) { |
| + t.Helper() |
| + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = f.Truncate(size) |
| + closeErr := f.Close() |
| + if err != nil || closeErr != nil { |
| + t.Fatal(err, closeErr) |
| + } |
| + } |
| + detach := func(device string) { |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if out, err := exec.CommandContext(ctx, "losetup", "--detach", device).CombinedOutput(); err != nil { |
| + t.Errorf("detach %s: %v %s", device, err, out) |
| + } |
| + }) |
| + } |
| + if first { |
| + if err := os.Mkdir(data, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + create(image, 128<<20) |
| + command("mkfs.ext4", "-q", "-F", image) |
| + } else { |
| + old, err := os.ReadFile(deviceFile) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + suffix := strings.TrimPrefix(string(old), "/dev/loop") |
| + if suffix == string(old) { |
| + t.Fatal("invalid loop path") |
| + } |
| + if _, err := strconv.ParseUint(suffix, 10, 32); err != nil { |
| + t.Fatal(err) |
| + } |
| + blocker := filepath.Join(servingRebootRoot, "blocker") |
| + create(blocker, 1<<20) |
| + command("losetup", string(old), blocker) |
| + detach(string(old)) |
| + } |
| + device := command("losetup", "--find", "--show", image) |
| + detach(device) |
| + uuid := command("blkid", "-s", "UUID", "-o", "value", device) |
| + if first { |
| + if err := os.WriteFile(deviceFile, []byte(device), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(uuidFile, []byte(uuid), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + old, err := os.ReadFile(deviceFile) |
| + if err != nil || string(old) == device { |
| + t.Fatal("device did not change", err) |
| + } |
| + saved, err := os.ReadFile(uuidFile) |
| + if err != nil || uuid == "" || string(saved) != uuid { |
| + t.Fatal("filesystem UUID changed", err) |
| + } |
| + } |
| + if err := unix.Mount(device, data, "ext4", unix.MS_NOSUID|unix.MS_NODEV, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := unix.Unmount(data, 0); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + var st unix.Statfs_t |
| + if err := unix.Statfs(data, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Logf("grown filesystem: device=%s uuid=%s blocks=%d blocksize=%d", device, uuid, st.Blocks, st.Bsize) |
| + if !first { |
| + if st.Blocks*uint64(st.Bsize) <= 128<<20 { |
| + t.Fatal("grown capacity lost across reboot") |
| + } |
| + return nil |
| + } |
| + return func() { |
| + t.Helper() |
| + if err := os.Truncate(image, 256<<20); err != nil { |
| + t.Fatal(err) |
| + } |
| + command("losetup", "--set-capacity", device) |
| + t.Log(command("resize2fs", device)) |
| + var grown unix.Statfs_t |
| + if err := unix.Statfs(data, &grown); err != nil { |
| + t.Fatal(err) |
| + } |
| + if grown.Blocks <= st.Blocks || grown.Fsid != st.Fsid || command("blkid", "-s", "UUID", "-o", "value", device) != uuid { |
| + t.Fatal("grow changed identity or did not increase capacity") |
| + } |
| + command("sync", "-f", data) |
| + t.Logf("grew filesystem: blocks %d -> %d, filesystem identity unchanged", st.Blocks, grown.Blocks) |
| + } |
| +} |
diff --git a/internal/host/claimservingidentity.go b/internal/host/claimservingidentity.go| index 02a05e675..f14755439 100644 |
| --- a/internal/host/claimservingidentity.go |
| +++ b/internal/host/claimservingidentity.go |
| @@ -7,8 +7,8 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| ) |
| -// Handles identify an inode within its filesystem across boots. Device and |
| -// inode numbers remain required too; these handles never authorize an open. |
| +// Handles identify an inode within its filesystem across boots. Current-boot |
| +// device, mount and inode checks still apply; handles never authorize an open. |
| type servingParkPersistentDirectory struct { |
| Filesystem [2]int32 |
| Type int32 |
diff --git a/internal/host/claimservingpark_rootrunner_linux_test.go b/internal/host/claimservingpark_rootrunner_linux_test.go| index 1d5e2df4b..52ce8dc5e 100644 |
| --- a/internal/host/claimservingpark_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpark_rootrunner_linux_test.go |
| @@ -392,6 +392,10 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) { |
| t.Fatal(err) |
| } |
| data := filepath.Join(servingRebootRoot, "data") |
| + var grow func() |
| + if strings.Contains(t.Name(), "GrownDisk") { |
| + grow = servingRunnerGrownDisk(t, data, true) |
| + } |
| servingRunnerMount(t, data) |
| state, err := rootstate.Open(filepath.Join(servingRebootRoot, "authority"), true, false) |
| if err != nil { |
| @@ -399,6 +403,7 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) { |
| } |
| defer state.Close() |
| r := servingRunnerPark(t, data, state) |
| + servingRunnerTopologyDiagnostic(t, newServingParkFS(data, state)) |
| boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| if err != nil { |
| t.Fatal(err) |
| @@ -406,6 +411,9 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) { |
| if err := (&servingParkLedger{state: state}).write("reboot.json", servingRebootWitness{Boot: strings.TrimSpace(string(boot)), Record: r}); err != nil { |
| t.Fatal(err) |
| } |
| + if grow != nil { |
| + grow() |
| + } |
| } |
| func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| @@ -433,6 +441,9 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| } |
| }) |
| data := filepath.Join(servingRebootRoot, "data") |
| + if strings.Contains(t.Name(), "GrownDisk") { |
| + servingRunnerGrownDisk(t, data, false) |
| + } |
| // Mount IDs can be reused across boots. Add bind layers until this fixture |
| // has a different ID, instead of assuming reboot alone changes the number. |
| fs := newServingParkFS(data, state) |
| @@ -462,10 +473,41 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + servingRunnerTopologyDiagnostic(t, fs) |
| observation, err := fs.inspectRebootTopology(t.Context()) |
| if err != nil || observation.BootID != strings.TrimSpace(string(boot)) || observation.Previous.BootID != saved.Boot || observation.CurrentParent.Mount == observation.Previous.Parent.Mount { |
| t.Fatal("persistent topology inspection after actual reboot", observation, err) |
| } |
| + for _, replacement := range []string{"directory-bind", "filesystem"} { |
| + t.Run(replacement, func(t *testing.T) { |
| + path := filepath.Join(data, "instances", ".serving-parked") |
| + if replacement == "directory-bind" { |
| + other := filepath.Join(data, "replacement") |
| + if err := os.Mkdir(other, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mount(other, path, "", unix.MS_BIND, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if err := unix.Mount("tmpfs", path, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, "size=1m,mode=0700"); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := unix.Unmount(path, 0); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if _, err := fs.inspectRebootTopology(t.Context()); err == nil { |
| + t.Fatal("replacement mount accepted across reboot") |
| + } |
| + if body, err := state.Read("namespace.json"); err != nil || !bytes.Equal(body, before) { |
| + t.Fatal("replacement refusal changed authority", err) |
| + } |
| + }) |
| + } |
| + if _, err := fs.inspectRebootTopology(t.Context()); err != nil { |
| + t.Fatal("original mount after replacement controls", err) |
| + } |
| parentAfter, err := state.Read("namespace-parent.json") |
| if err != nil || !bytes.Equal(parentBefore, parentAfter) { |
| t.Fatal("inspection rewrote parent authority", err) |
| @@ -523,7 +565,7 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| } |
| // The kernel need not recycle the number in this run. Normalize only this |
| - // fixture's saved mount numbers to the actual new mount, leaving the real |
| + // fixture's saved device and mount numbers to the actual new mount, leaving the real |
| // pre-reboot BootID untouched. A mount-number-only guard would now accept it. |
| parent, actual, err := fs.parent() |
| if err != nil { |
| @@ -534,10 +576,11 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| if servingParkDecode(before, &namespace) != nil || namespace.BootID != saved.Boot { |
| t.Fatal("phase 1 did not persist its actual boot") |
| } |
| - if actual.Device != saved.Record.Source.Parent.Device || actual.Inode != saved.Record.Source.Parent.Inode { |
| + if actual.Inode != saved.Record.Source.Parent.Inode { |
| t.Fatal("reboot fixture changed its underlying parent identity") |
| } |
| namespace.Parent.Mount, namespace.Root.Mount = actual.Mount, actual.Mount |
| + namespace.Parent.Device, namespace.Root.Device = actual.Device, actual.Device |
| if err := (&servingParkLedger{state: state}).write("namespace.json", namespace); err != nil { |
| t.Fatal(err) |
| } |
| @@ -545,9 +588,13 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) { |
| r.Authorization.Directory.Mount = actual.Mount |
| r.Source.Directory.Mount, r.Source.Parent.Mount = actual.Mount, actual.Mount |
| r.Event.Mount, r.Namespace.Mount = actual.Mount, actual.Mount |
| + r.Authorization.Directory.Device = actual.Device |
| + r.Source.Directory.Device, r.Source.Parent.Device = actual.Device, actual.Device |
| + r.Event.Device, r.Namespace.Device = actual.Device, actual.Device |
| for i := range r.Source.Files { |
| if r.Source.Files[i].Present { |
| r.Source.Files[i].Mount = actual.Mount |
| + r.Source.Files[i].Device = actual.Device |
| } |
| } |
| before, err = state.Read("namespace.json") |
| @@ -685,6 +732,39 @@ func TestRootRebootServingParkAcceptancePhase2(t *testing.T) { |
| TestRootRebootServingParkChangedMountPhase2(t) |
| } |
| +func servingRunnerTopologyDiagnostic(t *testing.T, fs *servingParkFS) { |
| + t.Helper() |
| + body, err := fs.state.Read("namespace.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Logf("recorded topology: %s", body) |
| + parent, pid, err := fs.parent() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer parent.Close() |
| + root, rid, err := fs.privateDir(parent, ".serving-parked") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer root.Close() |
| + boot, err := fs.currentBoot() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + topology, err := fs.captureTopology(parent, root, servingParkNamespace{Parent: pid, Root: rid, BootID: boot}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Logf("current topology: boot=%s parent=%+v root=%+v persistent=%+v", boot, pid, rid, topology) |
| + out, err := exec.CommandContext(t.Context(), "findmnt", "-n", "-o", "UUID,MAJ:MIN,SOURCE", "-T", filepath.Join(fs.data, "instances")).CombinedOutput() |
| + if err != nil { |
| + t.Fatal(err, string(out)) |
| + } |
| + t.Logf("current filesystem UUID/device/source: %s", out) |
| +} |
| + |
| func TestRootFilesSeedRealOriginPreparation(t *testing.T) { |
| runRootFilesSeedRealOriginPreparation(t, false) |
| } |
diff --git a/internal/host/claimservingreboot_binding.go b/internal/host/claimservingreboot_binding.go| index 977d985d3..23705c011 100644 |
| --- a/internal/host/claimservingreboot_binding.go |
| +++ b/internal/host/claimservingreboot_binding.go |
| @@ -8,7 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| -// A binding changes only boot-local mount numbers. Durable records keep their |
| +// A binding changes only boot-local device and mount numbers. Durable records keep their |
| // original identities; progress writes translate back to those identities. |
| type servingParkBootBinding struct { |
| Version int |
| @@ -25,7 +25,7 @@ func servingParkReadBinding(state rootstate.Store, id string) (*servingParkBootB |
| return nil, err |
| } |
| var b servingParkBootBinding |
| - if servingParkDecode(body, &b) != nil || b.Version != 1 || b.ID != id || !validServingParkBootID(b.BeforeBoot) || !validServingParkBootID(b.BootID) || b.BeforeBoot == b.BootID || b.BeforeParent.Device == 0 || b.BeforeParent.Inode == 0 || b.BeforeParent.Mount == 0 || b.Parent.Device != b.BeforeParent.Device || b.Parent.Inode != b.BeforeParent.Inode || b.Parent.Mount == 0 { |
| + if servingParkDecode(body, &b) != nil || b.Version != 1 || b.ID != id || !validServingParkBootID(b.BeforeBoot) || !validServingParkBootID(b.BootID) || b.BeforeBoot == b.BootID || b.BeforeParent.Device == 0 || b.BeforeParent.Inode == 0 || b.BeforeParent.Mount == 0 || b.Parent.Device == 0 || b.Parent.Inode != b.BeforeParent.Inode || b.Parent.Mount == 0 { |
| return nil, hostbox.ErrChanged |
| } |
| return &b, nil |
| @@ -42,7 +42,7 @@ func (b servingParkBootBinding) directory(d *servingArtifactDirectory, reverse b |
| if d.Device != from.Device || d.Mount != from.Mount { |
| return hostbox.ErrChanged |
| } |
| - d.Mount = to.Mount |
| + d.Device, d.Mount = to.Device, to.Mount |
| return nil |
| } |
| @@ -80,7 +80,7 @@ func (b servingParkBootBinding) record(r *servingParkRecord, reverse bool) error |
| if err := b.directory(&d, reverse); err != nil { |
| return err |
| } |
| - f.Mount = d.Mount |
| + f.Device, f.Mount = d.Device, d.Mount |
| } |
| return nil |
| } |
diff --git a/internal/host/claimservingreboot_linux.go b/internal/host/claimservingreboot_linux.go| index dcb3e6565..5b4ef8d6e 100644 |
| --- a/internal/host/claimservingreboot_linux.go |
| +++ b/internal/host/claimservingreboot_linux.go |
| @@ -87,7 +87,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser |
| return out, err |
| } |
| defer source.Close() |
| - out.Source, err = f.rebootDirectory(source, r.Source.Directory, r.Source.Persistent, pid.Mount) |
| + out.Source, err = f.rebootDirectory(source, r.Source.Directory, r.Source.Persistent, pid) |
| if err != nil { |
| return out, err |
| } |
| @@ -104,7 +104,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser |
| if !r.Prepared || r.EventRemoved { |
| return out, hostbox.ErrChanged |
| } |
| - out.Event, err = f.rebootDirectory(event, r.Event, r.EventPersistent, pid.Mount) |
| + out.Event, err = f.rebootDirectory(event, r.Event, r.EventPersistent, pid) |
| if err != nil || out.Event != eid { |
| return out, hostbox.ErrChanged |
| } |
| @@ -141,7 +141,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser |
| return out, err |
| } |
| } |
| - if !servingParkRebootFileMatches(item, src, dst, pid.Mount, r) { |
| + if !servingParkRebootFileMatches(item, src, dst, pid, r) { |
| return out, hostbox.ErrChanged |
| } |
| } |
| @@ -155,13 +155,13 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser |
| return out, nil |
| } |
| -func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory, proof servingParkPersistentDirectory, mount uint64) (servingArtifactDirectory, error) { |
| +func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory, proof servingParkPersistentDirectory, parent servingArtifactDirectory) (servingArtifactDirectory, error) { |
| var zero servingArtifactDirectory |
| id, err := servingParkDirID(d) |
| if err != nil { |
| return zero, err |
| } |
| - if !proof.valid() || id.Device != old.Device || id.Inode != old.Inode || id.Mount != mount { |
| + if !proof.valid() || id.Device != parent.Device || id.Inode != old.Inode || id.Mount != parent.Mount { |
| return zero, hostbox.ErrChanged |
| } |
| actual, err := f.handle(d) |
| @@ -174,7 +174,7 @@ func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory |
| return id, nil |
| } |
| -func servingParkRebootFileMatches(old, src, dst servingParkFile, mount uint64, r servingParkRecord) bool { |
| +func servingParkRebootFileMatches(old, src, dst servingParkFile, parent servingArtifactDirectory, r servingParkRecord) bool { |
| if !old.Present { |
| return !src.Present && !dst.Present |
| } |
| @@ -182,7 +182,7 @@ func servingParkRebootFileMatches(old, src, dst servingParkFile, mount uint64, r |
| return false |
| } |
| expected := old |
| - expected.Mount = mount |
| + expected.Device, expected.Mount = parent.Device, parent.Mount |
| if old.Deleted || r.EventRemoved { |
| return !src.Present && !dst.Present |
| } |
diff --git a/internal/host/claimservingrenumber_linux_test.go b/internal/host/claimservingrenumber_linux_test.go| new file mode 100644 |
| index 000000000..de5e1aefe |
| --- /dev/null |
| +++ b/internal/host/claimservingrenumber_linux_test.go |
| @@ -0,0 +1,201 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| +) |
| + |
| +// Put the historical records on a different boot-local device number without |
| +// changing any persistent proof or anything in the actual filesystem. |
| +func servingRenumberHistory(t *testing.T, f *servingParkDiskFixture, r *servingParkRecord) { |
| + t.Helper() |
| + old := r.Source.Parent.Device + 123 |
| + for _, d := range []*servingArtifactDirectory{&r.Source.Parent, &r.Source.Directory, &r.Authorization.Directory, &r.Namespace, &r.Event} { |
| + if d.Inode != 0 { |
| + d.Device = old |
| + } |
| + } |
| + for i := range r.Source.Files { |
| + if r.Source.Files[i].Present { |
| + r.Source.Files[i].Device = old |
| + } |
| + } |
| + if err := f.ledger.save(*r); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := f.fs.historicalNamespaceParent() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Parent.Device = old |
| + if err := f.ledger.write("namespace-parent.json", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + var n servingParkNamespace |
| + body, err := f.disk.Read("namespace.json") |
| + if err != nil || servingParkDecode(body, &n) != nil { |
| + t.Fatal(err) |
| + } |
| + n.Parent.Device, n.Root.Device = old, old |
| + if err := f.ledger.write("namespace.json", n); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.Authorization.Legacy { |
| + c, err := f.ledger.legacyConsumption(r.Authorization.Successor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c == nil { |
| + return |
| + } |
| + c.Source, c.Authorization = r.Source, r.Authorization |
| + if err := f.ledger.write(servingLegacyKey("legacy", r.Authorization.Successor), c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestServingParkRebootDeviceRenumber(t *testing.T) { |
| + for _, phase := range []string{"captured", "prepared", "rename-reply", "parked", "complete", "unlink-reply", "partial-unlink", "rmdir-reply", "closed"} { |
| + t.Run(phase, func(t *testing.T) { |
| + f, r := servingRebootRecordFixture(t, phase) |
| + servingRenumberHistory(t, f, &r) |
| + before := servingBootSnapshot(t, f.disk.dir) |
| + got, err := f.fs.inspectRebootRecord(t.Context(), r.Authorization.ID) |
| + if err != nil || got.Topology.CurrentParent.Device == r.Source.Parent.Device || !reflect.DeepEqual(got.Record, r) { |
| + t.Fatal("renumbered-device inspection", got, err) |
| + } |
| + if !reflect.DeepEqual(before, servingBootSnapshot(t, f.disk.dir)) { |
| + t.Fatal("inspection rewrote history") |
| + } |
| + if err := f.fs.CheckParked(&r); err == nil { |
| + t.Fatal("inspection granted old-boot authority") |
| + } |
| + if phase == "captured" || phase == "prepared" || phase == "rename-reply" { |
| + return |
| + } |
| + var active *servingParkRebootAuthority |
| + if !r.Complete { |
| + a := r.activeAuthorization() |
| + active = &servingParkRebootAuthority{ClaimETag: "fixture", Actor: hostbox.Actor{Box: a.Successor.Box, Region: a.Successor.Region, Enrollment: "fixture", Revision: 1, Eligible: true}, Directory: got.Source, Tags: a.TagsFrom} |
| + } |
| + if err := f.ledger.prepareRebootLocked(t.Context(), r, active); err != nil { |
| + t.Fatal("prepare", err) |
| + } |
| + if err := f.ledger.commitRebootLocked(t.Context(), r, active); err != nil { |
| + t.Fatal("commit", err) |
| + } |
| + current, err := f.ledger.read(r.Authorization.ID) |
| + if err != nil || current.Source.Parent.Device != got.Topology.CurrentParent.Device { |
| + t.Fatal("current device", current, err) |
| + } |
| + if err := f.ledger.save(current); err != nil { |
| + t.Fatal("reverse binding", err) |
| + } |
| + raw, err := f.ledger.readHistorical(r.Authorization.ID) |
| + if err != nil || !reflect.DeepEqual(raw, r) { |
| + t.Fatal("round trip changed history", raw, err) |
| + } |
| + if r.Parked && !r.Deleting { |
| + if err := f.fs.CheckParked(¤t); !r.Complete && err != nil { |
| + t.Fatal("bound park", err) |
| + } |
| + if r.Complete { |
| + f.now = current.ReapAfter |
| + if err := f.ledger.reapLocked(t.Context(), r.Authorization.ID); err != nil { |
| + t.Fatal("reap", err) |
| + } |
| + if _, err := os.Stat(f.fs.data + "/instances/.serving-parked/" + r.Authorization.ID); !os.IsNotExist(err) { |
| + t.Fatal("event survived reap", err) |
| + } |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkRebootDeviceRenumberRefusesReplacement(t *testing.T) { |
| + for _, fault := range []string{"filesystem", "root-handle", "source-handle", "event-handle", "file-handle", "inode", "same-boot"} { |
| + t.Run(fault, func(t *testing.T) { |
| + f, r := servingRebootRecordFixture(t, "parked") |
| + servingRenumberHistory(t, f, &r) |
| + var n servingParkNamespace |
| + body, err := f.disk.Read("namespace.json") |
| + if err != nil || servingParkDecode(body, &n) != nil { |
| + t.Fatal(err) |
| + } |
| + switch fault { |
| + case "filesystem": |
| + n.Topology.Parent.Filesystem[0]++ |
| + n.Topology.Root.Filesystem = n.Topology.Parent.Filesystem |
| + case "root-handle": |
| + n.Topology.Root.Handle = "aa" |
| + case "source-handle": |
| + r.Source.Persistent.Handle = "aa" |
| + case "event-handle": |
| + r.EventPersistent.Handle = "aa" |
| + case "file-handle": |
| + r.Source.Files[0].Persistent.Handle = "aa" |
| + case "inode": |
| + r.Source.Directory.Inode++ |
| + r.Authorization.Directory = r.Source.Directory |
| + case "same-boot": |
| + f.fs.boot = func() (string, error) { return r.Source.BootID, nil } |
| + } |
| + if err := f.ledger.save(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.ledger.write("namespace.json", n); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := servingBootSnapshot(t, f.disk.dir) |
| + if _, err := f.fs.inspectRebootRecord(t.Context(), r.Authorization.ID); err == nil { |
| + t.Fatal("renumbering accepted replaced identity") |
| + } |
| + if !reflect.DeepEqual(before, servingBootSnapshot(t, f.disk.dir)) { |
| + t.Fatal("refusal changed authority") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkRebootDeviceRenumberLostWrite(t *testing.T) { |
| + for _, before := range []bool{true, false} { |
| + for at := 1; at <= 3; at++ { |
| + f, r := servingRebootRecordFixture(t, "complete") |
| + servingRenumberHistory(t, f, &r) |
| + fault := &servingParkFaultStore{Store: f.disk, failAt: at, before: before} |
| + f.ledger.state, f.fs.state = fault, fault |
| + root := &servingParkRoot{ledger: f.ledger} |
| + err := root.commitServingReboot(t.Context(), r.Authorization.ID) |
| + if !errors.Is(err, os.ErrPermission) { |
| + t.Fatalf("%v/%d: missing write fault: %v", before, at, err) |
| + } |
| + f.ledger.state, f.fs.state = f.disk, f.disk |
| + if err := root.commitServingReboot(t.Context(), r.Authorization.ID); err != nil { |
| + t.Fatal("commit replay", err) |
| + } |
| + if err := f.fs.recoverRecordedNamespace(t.Context()); err != nil { |
| + t.Fatal("namespace recovery", err) |
| + } |
| + current, err := f.ledger.read(r.Authorization.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.now = current.ReapAfter |
| + if err := f.ledger.reapLocked(t.Context(), r.Authorization.ID); err != nil { |
| + t.Fatal("reap after lost reply", err) |
| + } |
| + raw, err := f.ledger.readHistorical(r.Authorization.ID) |
| + if err != nil || raw.Source.BootID != r.Source.BootID || raw.Source.Parent != r.Source.Parent || !raw.Closed { |
| + t.Fatal("history after recovery", raw, err) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimservingtopology_linux.go b/internal/host/claimservingtopology_linux.go| index a463ea951..d223689d6 100644 |
| --- a/internal/host/claimservingtopology_linux.go |
| +++ b/internal/host/claimservingtopology_linux.go |
| @@ -141,9 +141,10 @@ func (f *servingParkFS) inspectRebootTopology(ctx context.Context) (servingParkR |
| return zero, err |
| } |
| defer root.Close() |
| - // Device renumbering remains a refusal. Persistent evidence is additional |
| - // to the original inode/device checks, never a reason to drop them. |
| - if pid.Device != n.Parent.Device || pid.Inode != n.Parent.Inode || rid.Device != n.Root.Device || rid.Inode != n.Root.Inode || rid.Mount != pid.Mount { |
| + // Device and mount numbers are boot-local. Across boots, require the same |
| + // filesystem and persistent handles below, plus unchanged inode numbers. |
| + // Both directories must still share the current device and mount. |
| + if pid.Inode != n.Parent.Inode || rid.Device != pid.Device || rid.Inode != n.Root.Inode || rid.Mount != pid.Mount { |
| return zero, hostbox.ErrChanged |
| } |
| current := servingParkNamespace{Version: 1, Parent: pid, Root: rid, BootID: boot} |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index a0c1f85a7..e41d97382 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -237,18 +237,18 @@ internal/host/claimservingquarantine_linux.go servingParkQuarantine.name authori |
| internal/host/claimservingquarantine_linux.go servingParkQuarantine.unrecordedName authority C,J4 rev3=PROPOSED-seed; fixed retention basename from the existing private quarantine nonce; no caller path or new nonce on retry e037771d8e939a426c26104c10b92a2b205376d14574a92015f6f5a4bff90375 |
| internal/host/claimservingquarantine_linux.go servingParkQuarantine.validate authority C,J4 rev3=PROPOSED-seed; private quarantine shape and phase checks; original, prior, unrecorded and fresh directories must have distinct same-mount identities; retention only; prior namespace and quarantine must carry the same boot scope 285f4c277e15af5001ff7d69723d1ff84a6137c8d08bedf5a7c8580d2e246398 |
| internal/host/claimservingquarantine_linux.go type servingParkQuarantine authority C,J4 rev3=PROPOSED-seed; private versioned quarantine intent; additive unrecorded-replacement identity and rename progress bounded to one spare; no artifact deletion authority; additive BootID scopes parent, obstruction, prior root and replacement identities 602321aa523aa4d4b9bade287b70abf2c86f4daeb6bb245bc7f5412b688a1680 |
| -internal/host/claimservingreboot_binding.go servingParkBootBinding.directory authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 791c49920c31e1db6826f6d285731669226f12dd0f277ce04457630b6df014d7 |
| -internal/host/claimservingreboot_binding.go servingParkBootBinding.record authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 0ad6e9a4a807af142d647ed5edfbd772a32dcf9da5f3eaffeafaab019a677b57 |
| -internal/host/claimservingreboot_binding.go servingParkLedger.bindRecord authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved e98b72dbd294f4a0b57bad42a2d6b3a429d56518db56dbdf36405529130aa513 |
| -internal/host/claimservingreboot_binding.go servingParkReadBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved file:Read@111 1b4ea3111bdadb39201c67fe3ba5bc5505ef5f7da2a3f9beb485dbe8bc72708e |
| -internal/host/claimservingreboot_binding.go type servingParkBootBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 4ecb75e5992c97d9d0445210689aa1f659f4c1fb8c8dd81ba4b84bd639f7fee7 |
| +internal/host/claimservingreboot_binding.go servingParkBootBinding.directory authority C,J4 rev3=PROPOSED-seed; exact from-device/from-mount required before mapping both to independently committed current values; reverse mapping preserves historical identities 8df696c84f41f4ccbd77271b6a9ce6d0237d661f2db5fe1b40e9a0e0d5f98825 |
| +internal/host/claimservingreboot_binding.go servingParkBootBinding.record authority C,J4 rev3=PROPOSED-seed; maps all directories, continuation authorizations and present artifacts through private reboot binding; reverse save preserves historical device, mount and boot e72d4c59265421e3cef5f31692029bee46f63638f8c72401422731e550e15763 |
| +internal/host/claimservingreboot_binding.go servingParkLedger.bindRecord authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating boot-local device and mount identities; historical retirement/legacy bytes preserved e98b72dbd294f4a0b57bad42a2d6b3a429d56518db56dbdf36405529130aa513 |
| +internal/host/claimservingreboot_binding.go servingParkReadBinding authority C,J4 rev3=PROPOSED-seed; private committed reboot binding; nonzero boot-local device/mount on both sides, unchanged parent inode and distinct boots; historical bytes unchanged file:Read@111 8a748537b12e8d72b42c07cda845509bb334a3552ae2735205f52e7d6fbafd9a |
| +internal/host/claimservingreboot_binding.go type servingParkBootBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating boot-local device and mount identities; historical retirement/legacy bytes preserved 4ecb75e5992c97d9d0445210689aa1f659f4c1fb8c8dd81ba4b84bd639f7fee7 |
| internal/host/claimservingreboot_commit_linux.go servingParkFS.readNamespace authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses file:Read@114 d92accaa928f703132adef9aa1e81cae95a7bfa959c7a6fe5086189154a7c7f1 |
| internal/host/claimservingreboot_commit_linux.go servingParkLedger.commitRebootLocked authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses file:Read@246 d711de74b486ec757cbdac7aacb28e4414d47ba740082f8930857774b51d048a |
| internal/host/claimservingreboot_commit_linux.go servingParkRoot.commitServingReboot authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses 6d511474a2112ea80f5457b0faa939da7144fb7bf72526e021e696ad90a0917b |
| internal/host/claimservingreboot_linux.go servingParkFS.inspectRebootRecord authority C,J4 rev3=PROPOSED-seed; repeated read-only per-retirement inspection under namespace lock; unchanged historical record and topology; result is not mutation or agent authority baacf7e1df1d8fd4a09a5f6204597ab51b8765feca9c614ddc74d863a3cf9055 |
| -internal/host/claimservingreboot_linux.go servingParkFS.observeRebootRecord authority C,J4 rev3=PROPOSED-seed; immutable historical records and live persistent source/park identities; refuses inconsistent boot, mount, filesystem or ownership file:Readdirnames@2515 48a675389be8a8184e428c50971ddb8b7e8c33f8ceccef3703858fc307415053 |
| -internal/host/claimservingreboot_linux.go servingParkFS.rebootDirectory authority C,J4 rev3=PROPOSED-seed; pinned descriptor device/inode, current mount and historical filesystem handle, owner and mode; no effects 2224be87510731990ca620abc0624a80675eca21228e561fa3e540d2ac8273c0 |
| -internal/host/claimservingreboot_linux.go servingParkRebootFileMatches authority C,J4 rev3=PROPOSED-seed; historical file handle and current mount compared to source/event observations; lost rename and unlink replies require original progress; no writes 180ec916768fd2d012e689199963586dabbaa77553cea9d806882d1eacb15a75 |
| +internal/host/claimservingreboot_linux.go servingParkFS.observeRebootRecord authority C,J4 rev3=PROPOSED-seed; repeated topology and immutable retirement read; source/event/artifact proof uses the independently observed current device and mount; no historical rewrite file:Readdirnames@2503 01bbe5c19696cae8a43e5a80c960c12b69b5fbc1defdfa597d432a6eb4df6682 |
| +internal/host/claimservingreboot_linux.go servingParkFS.rebootDirectory authority C,J4 rev3=PROPOSED-seed; descriptor has historical inode, filesystem handle, owner and mode and current parent device/mount; no effects cc4eed6e9b2ff80f01f29c1933cc7238e01ac515ba85005c0c07a69234053df7 |
| +internal/host/claimservingreboot_linux.go servingParkRebootFileMatches authority C,J4 rev3=PROPOSED-seed; historical artifact handle and inode plus current parent device/mount; preserves single-link, size, ownership and progress checks 73a12fe037864c8111b4fd91e4cba824d307f49a6cb0fcb2723141bb34dcdcbd |
| internal/host/claimservingreboot_linux.go type servingParkRebootRecord authority C,J4 rev3=PROPOSED-seed; read-only historical retirement and current source/event/namespace observations; has no effect or transport consumer 2752876f2e40f92a720c17c05e479dbfc7c4910392a1e6dc5ba4dc82b566763c |
| internal/host/claimservingreboot_prepare_linux.go servingParkLedger.observeRebootPreparation authority C,J4 rev3=PROPOSED-seed; persistent source/event/file inspection, original retirement, validated slot references and exact permanent legacy consumption; current installation binds incomplete records; malformed or stale evidence refuses 252a5c4463f979e5442585b66d06453e279f45ac17fe4f0ff993df7dee678da6 |
| internal/host/claimservingreboot_prepare_linux.go servingParkLedger.prepareRebootLocked authority C,J4 rev3=PROPOSED-seed; historical retirement, fresh root installation, census, exact claim, stop and launch history; per-target-boot immutable preparation, repeated validation file:Read@625 a355b666f2aad99389f1c665a57a9b1603db5511538c68b64433bbde8beda0fb |
| @@ -274,7 +274,7 @@ internal/host/claimservingretire.go servingPredecessor authority C,J4 rev3=PROPO |
| internal/host/claimservingretire.go servingRetirementID authority C,J4 rev3=PROPOSED-seed; immutable predecessor and original successor tuples; deterministic journal identity independent of claim operation IDs c984974d17e621d7fe9c276a03ae789b86789abbccbb82a09d06f55c3bf13819 |
| internal/host/claimservingretire.go servingWriterIdentity authority C,J4 rev3=PROPOSED-seed; authoritative serving holder and unique cold admission; exact local executor, stamp, session, generation, incarnation, location and region file:Exec@403 f7d8c868ce9856eab448aa72bdf20ee02cb70179ce0f435d45888f72e0c3b7bf |
| internal/host/claimservingtopology_linux.go servingParkFS.captureTopology authority C,J4 rev3=PROPOSED-seed; current boot and pinned parent/root descriptors; persistent filesystem identities, handles, owner and mode; rereads descriptor identities and boot before returning creation evidence 169ff4183b9d65770c49aeaa341ad061ee38105e8cdea4ccfb5ee3f57079ec20 |
| -internal/host/claimservingtopology_linux.go servingParkFS.inspectRebootTopology authority C,J4 rev3=PROPOSED-seed; historical namespace parent and persistent filesystem handles; mount numbers alone never establish reboot identity file:Read@501,file:Read@956 d17c8bec59bd873dcf72479b066dd715dc1f329011af3f36e9c43d7291553549 |
| +internal/host/claimservingtopology_linux.go servingParkFS.inspectRebootTopology authority C,J4 rev3=PROPOSED-seed; same persistent filesystem and parent/root handles and inodes across boots; current device/mount must agree; reopen names and recheck boot; read-only file:Read@501,file:Read@956 1e1584cd4a0456f050209e46029d6a58e973d7af39f47a47ada75f0927a24a96 |
| internal/host/claimservingtopology_linux.go servingParkPersistentDir authority C,J4 rev3=PROPOSED-seed; descriptor-only Fstat, Fstatfs and NameToHandleAt with AT_EMPTY_PATH; bounded canonical handle and matching statx mount, mode and owner; no caller path, handle opening or permission widening file:Fstat@279,file:Fstatfs@375 2c60437509897dc3d7e861bf554a92cd5c338301b3bfbdee6b5d731ea9d3f7b8 |
| internal/host/claimservingtopology_linux.go type servingParkRebootObservation authority C,J4 rev3=PROPOSED-seed; read-only old namespace and current boot/topology observation; no effect consumer or transport result accepts it e000b7d132c36a4979c5be443fc87996beafe2a8db320bf978ef1f026a09baa2 |
| internal/host/claimservingtopology_linux.go type servingParkTopology authority C,J4 rev3=PROPOSED-seed; persistent parent/root evidence attached only when root creates and records a new namespace 16a7fce2850bf204ddd67aa10876979be08a5d500a21ea3072e2fc5c1c369586 |
| @@ -325,7 +325,7 @@ system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, |
| system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins ee25513ed72f9b8338d95682c4c76b4ced26ec1f060528fc5804da9afbfbfd83 |
| @coverage . bf3d70b6a2c5d769a3c580c9d5511231499b788d9c08a542895de8381e5a39c9 |
| @coverage internal/activator 41f0ad01ac54bae63f089ef8ec5bbfa9155bb76720d72b2d88563481de40fe5c |
| -@coverage internal/host 8872d86cde27a46eec09390c6bf7a35dfd977e4afca9825f2a32b068f420f038 |
| +@coverage internal/host dd140d90fe80775bf62b1b5a9992c1f399731781c9ec38afc237d285f9388425 |
| @coverage internal/hostbox db890260533bdace6ad9a7c23dc1538cb919962894f005fc7c5a59a18e969429 |
| @coverage internal/hostfiles a38dbcd9789ba4acd237e88c834f2b2bfca9847b0d43c32b0116dc8996d7a142 |
| @coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6 |