WIP files seed checkpoint 3 (not for merge): filesystem parking and durable retirement mechanisms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+1951
−3
internal/host/claimservingpark_linux.go+535 −0internal/host/claimservingpark_linux_test.go+708 −0internal/host/claimservingpark_other.go+20 −0internal/host/claimservingpark_records.go+491 −0internal/host/claimservingparksource_linux.go+73 −0internal/host/claimservingparksource_linux_test.go+83 −0internal/provision/plan9_b8_census_test.go+3 −0internal/provision/testdata/plan14-root-reads-seed.tsv+38 −3
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 358.286s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 4.452s
ok github.com/carlosframework/platform/cmd/moveprobe 94.086s
ok github.com/carlosframework/platform/cmd/zddprobe 0.775s
ok github.com/carlosframework/platform/internal/activator 235.167s
ok github.com/carlosframework/platform/internal/agentsock 0.031s
ok github.com/carlosframework/platform/internal/alerting 0.011s
ok github.com/carlosframework/platform/internal/analytics 0.028s
ok github.com/carlosframework/platform/internal/appconfig 2.989s
ok github.com/carlosframework/platform/internal/apperr 0.120s
ok github.com/carlosframework/platform/internal/applog 0.424s
ok github.com/carlosframework/platform/internal/awsinv 0.014s
ok github.com/carlosframework/platform/internal/boxchoice 0.015s
ok github.com/carlosframework/platform/internal/boxid 1.174s
? 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.011s
ok github.com/carlosframework/platform/internal/caps 0.030s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.780s
ok github.com/carlosframework/platform/internal/changelog 0.372s
ok github.com/carlosframework/platform/internal/clamd 0.062s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 368.602s
? 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.248s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.015s
ok github.com/carlosframework/platform/internal/dnsrec 0.008s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 2.332s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 28.199s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.019s
ok github.com/carlosframework/platform/internal/fleetapi 0.143s
ok github.com/carlosframework/platform/internal/gate 1.770s
ok github.com/carlosframework/platform/internal/genselect 0.018s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.036s
ok github.com/carlosframework/platform/internal/host 132.609s
ok github.com/carlosframework/platform/internal/hostactors 0.953s
ok github.com/carlosframework/platform/internal/hostbox 3.246s
ok github.com/carlosframework/platform/internal/hostclaim 1.207s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 8.146s
ok github.com/carlosframework/platform/internal/hostfence 0.047s
ok github.com/carlosframework/platform/internal/hostfiles 11.818s
ok github.com/carlosframework/platform/internal/hostfloor 0.030s
ok github.com/carlosframework/platform/internal/hostguard 1.123s
ok github.com/carlosframework/platform/internal/hostops 2.872s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 1.121s
ok github.com/carlosframework/platform/internal/hostresolve 0.275s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 1.839s
ok github.com/carlosframework/platform/internal/instanceexec 2.971s
ok github.com/carlosframework/platform/internal/instancefs 0.013s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.010s
ok github.com/carlosframework/platform/internal/layout 2.370s
ok github.com/carlosframework/platform/internal/lease 9.650s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.013s
ok github.com/carlosframework/platform/internal/ledger 0.031s
ok github.com/carlosframework/platform/internal/mail 0.326s
ok github.com/carlosframework/platform/internal/maintenance 0.143s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.888s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.179s
ok github.com/carlosframework/platform/internal/metrics 0.018s
ok github.com/carlosframework/platform/internal/moves 23.545s
ok github.com/carlosframework/platform/internal/moves/archive 1.367s
ok github.com/carlosframework/platform/internal/objstore 0.348s
ok github.com/carlosframework/platform/internal/ops 0.222s
ok github.com/carlosframework/platform/internal/park 0.502s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 18.324s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.202s
ok github.com/carlosframework/platform/internal/relay 7.255s
ok github.com/carlosframework/platform/internal/release 23.130s
ok github.com/carlosframework/platform/internal/rollout 5.155s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.015s
ok github.com/carlosframework/platform/internal/rootparticipation 0.019s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.015s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 3.001s
ok github.com/carlosframework/platform/internal/sim/hostlife 16.346s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.662s
ok github.com/carlosframework/platform/internal/status 0.104s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.260s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.009s
ok github.com/carlosframework/platform/internal/tunnelclient 0.010s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.892s
=== 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
--- 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, 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.024s
=== RUN TestT4StorageProbeMinio
2026/10/14 07:03:38 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE536A5DBFF24A, 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/claimservingpark_linux.go b/internal/host/claimservingpark_linux.go| new file mode 100644 |
| index 000000000..ddb47bd02 |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_linux.go |
| @@ -0,0 +1,535 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type servingParkNamespace struct { |
| + Version int |
| + Parent, Root servingArtifactDirectory |
| +} |
| + |
| +type servingParkFS struct { |
| + data string |
| + state rootstate.Store |
| + owner uint32 |
| + mount func(string) (bool, error) |
| + rename func(int, string, int, string, uint) error |
| + sync func(int) error |
| + unlink func(int, string, int) error |
| +} |
| + |
| +// Production construction always requires root and a nosymfollow mount. Tests |
| +// use the private fields to exercise filesystems without mount privileges. |
| +func newServingParkFS(data string, state rootstate.Store) *servingParkFS { |
| + return &servingParkFS{data: data, state: state, owner: 0, mount: instancefs.MountNoSymfollow, rename: unix.Renameat2, sync: unix.Fsync, unlink: unix.Unlinkat} |
| +} |
| + |
| +func servingParkDirID(f *os.File) (servingArtifactDirectory, error) { |
| + var st unix.Statx_t |
| + if err := unix.Statx(int(f.Fd()), "", unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW, unix.STATX_BASIC_STATS|unix.STATX_MNT_ID, &st); err != nil { |
| + return servingArtifactDirectory{}, err |
| + } |
| + if st.Mask&unix.STATX_MNT_ID == 0 || st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Ino == 0 || st.Mnt_id == 0 { |
| + return servingArtifactDirectory{}, hostbox.ErrChanged |
| + } |
| + return servingArtifactDirectory{Device: unix.Mkdev(st.Dev_major, st.Dev_minor), Inode: st.Ino, Mount: st.Mnt_id}, nil |
| +} |
| + |
| +func (f *servingParkFS) parent() (*os.File, servingArtifactDirectory, error) { |
| + var zero servingArtifactDirectory |
| + if f == nil || f.state == nil || f.mount == nil || f.rename == nil || f.sync == nil || f.unlink == nil || !filepath.IsAbs(f.data) || filepath.Clean(f.data) != f.data || uint32(os.Geteuid()) != f.owner { |
| + return nil, zero, hostbox.ErrUnsupported |
| + } |
| + path := filepath.Join(f.data, "instances") |
| + p, err := instancefs.OpenDirBeneath(path, ".") |
| + if err != nil { |
| + return nil, zero, err |
| + } |
| + id, err := servingParkDirID(p) |
| + if err != nil { |
| + p.Close() |
| + return nil, zero, err |
| + } |
| + ok, err := f.mount(path) |
| + if err != nil || !ok { |
| + p.Close() |
| + return nil, zero, hostbox.ErrChanged |
| + } |
| + // The pathname's effective mount must still name the pinned descriptor. |
| + again, err := instancefs.OpenDirBeneath(path, ".") |
| + if err != nil { |
| + p.Close() |
| + return nil, zero, err |
| + } |
| + next, err := servingParkDirID(again) |
| + again.Close() |
| + if err != nil || next != id { |
| + p.Close() |
| + return nil, zero, hostbox.ErrChanged |
| + } |
| + return p, id, nil |
| +} |
| + |
| +func servingParkOpenDir(parent *os.File, name string) (*os.File, error) { |
| + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") { |
| + return nil, hostbox.ErrChanged |
| + } |
| + fd, err := unix.Openat(int(parent.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return os.NewFile(uintptr(fd), name), nil |
| +} |
| + |
| +func (f *servingParkFS) privateDir(parent *os.File, name string) (*os.File, servingArtifactDirectory, error) { |
| + var zero servingArtifactDirectory |
| + d, err := servingParkOpenDir(parent, name) |
| + if err != nil { |
| + return nil, zero, err |
| + } |
| + var st unix.Stat_t |
| + err = unix.Fstat(int(d.Fd()), &st) |
| + if err != nil || st.Uid != f.owner || st.Mode&0077 != 0 { |
| + d.Close() |
| + return nil, zero, hostbox.ErrChanged |
| + } |
| + id, err := servingParkDirID(d) |
| + if err != nil { |
| + d.Close() |
| + return nil, zero, err |
| + } |
| + return d, id, nil |
| +} |
| + |
| +// namespace refuses every unrecorded obstruction. Quarantine recovery must be |
| +// completed before a production caller can recover an unknown park root. |
| +func (f *servingParkFS) namespace() (*os.File, servingParkNamespace, error) { |
| + var n servingParkNamespace |
| + parent, id, err := f.parent() |
| + if err != nil { |
| + return nil, n, err |
| + } |
| + defer parent.Close() |
| + b, err := f.state.Read("namespace.json") |
| + if errors.Is(err, os.ErrNotExist) { |
| + // EEXIST is deliberately not adoption authority for a service-owned name. |
| + if err = unix.Mkdirat(int(parent.Fd()), ".serving-parked", 0700); err != nil { |
| + return nil, n, err |
| + } |
| + d, rid, e := f.privateDir(parent, ".serving-parked") |
| + if e != nil { |
| + return nil, n, e |
| + } |
| + if rid.Device != id.Device || rid.Mount != id.Mount { |
| + d.Close() |
| + return nil, n, hostbox.ErrChanged |
| + } |
| + n = servingParkNamespace{Version: 1, Parent: id, Root: rid} |
| + if e = f.sync(int(parent.Fd())); e == nil { |
| + l := servingParkLedger{state: f.state} |
| + e = l.write("namespace.json", n) |
| + } |
| + if e != nil { |
| + d.Close() |
| + return nil, n, e |
| + } |
| + return d, n, nil |
| + } |
| + if err != nil { |
| + return nil, n, err |
| + } |
| + if servingParkDecode(b, &n) != nil || n.Version != 1 || n.Parent != id { |
| + return nil, n, hostbox.ErrChanged |
| + } |
| + d, rid, err := f.privateDir(parent, ".serving-parked") |
| + if err != nil { |
| + return nil, n, err |
| + } |
| + if rid != n.Root || rid.Device != id.Device || rid.Mount != id.Mount { |
| + d.Close() |
| + return nil, n, hostbox.ErrChanged |
| + } |
| + return d, n, nil |
| +} |
| + |
| +func (f *servingParkFS) source(a servingParkAuthorization) (*os.File, servingArtifactDirectory, error) { |
| + var zero servingArtifactDirectory |
| + host := a.Successor.Host |
| + if host == "" || host == "." || host == ".." || strings.ContainsAny(host, "/\\\x00") || len(host)+7 > 255 { |
| + return nil, zero, hostbox.ErrChanged |
| + } |
| + p, pid, err := f.parent() |
| + if err != nil { |
| + return nil, zero, err |
| + } |
| + defer p.Close() |
| + d, err := servingParkOpenDir(p, provision.InstanceID(host)) |
| + if err != nil { |
| + return nil, zero, err |
| + } |
| + id, err := servingParkDirID(d) |
| + if err != nil || id != a.Directory || id.Device != pid.Device || id.Mount != pid.Mount { |
| + d.Close() |
| + return nil, zero, hostbox.ErrChanged |
| + } |
| + return d, pid, nil |
| +} |
| + |
| +func servingParkStat(parent *os.File, name string) (servingParkFile, error) { |
| + out := servingParkFile{Name: name} |
| + var st unix.Statx_t |
| + err := unix.Statx(int(parent.Fd()), name, unix.AT_SYMLINK_NOFOLLOW, unix.STATX_BASIC_STATS|unix.STATX_MNT_ID, &st) |
| + if errors.Is(err, unix.ENOENT) { |
| + return out, nil |
| + } |
| + if err != nil { |
| + return out, err |
| + } |
| + if st.Mask&unix.STATX_MNT_ID == 0 || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Ino == 0 { |
| + return out, hostbox.ErrChanged |
| + } |
| + // Allocated bytes are the upper bound reclaimable by removing this link; |
| + // pressure callers must still remeasure f_bavail after deleting it. |
| + if st.Blocks > ^uint64(0)/512 { |
| + return out, hostbox.ErrChanged |
| + } |
| + out.Present, out.Device, out.Inode, out.Mount, out.Bytes, out.Mode, out.Links = true, unix.Mkdev(st.Dev_major, st.Dev_minor), st.Ino, st.Mnt_id, st.Blocks*512, uint32(st.Mode)&unix.S_IFMT, st.Nlink |
| + return out, nil |
| +} |
| + |
| +func sameServingParkFile(a, b servingParkFile) bool { |
| + return a.Name == b.Name && a.Present == b.Present && a.Device == b.Device && a.Inode == b.Inode && a.Mount == b.Mount && a.Mode == b.Mode && a.Links == b.Links && a.Bytes == b.Bytes |
| +} |
| + |
| +func (f *servingParkFS) Capture(a servingParkAuthorization) (servingParkSource, error) { |
| + var out servingParkSource |
| + source, parent, err := f.source(a) |
| + if err != nil { |
| + return out, err |
| + } |
| + defer source.Close() |
| + out.Directory, out.Parent = a.Directory, parent |
| + for i, suffix := range []string{"", ".gen", "-wal", "-shm"} { |
| + file, err := servingParkStat(source, a.Successor.Host+".db"+suffix) |
| + if err != nil { |
| + return out, err |
| + } |
| + if file.Present && (file.Device != parent.Device || file.Mount != parent.Mount) { |
| + return out, hostbox.ErrChanged |
| + } |
| + out.Files[i] = file |
| + } |
| + marker := out.Files[1] |
| + if marker.Present { |
| + if a.Legacy { |
| + return out, hostbox.ErrChanged |
| + } |
| + fd, err := unix.Openat(int(source.Fd()), marker.Name, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return out, err |
| + } |
| + mf := os.NewFile(uintptr(fd), marker.Name) |
| + var st unix.Stat_t |
| + if err = unix.Fstat(fd, &st); err != nil || uint64(st.Dev) != marker.Device || st.Ino != marker.Inode || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + mf.Close() |
| + return out, hostbox.ErrChanged |
| + } |
| + body, err := io.ReadAll(io.LimitReader(mf, 4097)) |
| + mf.Close() |
| + if err != nil || len(body) > 4096 || strings.TrimSpace(string(body)) != a.Predecessor.Generation { |
| + return out, hostbox.ErrChanged |
| + } |
| + again, err := servingParkStat(source, marker.Name) |
| + if err != nil || !sameServingParkFile(marker, again) { |
| + return out, hostbox.ErrChanged |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +func (f *servingParkFS) event(r *servingParkRecord) (*os.File, *os.File, error) { |
| + root, n, err := f.namespace() |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if n.Parent != r.Source.Parent { |
| + root.Close() |
| + return nil, nil, hostbox.ErrChanged |
| + } |
| + event, id, err := f.privateDir(root, r.Authorization.ID) |
| + if err != nil { |
| + root.Close() |
| + return nil, nil, err |
| + } |
| + if !r.Prepared || id != r.Event || id.Device != n.Root.Device || id.Mount != n.Root.Mount { |
| + root.Close() |
| + event.Close() |
| + return nil, nil, hostbox.ErrChanged |
| + } |
| + return root, event, nil |
| +} |
| + |
| +func (f *servingParkFS) Prepare(r *servingParkRecord, save func() error) error { |
| + if r.Prepared { |
| + root, event, err := f.event(r) |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + defer event.Close() |
| + return f.sync(int(root.Fd())) |
| + } |
| + root, n, err := f.namespace() |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + if n.Parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + if err = unix.Mkdirat(int(root.Fd()), r.Authorization.ID, 0700); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return err |
| + } |
| + event, id, err := f.privateDir(root, r.Authorization.ID) |
| + if err != nil { |
| + return err |
| + } |
| + defer event.Close() |
| + if id.Device != n.Root.Device || id.Mount != n.Root.Mount { |
| + return hostbox.ErrChanged |
| + } |
| + // An event exists only under a recorded root-private namespace and a durable |
| + // intent. Before its identity is recorded it must be entirely empty. |
| + names, err := event.Readdirnames(1) |
| + if err != nil && !errors.Is(err, io.EOF) { |
| + return err |
| + } |
| + if len(names) != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + if err = f.sync(int(root.Fd())); err != nil { |
| + return err |
| + } |
| + r.Event, r.Prepared = id, true |
| + return save() |
| +} |
| + |
| +func (f *servingParkFS) Move(r *servingParkRecord, save func() error) error { |
| + root, event, err := f.event(r) |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + defer event.Close() |
| + source, parent, err := f.source(r.Authorization) |
| + if err != nil { |
| + return err |
| + } |
| + defer source.Close() |
| + if parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + // Check the complete set before moving any name. |
| + for _, recorded := range r.Source.Files { |
| + src, e := servingParkStat(source, recorded.Name) |
| + if e != nil { |
| + return e |
| + } |
| + dst, e := servingParkStat(event, recorded.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if !recorded.Present { |
| + if src.Present || dst.Present { |
| + return hostbox.ErrChanged |
| + } |
| + } else if recorded.Moved { |
| + if src.Present || !sameServingParkFile(recorded, dst) { |
| + return hostbox.ErrChanged |
| + } |
| + } else if !((sameServingParkFile(recorded, src) && !dst.Present) || (!src.Present && sameServingParkFile(recorded, dst))) { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + for i := range r.Source.Files { |
| + item := &r.Source.Files[i] |
| + if !item.Present { |
| + continue |
| + } |
| + src, e := servingParkStat(source, item.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if src.Present { |
| + if item.Moved || !sameServingParkFile(*item, src) { |
| + return hostbox.ErrChanged |
| + } |
| + if e = f.rename(int(source.Fd()), item.Name, int(event.Fd()), item.Name, unix.RENAME_NOREPLACE); e != nil { |
| + return e |
| + } |
| + } |
| + // A replacement raced into rename may be retained here, never deleted. |
| + dst, e := servingParkStat(event, item.Name) |
| + if e != nil || !sameServingParkFile(*item, dst) { |
| + return hostbox.ErrChanged |
| + } |
| + if e = f.sync(int(source.Fd())); e != nil { |
| + return e |
| + } |
| + if e = f.sync(int(event.Fd())); e != nil { |
| + return e |
| + } |
| + if !item.Moved { |
| + item.Moved = true |
| + if e = save(); e != nil { |
| + return e |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (f *servingParkFS) Delete(r *servingParkRecord, save func() error) error { |
| + root, event, err := f.event(r) |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + defer event.Close() |
| + // Validate all children before unlinking any one of them. There is no |
| + // recursive deletion and no pathname derived from a registry row. |
| + names, err := event.Readdirnames(-1) |
| + if err != nil { |
| + return err |
| + } |
| + expected := make(map[string]servingParkFile) |
| + for _, item := range r.Source.Files { |
| + if item.Present { |
| + expected[item.Name] = item |
| + } |
| + } |
| + for _, name := range names { |
| + item, ok := expected[name] |
| + if !ok || item.Deleted { |
| + return hostbox.ErrChanged |
| + } |
| + actual, e := servingParkStat(event, name) |
| + if e != nil || !sameServingParkFile(item, actual) { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + for _, item := range r.Source.Files { |
| + if !item.Present { |
| + continue |
| + } |
| + actual, e := servingParkStat(event, item.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if item.Deleted && actual.Present || !actual.Present && !item.DeleteIntent || actual.Present && !sameServingParkFile(item, actual) { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + for i := range r.Source.Files { |
| + item := &r.Source.Files[i] |
| + if !item.Present { |
| + continue |
| + } |
| + actual, e := servingParkStat(event, item.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if item.Deleted { |
| + if actual.Present { |
| + return hostbox.ErrChanged |
| + } |
| + continue |
| + } |
| + if !actual.Present && !item.DeleteIntent { |
| + return hostbox.ErrChanged |
| + } |
| + if actual.Present && !sameServingParkFile(*item, actual) { |
| + return hostbox.ErrChanged |
| + } |
| + if !item.DeleteIntent { |
| + item.DeleteIntent = true |
| + if e = save(); e != nil { |
| + return e |
| + } |
| + } |
| + if actual.Present { |
| + if e = f.unlink(int(event.Fd()), item.Name, 0); e != nil { |
| + return e |
| + } |
| + } |
| + if e = f.sync(int(event.Fd())); e != nil { |
| + return e |
| + } |
| + item.Deleted = true |
| + if e = save(); e != nil { |
| + return e |
| + } |
| + } |
| + // Keep the recorded empty event until tombstone cleanup is implemented. |
| + // It owns no database blocks and cannot be reused by another retirement. |
| + return nil |
| +} |
| + |
| +func (f *servingParkFS) CheckParked(r *servingParkRecord) error { |
| + root, event, err := f.event(r) |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + defer event.Close() |
| + source, parent, err := f.source(r.Authorization) |
| + if err != nil { |
| + return err |
| + } |
| + defer source.Close() |
| + if parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + expected := make(map[string]bool) |
| + for _, item := range r.Source.Files { |
| + src, e := servingParkStat(source, item.Name) |
| + if e != nil || src.Present { |
| + return hostbox.ErrChanged |
| + } |
| + dst, e := servingParkStat(event, item.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if item.Present && !item.Deleted { |
| + if !sameServingParkFile(item, dst) { |
| + return hostbox.ErrChanged |
| + } |
| + expected[item.Name] = true |
| + } else if dst.Present { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + names, err := event.Readdirnames(-1) |
| + if err != nil { |
| + return err |
| + } |
| + for _, name := range names { |
| + if !expected[name] { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimservingpark_linux_test.go b/internal/host/claimservingpark_linux_test.go| new file mode 100644 |
| index 000000000..3af4ca23d |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_linux_test.go |
| @@ -0,0 +1,708 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// This fixture exercises the durable driver, with explicit seams for root UID, |
| +// nosymfollow and the outer claim/host-guard authorization. It is not a |
| +// privileged mount or production transport witness. |
| +type servingParkDiskFixture struct { |
| + t *testing.T |
| + ledger *servingParkLedger |
| + fs *servingParkFS |
| + disk *rollbackDisk |
| + data string |
| + now time.Time |
| + base servingArtifactIdentity |
| + target hostguard.FenceTarget |
| +} |
| + |
| +func newServingParkDiskFixture(t *testing.T) *servingParkDiskFixture { |
| + t.Helper() |
| + rf, _, _, in := servingRetirementFixture(t) |
| + admitted := servingAdmit(t, rf, &in, "G") |
| + receipt := servingStop(t, rf, admitted) |
| + base, err := servingWriterIdentity(admitted, "box-1", "ie") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + disk := &rollbackDisk{dir: t.TempDir()} |
| + fs := newServingParkFS(rf.guard.DataDir, disk) |
| + fs.owner = uint32(os.Geteuid()) |
| + fs.mount = func(string) (bool, error) { return true, nil } |
| + f := &servingParkDiskFixture{t: t, fs: fs, disk: disk, data: rf.guard.DataDir, now: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC), base: base, target: receipt.Target} |
| + f.ledger = &servingParkLedger{state: disk, fs: fs, journal: rf.journal, now: func() time.Time { return f.now }} |
| + return f |
| +} |
| + |
| +func (f *servingParkDiskFixture) evidence(n uint64, legacy bool, state string) (servingParkAuthorization, []hostguard.FenceReceipt) { |
| + t := f.t |
| + t.Helper() |
| + prior, next := f.base, f.base |
| + prior.Stamp, prior.Session, prior.Generation = n, n, fmt.Sprintf("G%d", n) |
| + next.Stamp, next.Session, next.Generation = n+1, n+1, fmt.Sprintf("G%d", n+1) |
| + target := f.target |
| + target.Stamp, target.Session, target.FenceID = n, n, fmt.Sprintf("stop-%d", n) |
| + s := StopReceipt{V: 1, ID: target.FenceID, Kind: StopReceiptStopped, Region: target.Region, ClaimETag: target.ClaimETag, ClaimGen: target.ClaimGen, FenceID: target.FenceID, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, DirectoryRevision: target.DirectoryRevision, EvidenceAt: f.now} |
| + receipts := []hostguard.FenceReceipt{{Target: target, Bytes: mustMarshalStopReceipt(s), EvidenceAt: f.now}} |
| + if state != "missing" && !legacy { |
| + if err := f.ledger.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "controller-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if state != "prelaunch" { |
| + res := hostbox.LaunchReservation{ID: fmt.Sprintf("%d:main", n), Role: "main", InvocationID: fmt.Sprintf("invocation-%d", n), Inputs: "inputs", Target: target, Group: hostbox.LaunchGroup{RelativePath: instancectl.ClaimUnitControlGroup(target.Host)}, Executable: hostbox.LaunchFile{Device: 1, Inode: 3}} |
| + if err := f.ledger.journal.IntendUnit(t.Context(), res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if state == "started" { |
| + group := hostbox.LaunchGroup{RelativePath: res.Group.RelativePath, Device: 1, Inode: 4, InvocationID: fmt.Sprintf("unit-%d", n)} |
| + if err := f.ledger.journal.StartedUnit(t.Context(), res, group, 99, "tenant-start"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if state != "intended" { |
| + if err := f.ledger.journal.SettleUnit(t.Context(), target, res.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + } |
| + source, err := os.Open(filepath.Dir(provision.NestedDBPath(f.data, next.Host))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := servingParkDirID(source) |
| + source.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := servingParkAuthorization{TagsFrom: n, Predecessor: prior, OriginalSuccessor: next, Successor: next, Directory: id, Legacy: legacy} |
| + if legacy { |
| + a.Predecessor = servingArtifactIdentity{} |
| + a.TagsFrom = 0 |
| + } |
| + a.ID = servingRetirementID(a.Predecessor, next, legacy) |
| + return a, receipts |
| +} |
| +func (f *servingParkDiskFixture) put(suffix, body string) { |
| + f.t.Helper() |
| + if err := os.WriteFile(provision.NestedDBPath(f.data, f.base.Host)+suffix, []byte(body), 0600); err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| +func (f *servingParkDiskFixture) park(a servingParkAuthorization, receipts []hostguard.FenceReceipt) (servingParkRecord, error) { |
| + var r servingParkRecord |
| + err := f.ledger.withNamespace(f.t.Context(), func() error { var err error; r, err = f.ledger.parkLocked(f.t.Context(), a, receipts); return err }) |
| + return r, err |
| +} |
| +func (f *servingParkDiskFixture) complete(a servingParkAuthorization) { |
| + f.t.Helper() |
| + if err := f.ledger.withNamespace(f.t.Context(), func() error { return f.ledger.completeLocked(f.t.Context(), a.ID, a.Successor) }); err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| +func (f *servingParkDiskFixture) path(a servingParkAuthorization, suffix string) string { |
| + return filepath.Join(f.data, "instances", ".serving-parked", a.ID, a.Successor.Host+".db"+suffix) |
| +} |
| +func (f *servingParkDiskFixture) retained(a servingParkAuthorization, body string, deadline time.Time) { |
| + f.t.Helper() |
| + slot, err := f.ledger.slot(a) |
| + if err != nil || slot.ID != a.ID { |
| + f.t.Fatalf("park owner changed: %+v %v", slot, err) |
| + } |
| + b, err := os.ReadFile(f.path(a, "")) |
| + if err != nil || string(b) != body { |
| + f.t.Fatalf("retained database: %q %v", b, err) |
| + } |
| + r, err := f.ledger.read(a.ID) |
| + if err != nil || !r.ReapAfter.Equal(deadline) { |
| + f.t.Fatalf("park deadline changed: %v %v", r.ReapAfter, err) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerKeepsLastStartedDatabase(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy writes") |
| + f.put("-wal", "legacy WAL") |
| + f.put("-shm", "legacy SHM") |
| + legacy, stops := f.evidence(10, true, "missing") |
| + parked, err := f.park(legacy, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(legacy) |
| + for _, suffix := range []string{"-wal", "-shm"} { |
| + if b, e := os.ReadFile(f.path(legacy, suffix)); e != nil || len(b) == 0 { |
| + t.Fatalf("companion not preserved: %s %v", suffix, e) |
| + } |
| + } |
| + for n := uint64(11); n < 179; n++ { |
| + f.now = f.now.Add(time.Hour) |
| + // Alternate a failed restore with no database and a successful restore |
| + // whose tenant never started. Neither owns the retention slot. |
| + if n%2 == 0 { |
| + f.put("", "unlaunched baseline") |
| + } |
| + f.put(".gen", fmt.Sprintf("G%d", n)) |
| + f.put("-wal", "companion") |
| + next, receipts := f.evidence(n, false, "prelaunch") |
| + r, e := f.park(next, receipts) |
| + if e != nil { |
| + t.Fatalf("retirement %d: %v", n, e) |
| + } |
| + if !r.Closed || r.Disposition == servingParkRetain { |
| + t.Fatalf("unlaunched source retained: %+v", r) |
| + } |
| + f.complete(next) |
| + f.retained(legacy, "legacy writes", parked.ReapAfter) |
| + } |
| + f.put("", "tenant writes") |
| + next, receipts := f.evidence(179, false, "started") |
| + r, err := f.park(next, receipts) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(next) |
| + if _, err := os.Stat(f.path(legacy, "")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("legacy not replaced: %v", err) |
| + } |
| + f.retained(next, "tenant writes", r.ReapAfter) |
| + // A tombstoned replay cannot replace the current slot or reset its clock. |
| + if _, err := f.park(legacy, stops); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(legacy) |
| + f.retained(next, "tenant writes", r.ReapAfter) |
| +} |
| + |
| +func TestServingParkLedgerAmbiguousLaunchPreservesBothCopies(t *testing.T) { |
| + for _, state := range []string{"missing", "intended", "settled-unobserved"} { |
| + t.Run(state, func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + legacy, stops := f.evidence(10, true, "missing") |
| + park, err := f.park(legacy, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(legacy) |
| + f.put("", "uncertain tenant writes") |
| + next, receipts := f.evidence(11, false, state) |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("ambiguous launch accepted") |
| + } |
| + f.retained(legacy, "legacy", park.ReapAfter) |
| + b, err := os.ReadFile(provision.NestedDBPath(f.data, f.base.Host)) |
| + if err != nil || string(b) != "uncertain tenant writes" { |
| + t.Fatalf("source changed: %q %v", b, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerExpiryAndIdentity(t *testing.T) { |
| + for _, attack := range []string{"none", "inode", "hardlink", "symlink", "unknown child", "event", "root", "parent", "missing", "mount"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "parked") |
| + a, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + f.now = r.ReapAfter.Add(-time.Nanosecond) |
| + reap := func() error { |
| + return f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }) |
| + } |
| + if err := reap(); err == nil { |
| + t.Fatal("reaped before deadline") |
| + } |
| + f.now = r.ParkedAt.Add(-time.Hour) |
| + if err := reap(); err == nil { |
| + t.Fatal("clock rollback reaped park") |
| + } |
| + f.now = r.ReapAfter |
| + path := f.path(a, "") |
| + victim := filepath.Join(t.TempDir(), "victim") |
| + if err := os.WriteFile(victim, []byte("victim"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "inode": |
| + if err := os.Rename(path, path+".saved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("replacement"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(path+".saved", filepath.Join(t.TempDir(), "original")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "hardlink": |
| + if err := os.Link(path, path+".link"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "symlink": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(victim, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "unknown child": |
| + if err := os.WriteFile(path+".unknown", []byte("keep"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "event", "root", "parent": |
| + dir := filepath.Dir(path) |
| + if attack == "root" { |
| + dir = filepath.Dir(dir) |
| + } |
| + if attack == "parent" { |
| + dir = filepath.Dir(filepath.Dir(dir)) |
| + } |
| + if err := os.Rename(dir, dir+".saved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "mount": |
| + f.fs.mount = func(string) (bool, error) { return false, nil } |
| + } |
| + err = reap() |
| + if attack == "none" { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, e := os.Stat(path); !errors.Is(e, os.ErrNotExist) { |
| + t.Fatalf("not reaped: %v", e) |
| + } |
| + } else if err == nil { |
| + t.Fatal("unsafe reaping accepted") |
| + } |
| + if b, e := os.ReadFile(victim); e != nil || string(b) != "victim" { |
| + t.Fatalf("victim changed: %q %v", b, e) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerNoReplaceAndCrossMountRefusal(t *testing.T) { |
| + for _, failure := range []error{unix.EXDEV, unix.EEXIST} { |
| + t.Run(failure.Error(), func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "source") |
| + a, stops := f.evidence(10, true, "missing") |
| + f.fs.rename = func(int, string, int, string, uint) error { return failure } |
| + if _, err := f.park(a, stops); !errors.Is(err, failure) { |
| + t.Fatalf("rename result: %v", err) |
| + } |
| + b, err := os.ReadFile(provision.NestedDBPath(f.data, f.base.Host)) |
| + if err != nil || string(b) != "source" { |
| + t.Fatalf("rename fallback touched source: %q %v", b, err) |
| + } |
| + f.fs.rename = unix.Renameat2 |
| + if _, err := f.park(a, stops); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerLostRenameAndDeleteReplies(t *testing.T) { |
| + for _, effect := range []string{"rename", "sync", "unlink"} { |
| + for boundary := 1; boundary <= 4; boundary++ { |
| + t.Run(fmt.Sprintf("%s-%d", effect, boundary), func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + f.put(suffix, "source"+suffix) |
| + } |
| + a, stops := f.evidence(10, true, "missing") |
| + calls := 0 |
| + fail := func(err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + calls++ |
| + if calls == boundary { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + } |
| + if effect == "rename" { |
| + f.fs.rename = func(a int, b string, c int, d string, e uint) error { return fail(unix.Renameat2(a, b, c, d, e)) } |
| + } |
| + if effect == "sync" { |
| + f.fs.sync = func(fd int) error { return fail(unix.Fsync(fd)) } |
| + } |
| + _, _ = f.park(a, stops) |
| + f.fs.rename, f.fs.sync = unix.Renameat2, unix.Fsync |
| + // A failure before namespace.json exists is deliberately an obstruction; |
| + // quarantine recovery (still unbound) must resolve it, never adoption. |
| + if _, err := f.disk.Read("namespace.json"); errors.Is(err, os.ErrNotExist) { |
| + if _, err := f.park(a, stops); err == nil { |
| + t.Fatal("unrecorded namespace adopted") |
| + } |
| + return |
| + } |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + if b, e := os.ReadFile(f.path(a, suffix)); e != nil || string(b) != "source"+suffix { |
| + t.Fatalf("parked bytes: %q %v", b, e) |
| + } |
| + } |
| + f.now = r.ReapAfter |
| + if effect == "unlink" { |
| + f.fs.unlink = func(fd int, n string, flags int) error { return fail(unix.Unlinkat(fd, n, flags)) } |
| + } |
| + _ = f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }) |
| + f.fs.unlink = unix.Unlinkat |
| + if err := f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestServingParkLedgerPendingCannotBeReplaced(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + a, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "new tenant writes") |
| + next, receipts := f.evidence(11, false, "started") |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("pending park replaced") |
| + } |
| + f.retained(a, "legacy", r.ReapAfter) |
| + if err := os.Remove(provision.NestedDBPath(f.data, f.base.Host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + f.put("", "new tenant writes") |
| + if _, err := f.park(next, receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerCancelIsEffectFree(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + called := false |
| + if err := f.ledger.withNamespace(ctx, func() error { called = true; return nil }); !errors.Is(err, context.Canceled) || called { |
| + t.Fatalf("cancel: %v, called=%v", err, called) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerDiscardResumesAfterUnlink(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + old, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(old, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(old) |
| + f.put("", "unlaunched") |
| + next, receipts := f.evidence(11, false, "prelaunch") |
| + f.fs.unlink = func(fd int, n string, flags int) error { |
| + if err := unix.Unlinkat(fd, n, flags); err != nil { |
| + return err |
| + } |
| + return os.ErrPermission |
| + } |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("lost unlink reply succeeded") |
| + } |
| + f.retained(old, "legacy", r.ReapAfter) |
| + f.fs.unlink = unix.Unlinkat |
| + if _, err := f.park(next, receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(next) |
| + f.retained(old, "legacy", r.ReapAfter) |
| +} |
| + |
| +func TestServingParkLedgerSourceReplacementDoesNotDeleteOldSlot(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + old, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(old, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(old) |
| + f.put("", "served") |
| + next, receipts := f.evidence(11, false, "started") |
| + // Interrupt just after the new source was captured and its intent saved. |
| + f.disk.afterWrite = func(name string, _ []byte) { |
| + if name == "retirement-"+next.ID+".json" { |
| + panic("crash") |
| + } |
| + } |
| + func() { |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("crash not reached") |
| + } |
| + }() |
| + _, _ = f.park(next, receipts) |
| + }() |
| + f.disk.afterWrite = nil |
| + source := provision.NestedDBPath(f.data, f.base.Host) |
| + if err := os.Rename(source, source+".saved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "replacement") |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("replaced source accepted") |
| + } |
| + f.retained(old, "legacy", r.ReapAfter) |
| +} |
| + |
| +func TestServingParkLedgerChecksAllArtifactsBeforeDeletion(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "database") |
| + f.put("-shm", "shared memory") |
| + a, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + if err := os.Remove(f.path(a, "-shm")); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.now = r.ReapAfter |
| + if err := f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }); err == nil { |
| + t.Fatal("unrecorded absence accepted") |
| + } |
| + if b, e := os.ReadFile(f.path(a, "")); e != nil || string(b) != "database" { |
| + t.Fatalf("partial validation deleted database: %q %v", b, e) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerCompletionRechecksSource(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + a, stops := f.evidence(10, true, "missing") |
| + _, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "replacement") |
| + if err := f.ledger.withNamespace(t.Context(), func() error { return f.ledger.completeLocked(t.Context(), a.ID, a.Successor) }); err == nil { |
| + t.Fatal("completed with replacement source") |
| + } |
| + r, err := f.ledger.read(a.ID) |
| + if err != nil || r.Complete { |
| + t.Fatalf("completion persisted: %v %v", r.Complete, err) |
| + } |
| +} |
| + |
| +func TestServingParkLedgerLegacyConsumptionSurvivesReaping(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + a, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + f.now = r.ReapAfter |
| + if err := f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Even removal of the disposable retirement tombstone does not reauthorize |
| + // a different legacy retirement for this installation/incarnation. |
| + if err := f.disk.Remove("retirement-" + a.ID + ".json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + slot, err := f.ledger.slot(a) |
| + if err == nil { |
| + t.Fatal("slot accepted missing tombstone") |
| + } |
| + // Model future slot/tombstone GC; the durable consumed-source record stays. |
| + slot = servingParkSlot{Version: 1, Box: a.Successor.Box, Host: a.Successor.Host} |
| + if err := f.ledger.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "restored successor") |
| + next, receipts := f.evidence(11, true, "missing") |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("successor parked as legacy") |
| + } |
| +} |
| + |
| +type servingParkFaultStore struct { |
| + rootstate.Store |
| + count, failAt int |
| + before bool |
| +} |
| + |
| +func (s *servingParkFaultStore) Write(name string, body []byte) error { |
| + s.count++ |
| + if s.count == s.failAt && s.before { |
| + return os.ErrPermission |
| + } |
| + if err := s.Store.Write(name, body); err != nil { |
| + return err |
| + } |
| + if s.count == s.failAt { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func TestServingParkLedgerDurableWriteFailures(t *testing.T) { |
| + for _, before := range []bool{false, true} { |
| + for boundary := 1; boundary <= 24; boundary++ { |
| + t.Run(fmt.Sprintf("before-%t-boundary-%d", before, boundary), func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "database") |
| + f.put("-wal", "wal") |
| + f.put("-shm", "shm") |
| + a, stops := f.evidence(10, true, "missing") |
| + faults := &servingParkFaultStore{Store: f.disk, failAt: boundary, before: before} |
| + f.ledger.state, f.fs.state = faults, faults |
| + r, err := f.park(a, stops) |
| + if err == nil { |
| + err = f.ledger.withNamespace(t.Context(), func() error { return f.ledger.completeLocked(t.Context(), a.ID, a.Successor) }) |
| + } |
| + if err == nil { |
| + f.now = r.ReapAfter |
| + err = f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }) |
| + } |
| + f.ledger.state, f.fs.state = f.disk, f.disk |
| + if _, readErr := f.disk.Read("namespace.json"); os.IsNotExist(readErr) { |
| + if _, statErr := os.Stat(filepath.Join(f.data, "instances", ".serving-parked")); statErr == nil { |
| + if _, err := f.park(a, stops); err == nil { |
| + t.Fatal("unknown root adopted after write failure") |
| + } |
| + return |
| + } |
| + } |
| + r, err = f.park(a, stops) |
| + if err != nil { |
| + t.Fatalf("park replay: %v", err) |
| + } |
| + f.complete(a) |
| + f.now = r.ReapAfter |
| + if err := f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }); err != nil { |
| + t.Fatalf("reap replay: %v", err) |
| + } |
| + got, err := f.ledger.read(a.ID) |
| + if err != nil || !got.Closed || !got.Complete { |
| + t.Fatalf("unfinished replay: %+v %v", got, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestServingParkLedgerSlotsAcrossIncarnationsAndHosts(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "first incarnation") |
| + old, stops := f.evidence(10, true, "missing") |
| + _, err := f.park(old, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(old) |
| + f.base.Incarnation, f.target.Incarnation = "next-incarnation", "next-incarnation" |
| + f.put("", "next incarnation") |
| + next, receipts := f.evidence(11, false, "started") |
| + r, err := f.park(next, receipts) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(next) |
| + if _, err := os.Stat(f.path(old, "")); !os.IsNotExist(err) { |
| + t.Fatalf("old incarnation remains: %v", err) |
| + } |
| + f.base.Host, f.target.Host = "other.example", "other.example" |
| + if err := os.MkdirAll(filepath.Dir(provision.NestedDBPath(f.data, f.base.Host)), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "other host") |
| + other, receipts := f.evidence(12, true, "missing") |
| + if _, err := f.park(other, receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(other) |
| + f.retained(next, "next incarnation", r.ReapAfter) |
| +} |
| + |
| +func TestServingParkLedgerDiscardPendingBlocksNextRetirement(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + old, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(old, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(old) |
| + f.put("", "unlaunched") |
| + pending, receipts := f.evidence(11, false, "prelaunch") |
| + if _, err := f.park(pending, receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put("", "next tenant writes") |
| + next, receipts := f.evidence(12, false, "started") |
| + if _, err := f.park(next, receipts); err == nil { |
| + t.Fatal("unfinished discard overtaken") |
| + } |
| + f.retained(old, "legacy", r.ReapAfter) |
| +} |
| + |
| +func TestServingParkLedgerRefusesPrematureTombstone(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "legacy") |
| + a, stops := f.evidence(10, true, "missing") |
| + r, err := f.park(a, stops) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + r.Complete, r.Deleting, r.Closed, r.Cause = true, true, true, "expiry" |
| + if err := f.ledger.write("retirement-"+a.ID+".json", r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.ledger.read(a.ID); err == nil { |
| + t.Fatal("incomplete deletion accepted as a tombstone") |
| + } |
| + if b, e := os.ReadFile(f.path(a, "")); e != nil || string(b) != "legacy" { |
| + t.Fatalf("malformed record changed data: %q %v", b, e) |
| + } |
| +} |
diff --git a/internal/host/claimservingpark_other.go b/internal/host/claimservingpark_other.go| new file mode 100644 |
| index 000000000..e6ed6f549 |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_other.go |
| @@ -0,0 +1,20 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type servingParkFS struct{} |
| + |
| +func newServingParkFS(string, rootstate.Store) *servingParkFS { return &servingParkFS{} } |
| +func (*servingParkFS) Capture(servingParkAuthorization) (servingParkSource, error) { |
| + return servingParkSource{}, hostbox.ErrUnsupported |
| +} |
| +func (*servingParkFS) Prepare(*servingParkRecord, func() error) error { return hostbox.ErrUnsupported } |
| +func (*servingParkFS) Move(*servingParkRecord, func() error) error { return hostbox.ErrUnsupported } |
| +func (*servingParkFS) Delete(*servingParkRecord, func() error) error { return hostbox.ErrUnsupported } |
| + |
| +func (*servingParkFS) CheckParked(*servingParkRecord) error { return hostbox.ErrUnsupported } |
diff --git a/internal/host/claimservingpark_records.go b/internal/host/claimservingpark_records.go| new file mode 100644 |
| index 000000000..dd09c056a |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_records.go |
| @@ -0,0 +1,491 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// servingParkLedger has no production caller until root authorization and the |
| +// journal transport are bound. Its callbacks are private root implementation |
| +// seams; no request can supply a source, disposition, timestamp or completion. |
| +type servingParkLedger struct { |
| + state rootstate.Store |
| + fs servingParkFilesystem |
| + journal *hostbox.LaunchJournal |
| + now func() time.Time |
| +} |
| + |
| +type servingParkFile struct { |
| + Name string |
| + Present bool |
| + Device, Inode, Mount, Bytes uint64 |
| + Mode, Links uint32 |
| + Moved, DeleteIntent, Deleted bool |
| +} |
| + |
| +type servingParkSource struct { |
| + Directory servingArtifactDirectory |
| + Parent servingArtifactDirectory |
| + Files [4]servingParkFile |
| +} |
| + |
| +type servingParkRecord struct { |
| + Version int |
| + Authorization servingParkAuthorization |
| + Source servingParkSource |
| + Receipts []hostguard.FenceReceipt |
| + Disposition servingParkDisposition |
| + Event servingArtifactDirectory |
| + Prepared, Parked, Complete, Deleting, Closed bool |
| + ParkedAt, ReapAfter time.Time |
| + Cause string |
| +} |
| + |
| +type servingLegacyConsumption struct { |
| + Authorization servingParkAuthorization |
| + Source servingParkSource |
| +} |
| + |
| +type servingParkSlot struct { |
| + Version int |
| + Box, Host, ID, Pending string |
| +} |
| + |
| +type servingParkFilesystem interface { |
| + // Capture validates a root-authenticated source before any slot is changed. |
| + Capture(servingParkAuthorization) (servingParkSource, error) |
| + Prepare(*servingParkRecord, func() error) error |
| + Move(*servingParkRecord, func() error) error |
| + Delete(*servingParkRecord, func() error) error |
| + CheckParked(*servingParkRecord) error |
| +} |
| + |
| +func servingParkKey(box, host string) string { |
| + sum := sha256.Sum256([]byte(box + "\x00" + host)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +func servingParkDecode(body []byte, out any) error { |
| + d := json.NewDecoder(bytes.NewReader(body)) |
| + d.DisallowUnknownFields() |
| + if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +func (l *servingParkLedger) write(name string, value any) error { |
| + body, err := json.Marshal(value) |
| + if err != nil { |
| + return err |
| + } |
| + return l.state.Write(name, body) |
| +} |
| + |
| +func (l *servingParkLedger) read(id string) (servingParkRecord, error) { |
| + var r servingParkRecord |
| + if len(id) != 64 { |
| + return r, hostbox.ErrChanged |
| + } |
| + if _, err := hex.DecodeString(id); err != nil { |
| + return r, hostbox.ErrChanged |
| + } |
| + body, err := l.state.Read("retirement-" + id + ".json") |
| + if err != nil { |
| + return r, err |
| + } |
| + if err = servingParkDecode(body, &r); err != nil { |
| + return r, err |
| + } |
| + if err = validateServingParkRecord(r); err != nil || r.Authorization.ID != id { |
| + return r, hostbox.ErrChanged |
| + } |
| + return r, nil |
| +} |
| + |
| +func validateServingParkRecord(r servingParkRecord) error { |
| + a := r.Authorization |
| + s := a.OriginalSuccessor |
| + if r.Version != 1 || a.ID != servingRetirementID(a.Predecessor, s, a.Legacy) || s.Host == "" || s.Box == "" || s.Incarnation == "" || s.Generation == "" || s.Region == "" || s.Location == "" || s.Stamp == 0 || s.Stamp != s.Session || a.Successor != s || a.Directory != r.Source.Directory || r.Source.Directory.Inode == 0 || r.Source.Directory.Device == 0 || r.Source.Directory.Mount == 0 || r.Source.Parent.Inode == 0 || r.Source.Parent.Device != r.Source.Directory.Device || r.Source.Parent.Mount != r.Source.Directory.Mount { |
| + return hostbox.ErrChanged |
| + } |
| + if a.Legacy { |
| + if a.Predecessor != (servingArtifactIdentity{}) || a.TagsFrom != 0 || r.Source.Files[1].Present { |
| + return hostbox.ErrChanged |
| + } |
| + } else { |
| + p := a.Predecessor |
| + if p.Host != s.Host || p.Box != s.Box || p.Region != s.Region || p.Incarnation != s.Incarnation || p.Location != s.Location || p.Generation == "" || p.Generation == s.Generation || p.Stamp == 0 || p.Stamp != p.Session || p.Stamp >= s.Stamp || a.TagsFrom != p.Stamp { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + if len(r.Receipts) == 0 { |
| + return hostbox.ErrChanged |
| + } |
| + seen := make(map[string]bool) |
| + for _, receipt := range r.Receipts { |
| + t := receipt.Target |
| + if t.Host != s.Host || t.Box != s.Box || t.Region != s.Region || t.Incarnation != s.Incarnation || t.Stamp == 0 || t.Stamp >= s.Stamp || t.Session >= s.Session || t.Op != "" || t.Phase != "" || t.ObligationID != "" || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(context.Background(), receipt) != nil { |
| + return hostbox.ErrChanged |
| + } |
| + if !a.Legacy && (t.Stamp != a.Predecessor.Stamp || t.Session != a.Predecessor.Session) { |
| + return hostbox.ErrChanged |
| + } |
| + seen[t.FenceID] = true |
| + } |
| + for i, suffix := range []string{"", ".gen", "-wal", "-shm"} { |
| + f := r.Source.Files[i] |
| + if f.Name != s.Host+".db"+suffix { |
| + return hostbox.ErrChanged |
| + } |
| + if f.Present { |
| + if f.Device != r.Source.Directory.Device || f.Mount != r.Source.Directory.Mount || f.Inode == 0 || f.Mode != 0100000 || f.Links != 1 { |
| + return hostbox.ErrChanged |
| + } |
| + } else if f.Device != 0 || f.Inode != 0 || f.Mount != 0 || f.Mode != 0 || f.Links != 0 || f.Bytes != 0 || f.Moved || f.DeleteIntent || f.Deleted { |
| + return hostbox.ErrChanged |
| + } |
| + if f.Deleted && (!f.DeleteIntent || !r.Deleting) || f.DeleteIntent && !f.Moved { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + if r.Disposition != servingParkRetain && r.Disposition != servingParkDiscard && r.Disposition != servingParkArtifactFree { |
| + return hostbox.ErrChanged |
| + } |
| + if (r.Disposition == servingParkArtifactFree) == r.Source.Files[0].Present { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Prepared && (r.Event.Inode == 0 || r.Event.Device != r.Source.Directory.Device || r.Event.Mount != r.Source.Directory.Mount) { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Parked { |
| + if !r.Prepared || r.ParkedAt.IsZero() || !r.ReapAfter.Equal(r.ParkedAt.Add(7*24*time.Hour)) { |
| + return hostbox.ErrChanged |
| + } |
| + for _, f := range r.Source.Files { |
| + if f.Present && !f.Moved { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } else if !r.ParkedAt.IsZero() || !r.ReapAfter.IsZero() || r.Complete { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Deleting && (r.Cause != "discard" && r.Cause != "replacement" && r.Cause != "expiry" && r.Cause != "pressure") { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Deleting && !r.Parked { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Closed && !r.Deleting { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Closed { |
| + for _, item := range r.Source.Files { |
| + if item.Present && !item.Deleted { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } |
| + if r.Complete && r.Disposition != servingParkRetain && !r.Closed { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +func (l *servingParkLedger) save(r servingParkRecord) error { |
| + if err := validateServingParkRecord(r); err != nil { |
| + return err |
| + } |
| + return l.write("retirement-"+r.Authorization.ID+".json", r) |
| +} |
| + |
| +func (l *servingParkLedger) slot(a servingParkAuthorization) (servingParkSlot, error) { |
| + s := servingParkSlot{Version: 1, Box: a.Successor.Box, Host: a.Successor.Host} |
| + b, err := l.state.Read("slot-" + servingParkKey(s.Box, s.Host) + ".json") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return s, nil |
| + } |
| + if err != nil { |
| + return s, err |
| + } |
| + var saved servingParkSlot |
| + if servingParkDecode(b, &saved) != nil || saved.Version != 1 || saved.Box != s.Box || saved.Host != s.Host { |
| + return s, hostbox.ErrChanged |
| + } |
| + if saved.ID != "" { |
| + r, err := l.read(saved.ID) |
| + if err != nil || r.Disposition != servingParkRetain || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| + return s, hostbox.ErrChanged |
| + } |
| + } |
| + if saved.Pending != "" { |
| + r, err := l.read(saved.Pending) |
| + if err != nil || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| + return s, hostbox.ErrChanged |
| + } |
| + } |
| + return saved, nil |
| +} |
| + |
| +// withNamespace precedes the root host guard, slot lock and record lock. The |
| +// authority callback must acquire the host guard and independently authenticate |
| +// the claim, receipts, state directory and launch history while it is held. |
| +func (l *servingParkLedger) withNamespace(ctx context.Context, fn func() error) error { |
| + if ctx == nil || l == nil || l.state == nil || l.fs == nil || l.now == nil || fn == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + unlock, err := l.state.Lock(ctx, "namespace.lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + if err = ctx.Err(); err != nil { |
| + return err |
| + } |
| + return fn() |
| +} |
| + |
| +// parkLocked runs inside independently verified root host authority. Fresh |
| +// capture is never substituted for the immutable source on a replay. |
| +func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt) (servingParkRecord, error) { |
| + var zero servingParkRecord |
| + if err := ctx.Err(); err != nil { |
| + return zero, err |
| + } |
| + unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(a.Successor.Box, a.Successor.Host)+".lock") |
| + if err != nil { |
| + return zero, err |
| + } |
| + defer unlock() |
| + slot, err := l.slot(a) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if slot.Pending != "" && slot.Pending != a.ID { |
| + return zero, hostbox.ErrChanged |
| + } |
| + r, err := l.read(a.ID) |
| + if errors.Is(err, os.ErrNotExist) { |
| + source, e := l.fs.Capture(a) |
| + if e != nil { |
| + return zero, e |
| + } |
| + disposition := servingParkArtifactFree |
| + if a.Legacy { |
| + if source.Files[1].Present { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if source.Files[0].Present { |
| + disposition = servingParkRetain |
| + } |
| + } else { |
| + disposition, e = servingGenerationParkDisposition(ctx, l.journal, a.Predecessor, receipts, source.Files[0].Present) |
| + if e != nil { |
| + return zero, e |
| + } |
| + } |
| + r = servingParkRecord{Version: 1, Authorization: a, Source: source, Receipts: receipts, Disposition: disposition} |
| + if e = validateServingParkRecord(r); e != nil { |
| + return zero, e |
| + } |
| + // Consumption survives slot replacement, expiry and park tombstones. |
| + if a.Legacy { |
| + key := "legacy-" + servingParkKey(a.Successor.Box, a.Successor.Host+"\x00"+a.Successor.Incarnation) + ".json" |
| + var consumed servingLegacyConsumption |
| + want := servingLegacyConsumption{Authorization: a, Source: source} |
| + body, e := l.state.Read(key) |
| + if e == nil { |
| + if servingParkDecode(body, &consumed) != nil || consumed != want { |
| + return zero, hostbox.ErrChanged |
| + } |
| + } else if !errors.Is(e, os.ErrNotExist) { |
| + return zero, e |
| + } |
| + if e = l.write(key, want); e != nil { |
| + return zero, e |
| + } |
| + } |
| + if e = l.save(r); e != nil { |
| + return zero, e |
| + } |
| + } else if err != nil { |
| + return zero, err |
| + } |
| + if r.Authorization != a { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if r.Closed || r.Complete { |
| + return r, nil |
| + } |
| + if slot.Pending == "" { |
| + slot.Pending = a.ID |
| + if err = l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot); err != nil { |
| + return zero, err |
| + } |
| + } |
| + if r.Deleting { |
| + if r.Disposition == servingParkRetain || r.Cause != "discard" { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if err = l.deleteLocked(ctx, &r, "discard"); err != nil { |
| + return zero, err |
| + } |
| + return r, nil |
| + } |
| + if r.Disposition == servingParkRetain && slot.ID != a.ID { |
| + // A completed replay can never reacquire a slot it has lost. |
| + if r.Parked || r.Complete { |
| + return zero, hostbox.ErrChanged |
| + } |
| + fresh, e := l.fs.Capture(a) |
| + if e != nil || fresh != r.Source { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if slot.ID != "" { |
| + old, e := l.read(slot.ID) |
| + if e != nil || !old.Complete { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if !old.Closed { |
| + cause := "replacement" |
| + if old.Deleting { |
| + cause = old.Cause |
| + } |
| + if e = l.deleteLocked(ctx, &old, cause); e != nil { |
| + return zero, e |
| + } |
| + } |
| + } |
| + slot.ID = a.ID |
| + if err = l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot); err != nil { |
| + return zero, err |
| + } |
| + } |
| + save := func() error { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return l.save(r) |
| + } |
| + if err = l.fs.Prepare(&r, save); err != nil { |
| + return zero, err |
| + } |
| + if err = l.fs.Move(&r, save); err != nil { |
| + return zero, err |
| + } |
| + if !r.Parked { |
| + r.Parked, r.ParkedAt = true, l.now().UTC() |
| + r.ReapAfter = r.ParkedAt.Add(7 * 24 * time.Hour) |
| + if err = save(); err != nil { |
| + return zero, err |
| + } |
| + } |
| + // Even discarded restores are first isolated by recorded rename into the |
| + // root-owned event. A raced source replacement is never unlinked in place. |
| + if r.Disposition != servingParkRetain && !r.Closed { |
| + if err = l.deleteLocked(ctx, &r, "discard"); err != nil { |
| + return zero, err |
| + } |
| + } |
| + return r, nil |
| +} |
| + |
| +// completeLocked follows a fresh root check of the successor's paired tags. |
| +// Its caller holds namespace and host authority, never an agent Work lock. |
| +func (l *servingParkLedger) completeLocked(ctx context.Context, id string, a servingArtifactIdentity) error { |
| + r, err := l.read(id) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Authorization.Successor != a || !r.Parked { |
| + return hostbox.ErrChanged |
| + } |
| + slot, err := l.slot(r.Authorization) |
| + if err != nil { |
| + return err |
| + } |
| + if !r.Complete { |
| + if slot.Pending != id { |
| + return hostbox.ErrChanged |
| + } |
| + if err = l.fs.CheckParked(&r); err != nil { |
| + return err |
| + } |
| + if r.Disposition == servingParkRetain { |
| + if slot.ID != id || r.Closed || r.Deleting { |
| + return hostbox.ErrChanged |
| + } |
| + } else if !r.Closed { |
| + return hostbox.ErrChanged |
| + } |
| + if err = ctx.Err(); err != nil { |
| + return err |
| + } |
| + r.Complete = true |
| + if err = l.save(r); err != nil { |
| + return err |
| + } |
| + } |
| + if slot.Pending == id { |
| + slot.Pending = "" |
| + return l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot) |
| + } |
| + return nil |
| +} |
| + |
| +func (l *servingParkLedger) deleteLocked(ctx context.Context, r *servingParkRecord, cause string) error { |
| + if r.Closed { |
| + return nil |
| + } |
| + if cause != "discard" && (!r.Complete || r.Disposition != servingParkRetain) { |
| + return hostbox.ErrChanged |
| + } |
| + if cause == "discard" && r.Disposition == servingParkRetain { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Deleting && r.Cause != cause { |
| + return hostbox.ErrChanged |
| + } |
| + save := func() error { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return l.save(*r) |
| + } |
| + r.Deleting, r.Cause = true, cause |
| + if err := save(); err != nil { |
| + return err |
| + } |
| + if err := l.fs.Delete(r, save); err != nil { |
| + return err |
| + } |
| + r.Closed = true |
| + return save() |
| +} |
| + |
| +// reapLocked needs only namespace/slot/record authority; it never takes a host |
| +// guard. Pressure selection and authenticated replacement are separate callers. |
| +func (l *servingParkLedger) reapLocked(ctx context.Context, id string) error { |
| + r, err := l.read(id) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Closed { |
| + return nil |
| + } |
| + if !r.Complete || r.Disposition != servingParkRetain || l.now().Before(r.ReapAfter) { |
| + return hostbox.ErrChanged |
| + } |
| + return l.deleteLocked(ctx, &r, "expiry") |
| +} |
diff --git a/internal/host/claimservingparksource_linux.go b/internal/host/claimservingparksource_linux.go| new file mode 100644 |
| index 000000000..3d8f89555 |
| --- /dev/null |
| +++ b/internal/host/claimservingparksource_linux.go |
| @@ -0,0 +1,73 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// servingParkStateDirectory accepts only root installation and root inventory |
| +// snapshots. The future root handler must read both locally under host authority; |
| +// this function is not a transport decoder and accepts no registry row. |
| +func servingParkStateDirectory(data string, o hostclaim.Observed, installation hostguard.RootInstallation, in hostbox.StopInventory, revision uint64, actor hostbox.Actor) (servingArtifactDirectory, error) { |
| + var zero servingArtifactDirectory |
| + current, err := servingWriterIdentity(o, actor.Box, actor.Region) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if !filepath.IsAbs(data) || filepath.Clean(data) != data || strings.ContainsAny(current.Host, "/\\\x00") || current.Host == "." || current.Host == ".." { |
| + return zero, hostbox.ErrChanged |
| + } |
| + h := o.Claim.Holder |
| + if h.Family != "instance" || installation.Migration == 0 || installation.Empty || installation.Host != current.Host || installation.Incarnation != current.Incarnation || installation.Stamp == 0 || installation.Stamp > current.Stamp || installation.Account != h.Account || installation.App != h.App || installation.Record != h.Key || installation.Box != actor.Box || installation.Enrollment != actor.Enrollment || actor.Enrollment == "" || actor.Revision == 0 || revision == 0 || installation.InventoryRevision != revision { |
| + return zero, hostbox.ErrChanged |
| + } |
| + if installation.Stamp != current.Stamp { |
| + if _, err := servingPredecessor(o.Claim, current, installation.Stamp, ""); err != nil { |
| + return zero, err |
| + } |
| + } |
| + if !in.Complete || in.Host != installation.Host || in.Incarnation != installation.Incarnation || in.InstallationStamp != installation.Stamp || in.Box != actor.Box || in.Enrollment != actor.Enrollment || in.DirectoryRevision != fmt.Sprint(actor.Revision) { |
| + return zero, hostbox.ErrTypedInventory |
| + } |
| + relative := filepath.ToSlash(filepath.Join("instances", provision.InstanceID(current.Host))) |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", in.Host, in.Incarnation, in.InstallationStamp, in.Box))) |
| + owner := hex.EncodeToString(sum[:]) |
| + var selected hostbox.StopInventoryEntry |
| + for _, entry := range in.Entries { |
| + if entry.Kind != "path" || entry.ManagedRoot != "data" || entry.RelativePath != relative { |
| + continue |
| + } |
| + if selected.ArtifactID != "" || entry.ArtifactID == "" || entry.Ownership != owner || entry.Materialization != "materialized" || entry.Type != unix.S_IFDIR || entry.Device == 0 || entry.Inode == 0 { |
| + return zero, hostbox.ErrTypedInventory |
| + } |
| + selected = entry |
| + } |
| + if selected.ArtifactID == "" { |
| + return zero, hostbox.ErrTypedInventory |
| + } |
| + dir, err := instancefs.OpenDirBeneath(data, relative) |
| + if err != nil { |
| + return zero, err |
| + } |
| + defer dir.Close() |
| + id, err := servingParkDirID(dir) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if id.Device != selected.Device || id.Inode != selected.Inode { |
| + return zero, hostbox.ErrChanged |
| + } |
| + return id, nil |
| +} |
diff --git a/internal/host/claimservingparksource_linux_test.go b/internal/host/claimservingparksource_linux_test.go| new file mode 100644 |
| index 000000000..2929b82ed |
| --- /dev/null |
| +++ b/internal/host/claimservingparksource_linux_test.go |
| @@ -0,0 +1,83 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestServingParkSourceUsesRootInventory(t *testing.T) { |
| + for _, attack := range []string{"none", "unmaterialized", "duplicate", "owner", "revision", "enrollment", "record", "stamp", "foreign", "missing", "flat", "symlink", "replacement"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, _, _, inputs := servingRetirementFixture(t) |
| + o := servingAdmit(t, f, &inputs, "G") |
| + h := o.Claim.Holder |
| + actor := hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enrollment", Revision: 7} |
| + root := hostguard.RootInstallation{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Migration: 1, InventoryRevision: 3, Account: h.Account, App: h.App, Record: h.Key, Box: actor.Box, Enrollment: actor.Enrollment} |
| + d, err := os.Open(filepath.Dir(inputs.DBPath)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := servingParkDirID(d) |
| + d.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", o.Host, h.Incarnation, h.Stamp, actor.Box))) |
| + in := hostbox.StopInventory{Complete: true, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "7", Entries: []hostbox.StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: "instances/" + provision.InstanceID(o.Host), ArtifactID: "state", Ownership: hex.EncodeToString(owner[:]), Materialization: "materialized", Device: id.Device, Inode: id.Inode, Type: unix.S_IFDIR}}} |
| + switch attack { |
| + case "unmaterialized": |
| + in.Entries[0].Materialization = "intended" |
| + case "duplicate": |
| + in.Entries = append(in.Entries, in.Entries[0]) |
| + case "owner": |
| + in.Entries[0].Ownership = "forged" |
| + case "revision": |
| + root.InventoryRevision++ |
| + case "enrollment": |
| + actor.Enrollment = "foreign" |
| + case "record": |
| + root.Record = "foreign" |
| + case "stamp": |
| + root.Stamp++ |
| + case "foreign": |
| + in.Host = "other.example" |
| + case "missing": |
| + in.Entries = nil |
| + case "flat": |
| + in.Entries[0].RelativePath = "state" |
| + case "symlink", "replacement": |
| + dir := filepath.Dir(inputs.DBPath) |
| + if err := os.Rename(dir, dir+".original"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "symlink" { |
| + err = os.Symlink(dir+".original", dir) |
| + } else { |
| + err = os.Mkdir(dir, 0700) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := servingParkStateDirectory(f.guard.DataDir, o, root, in, 3, actor) |
| + if attack == "none" { |
| + if err != nil || got != id { |
| + t.Fatalf("source: %+v %v", got, err) |
| + } |
| + } else if err == nil { |
| + t.Fatal("unproved source accepted") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 232c82773..6ce8bb5b3 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -472,6 +472,9 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| + // rev3=PROPOSED-seed: local instances mount/state references; no deployment record writer. Root authority remains in the seed overlay. |
| + {"internal/host/claimservingpark_linux.go", "servingParkFS.parent", "key-site", "lit:instances", "1d241a7d446bbbade6e460efd911863f8a5246b8ac1d47b2548034e3feb2f216"}, |
| + {"internal/host/claimservingparksource_linux.go", "servingParkStateDirectory", "key-site", "lit:instances", "ec4a121330c5c8f27343f21af0bbe458cced885d4754e96c0d87b12d9b4a8f35"}, |
| {"internal/host/rootinventory.go", "ClaimRootHTTP.recoverRootInventory", "key-site", "key:RecordKey", "10d09b5ab6d07d316437233c9a65bf0bcdfe1083db4d0769fa8303b255dfd0ac"}, |
| {"internal/host/proofnamed.go", "proofBindingSource.record", "key-site", "key:RecordKey", "10a99ff4b355907831a904010b7019d480ce93f2f595e99e076d3c702d10ca2b"}, |
| {"internal/host/rootrecovery_rebind.go", "type recoveryRebindRequest", "key-site", "key:RecordKey", "b5db3e4dcd4807edea1d053eb1787fbf222be97d3f892e2ab66771457a51dc3d"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index 4de2c4271..e5864bd32 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -1,5 +1,5 @@ |
| -# Files seed checkpoint 2 overlay; pending Paul’s census ruling. |
| -# No root parking or deletion authority is implemented in this checkpoint. |
| +# Files seed checkpoint 3 overlay; pending Paul’s census ruling. |
| +# Root filesystem and retirement-ledger mechanisms have test callers only; production binding is refused. |
| # Load after zddr4 and its explicit seal check. No legacy fingerprints are added or removed. |
| # Every classification below is rev3=PROPOSED-seed, not an approved disposition. |
| internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G rev3=PROPOSED-seed; box process identity and provider bindings; serving dispatch independent of moves; serving enrollment required before a runtime can be built file:Read@4150,file:Read@9550,file:ReadFile@3694,registry:Host@9577,whole:Route@9665 088f34c7fe1b4f9bb7a3187d40d60bff0fd811ebf1e1888ce3d4781dc4de80d7 |
| @@ -31,6 +31,41 @@ 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 ae2fa116ece3bbd596baa02971ef69fba597ce2ab4135abb6bf9caae6d326202 |
| internal/hostbox/servingpark.go LaunchJournal.RootSessionLaunches authority C,J4 rev3=PROPOSED-seed; root-local persisted launch records for exact host/incarnation/stamp/session/box/enrollment; refuses agent journal transport cbbb9378bc53ac499972bed5fb654e05856a9afd7256d55323a717140ffcba18 |
| -@coverage internal/host 0e48977bb98fe6f8c707566b661fd034cda83afeb408512820466e0d31f972e7 |
| +internal/host/claimservingpark_linux.go newServingParkFS authority C,J4 rev3=PROPOSED-seed; root-only filesystem dependencies, fixed nosymfollow validation and private state store; no production caller 9a72f9bb7c62e85199e303411b42e01b0b016a2a5cf2d9c84e2588b5f36b52e2 |
| +internal/host/claimservingpark_linux.go sameServingParkFile authority C,J4 rev3=PROPOSED-seed; recorded basename, presence, mount, device, inode, regular type, single link and allocated-byte identity d5d684356e793dcfd55fe1ec9c609f2320e0155e052caca42475348075e8fd68 |
| +internal/host/claimservingpark_linux.go servingParkDirID authority C,J4 rev3=PROPOSED-seed; descriptor statx directory type, device, inode and mount identity file:Statx@102 3480dad6d65fedcc17e678df38cf13352ad3c4a8a9d0a09a395973d92b98094e |
| +internal/host/claimservingpark_linux.go servingParkFS.Capture authority C,J4 rev3=PROPOSED-seed; canonical nested source directory, four fixed basenames, source mount and bounded generation-marker bytes through the captured descriptor file:Fstat@920,file:Openat@694,file:ReadAll@1138 c3a9348ddd9548f3fcfdcd45abc42b6a758a6399ed34e92481785e26c110763d |
| +internal/host/claimservingpark_linux.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; all recorded event identities, unexpected children and source absence before root completion file:Readdirnames@822 144c738d31a4b6495d742310dc3ac171bd0d70bff902395a8cc77119191ccd22 |
| +internal/host/claimservingpark_linux.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; root-recorded event and artifact identities; validates the whole set before journaled per-name unlink, fsync and progress; no recursive deletion file:Readdirnames@341 8837ae9e8947479346f6dddeb8ea8f13290ad56d9d2b96f315e203f5c2e1646a |
| +internal/host/claimservingpark_linux.go servingParkFS.Move authority C,J4 rev3=PROPOSED-seed; recorded source/destination inodes and explicit absences; no-replace same-mount rename, post-rename identity and both parent fsyncs before progress 6e43ae58b6a02d167135fabdde7ade06502511c5237b313e6ca6c2a8e9a2c8d0 |
| +internal/host/claimservingpark_linux.go servingParkFS.Prepare authority C,J4 rev3=PROPOSED-seed; recorded private namespace and empty event before persisting event identity; existing unrecorded namespace is refused file:Prepare@24,file:Readdirnames@890 2480401a7da95a24a7557be0b8adff67394689abb418e36d0fafecdb8c1d827b |
| +internal/host/claimservingpark_linux.go servingParkFS.event authority C,J4 rev3=PROPOSED-seed; recorded namespace parent and private event mount/device/inode identities 8e2ce5bf2f5931e702b847d664bd5cff5fbf90c71ed74b5a2d52ca06d9e0da7c |
| +internal/host/claimservingpark_linux.go servingParkFS.namespace authority C,J4 rev3=PROPOSED-seed; private rootstate namespace record, effective parent mount and root-owned park directory; unknown roots refused without adoption file:Read@211 788a7186623346d4a623c2924cf33f2797b40515b6c42db8154bd38168cc8cd4 |
| +internal/host/claimservingpark_linux.go servingParkFS.parent authority C,J4 rev3=PROPOSED-seed; deployment-controlled instances path, descriptor mount identity and effective nosymfollow topology; path replacement refuses 1d241a7d446bbbade6e460efd911863f8a5246b8ac1d47b2548034e3feb2f216 |
| +internal/host/claimservingpark_linux.go servingParkFS.privateDir authority C,J4 rev3=PROPOSED-seed; no-follow directory descriptor, exact private owner and mode plus device/inode/mount identity file:Fstat@261 8a4a33c6871fa88cd53f9922f99e173102966827bfea3998b2b3bd749a2ad11b |
| +internal/host/claimservingpark_linux.go servingParkFS.source authority C,J4 rev3=PROPOSED-seed; canonical host-derived nested state directory checked against independently supplied root directory identity; no registry path b7a30539c735e811c366ebb7c853a962500e82305ba1caa64f73402ae6edbce5 |
| +internal/host/claimservingpark_linux.go servingParkOpenDir authority C,J4 rev3=PROPOSED-seed; single basename opened relative to a pinned parent with no-follow directory semantics file:Openat@210 665ff3adc3e1cd1c6c4b92439160a0ef8013ea8e35375bcdfa90747888751ff9 |
| +internal/host/claimservingpark_linux.go servingParkStat authority C,J4 rev3=PROPOSED-seed; no-follow artifact statx identity, single links, explicit absence and checked allocated-byte count file:Statx@143 2c5d25be8581568148d13e3cad6f4b5f861fec8bff2545502fbdea0d5d949bb6 |
| +internal/host/claimservingpark_other.go newServingParkFS authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 69baa4c747e687578e73b8369f2df9987fe6fb5836b78796cc41c776ba12c802 |
| +internal/host/claimservingpark_other.go servingParkFS.Capture authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority a5560b338356792386a5a1b2e27cb08ca0d5aa024cd03b567a78dfd986ce7871 |
| +internal/host/claimservingpark_other.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 3a8cb4d8052bbdea8cf7a300944de72c68012e6efa252c293fc1dc06a3a2150e |
| +internal/host/claimservingpark_other.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 8c169e8428b913d2f05b502ce971be49d5febaad95d8ae811aae21c81f3434ce |
| +internal/host/claimservingpark_other.go servingParkFS.Move authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 9d215b168da646166104db5624727038f793d2ae0c40eb71f23883f12b6b4b13 |
| +internal/host/claimservingpark_other.go servingParkFS.Prepare authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority file:Prepare@22 645770372357707f635351a377e943e78f1c160222385e41f42b80d57adedc29 |
| +internal/host/claimservingpark_records.go servingParkDecode authority C,J4 rev3=PROPOSED-seed; strict private-record JSON with unknown-field and trailing-value refusal 189c7944ea9f5d269d39ed6a869b6bd739e3033195f06cc1f2c9f23a23180184 |
| +internal/host/claimservingpark_records.go servingParkKey authority C,J4 rev3=PROPOSED-seed; box/host identity used for stable per-host namespace and slot keys 67518302550380dbf9b741a73758e5c94307eff70ff8a09cc56763a8aa794d86 |
| +internal/host/claimservingpark_records.go servingParkLedger.completeLocked authority C,J4 rev3=PROPOSED-seed; root retirement and pending-slot records plus fresh filesystem absence/identity checks; completion requires outer root claim-tag authority which remains unbound 246eb0689f2f36a968c60d9241f52d2f3cf7ebf76b025092e1658f1b7b495236 |
| +internal/host/claimservingpark_records.go servingParkLedger.deleteLocked authority C,J4 rev3=PROPOSED-seed; complete retained record or proved discard disposition and persisted cause/progress before filesystem deletion 1545950c564d51d9c29b0d76daca1ddec5a159481698b11accd4dd7411157b37 |
| +internal/host/claimservingpark_records.go servingParkLedger.parkLocked authority C,J4 rev3=PROPOSED-seed; immutable source capture, root-local launch journal, exact stop bytes, legacy consumption, pending work and incumbent slot; only started databases replace slots; outer root authorization remains unbound file:Prepare@3312,file:Read@1564 d248f233de019cf9e28cf89bbf1e41f8e9c23089556ca709d18a9b076a04c09c |
| +internal/host/claimservingpark_records.go servingParkLedger.read authority C,J4 rev3=PROPOSED-seed; private retirement record by validated deterministic ID and strict record invariants file:Read@249 868c8c3f5682dc36738d6960d57f2f1fcc6512374c8f8e0790187bf69cf3de64 |
| +internal/host/claimservingpark_records.go servingParkLedger.reapLocked authority C,J4 rev3=PROPOSED-seed; root completion, retention disposition and fixed seven-day deadline; rollback or pending work refuses expiry 2352daae92630f8e33bd7c9bdd70f382de9da9be559de8522982eb73cf935c09 |
| +internal/host/claimservingpark_records.go servingParkLedger.save authority C,J4 rev3=PROPOSED-seed; validated private retirement record before durable rootstate write b6f940b1e21cf1beb67638effea958d2b1119695494a562e22bbbe0469d8af49 |
| +internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4 rev3=PROPOSED-seed; private box/host slot and pending retirement identities across incarnation changes file:Read@179 5c0d897b1bc9f0733d1dad18a90ce27900e371aaae2940e49cded65888d1c084 |
| +internal/host/claimservingpark_records.go servingParkLedger.withNamespace authority C,J4 rev3=PROPOSED-seed; private rootstate namespace lock and cancellation before invoking root authority; production host-guard composition remains unbound b7cfae44fdb7de9ef8130f5581710ae3a1a526bb059b4f616650172490f5c8ac |
| +internal/host/claimservingpark_records.go servingParkLedger.write authority C,J4 rev3=PROPOSED-seed; serialized private record sent only to the supplied rootstate store 0635d2c27871e67079119a0f2e3034e817b765d0fe3a47e7d9bdc66fb2779571 |
| +internal/host/claimservingpark_records.go type servingParkFilesystem authority C,J4 rev3=PROPOSED-seed; private filesystem effect interface over root-recorded retirement identities; no wire or public request fields file:Prepare@180 b93f0e2af16b66aee1ff71294e215eeba9e661fe696c917c982d9ebc2dc682c7 |
| +internal/host/claimservingpark_records.go validateServingParkRecord authority C,J4 rev3=PROPOSED-seed; version, immutable tuples, exact serving receipt bytes, source identities, per-file progress, disposition, completion and retention deadline invariants 0ab78151f2243224751d32fb309db9e36eed27487935c97f9531a72adf42fc62 |
| +internal/host/claimservingparksource_linux.go servingParkStateDirectory authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshots and authoritative serving observation; exact account/app/record, revision/enrollment, canonical nested path, ownership and descriptor identity; snapshot acquisition remains unbound ec4a121330c5c8f27343f21af0bbe458cced885d4754e96c0d87b12d9b4a8f35 |
| +@coverage internal/host cc1a23e90ccf77b923b6c02649b3823cfbb0454368c06571e8e5292315becfc6 |
| @coverage internal/hostbox 72c9b640d98c779a044579f70a103a68cd9819c794a0bb3e53f378d39ad11419 |
| @coverage internal/hostguard 51408b42f01be94a232e395494e5d154cc57736e32a6519ac6c03e9ff3d091e6 |