carlos / platform Public

Plan 14 integrated

lifecycle E2E fixes five production create defects (not for merge):Found by the production-wired lifecycle witness: vendor-unit census rejection, zero-stamp create participation rejection, missing ordinary operation acknowledgement, provisioning participation capture order, missing fresh-instance directory before claimed token mint. E2E reaches create plus install.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 204a78ce36420140b7509e4dc7095ab12bd048e8 parent ed1a3db
23 files changed, +994 −69
  • internal/host/claim_first_wake_linux_test.go +57 −0
  • internal/host/claimassembly.go +2 −1
  • internal/host/claimparticipation.go +7 −1
  • internal/host/claimproduction.go +1 −1
  • internal/host/claimtoken_directory.go +37 −0
  • internal/host/plan14_census_vendor_root_linux_test.go +70 −0
  • internal/host/plan14_create_participation_test.go +125 −0
  • internal/host/plan14_lifecycle_reboot_root_linux_test.go +76 −0
  • internal/host/plan14_lifecycle_retire_root_linux_test.go +124 −0
  • internal/host/plan14_lifecycle_root_linux_test.go +273 −56
  • internal/host/rootexecution_client.go +34 −0
  • internal/hostbox/census_vendor_linux.go +49 −0
  • internal/hostbox/census_vendor_other.go +5 −0
  • internal/hostbox/empty_census.go +4 −1
  • internal/hostbox/migration_census_linux.go +3 −0
  • internal/hostbox/reconcile.go +6 −0
  • internal/hostbox/reconcile_test.go +36 −0
  • internal/hostexec/participation.go +6 −0
  • internal/provision/testdata/plan14-root-reads-p3.tsv +20 −6
  • internal/rootparticipation/request.go +1 −1
  • internal/rootstate/link_linux.go +2 −1
  • internal/rootstate/state_linux.go +1 −1
  • internal/rootstate/unit_link_linux_test.go +55 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	308.533s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.554s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.543s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	249.610s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.466s
ok  	github.com/carlosframework/platform/internal/apperr	0.077s
ok  	github.com/carlosframework/platform/internal/applog	0.144s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.616s
?   	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.009s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.698s
ok  	github.com/carlosframework/platform/internal/changelog	0.305s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	395.245s
?   	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.334s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.004s
ok  	github.com/carlosframework/platform/internal/domains	2.462s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	27.163s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.130s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.016s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.150s
ok  	github.com/carlosframework/platform/internal/host	185.887s
ok  	github.com/carlosframework/platform/internal/hostactors	0.490s
ok  	github.com/carlosframework/platform/internal/hostbox	3.113s
ok  	github.com/carlosframework/platform/internal/hostclaim	1.034s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.013s
ok  	github.com/carlosframework/platform/internal/hostexec	7.096s
ok  	github.com/carlosframework/platform/internal/hostfence	0.063s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.794s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.032s
ok  	github.com/carlosframework/platform/internal/hostguard	1.097s
ok  	github.com/carlosframework/platform/internal/hostops	2.284s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.006s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.777s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.234s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.004s
ok  	github.com/carlosframework/platform/internal/infraview	0.011s
ok  	github.com/carlosframework/platform/internal/instancectl	5.285s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.869s
ok  	github.com/carlosframework/platform/internal/instancefs	0.008s
ok  	github.com/carlosframework/platform/internal/instancekey	0.005s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.011s
ok  	github.com/carlosframework/platform/internal/layout	1.929s
ok  	github.com/carlosframework/platform/internal/lease	9.818s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.031s
ok  	github.com/carlosframework/platform/internal/mail	0.333s
ok  	github.com/carlosframework/platform/internal/maintenance	0.121s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.815s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.183s
ok  	github.com/carlosframework/platform/internal/metrics	0.016s
ok  	github.com/carlosframework/platform/internal/moves	22.050s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.041s
ok  	github.com/carlosframework/platform/internal/objstore	0.376s
ok  	github.com/carlosframework/platform/internal/ops	0.237s
ok  	github.com/carlosframework/platform/internal/park	0.279s
ok  	github.com/carlosframework/platform/internal/privatefile	0.010s
ok  	github.com/carlosframework/platform/internal/provision	38.742s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.014s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	6.323s
ok  	github.com/carlosframework/platform/internal/relay	7.266s
ok  	github.com/carlosframework/platform/internal/release	23.536s
ok  	github.com/carlosframework/platform/internal/rollout	4.342s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.060s
ok  	github.com/carlosframework/platform/internal/rootstate	0.031s
ok  	github.com/carlosframework/platform/internal/runs	0.578s
ok  	github.com/carlosframework/platform/internal/s3compat	0.008s
ok  	github.com/carlosframework/platform/internal/safefile	0.008s
ok  	github.com/carlosframework/platform/internal/schedule	2.850s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	13.148s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.954s
ok  	github.com/carlosframework/platform/internal/status	0.050s
ok  	github.com/carlosframework/platform/internal/steering	0.007s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.347s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.007s
ok  	github.com/carlosframework/platform/internal/unixdial	0.004s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.793s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 48496802 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/16 15:48:28 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF0D375CD61391, 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.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/host/claim_first_wake_linux_test.go b/internal/host/claim_first_wake_linux_test.go
new file mode 100644
index 000000000..0819fac05
--- /dev/null
+++ b/internal/host/claim_first_wake_linux_test.go
@@ -0,0 +1,57 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestClaimFirstWakeCreatesAuthorizedInstanceDirectory(t *testing.T) {
+ b := &activator.ExecBackend{DataDir: t.TempDir()}
+ host := "first-claim.example"
+ rt := registry.Route{Host: host, DBPath: provision.NestedDBPath(b.DataDir, host), Socket: provision.NestedSocket(host)}
+ parent := filepath.Join(b.DataDir, "instances")
+ if err := os.Mkdir(parent, 0700); err != nil {
+ t.Fatal(err)
+ }
+ denied := errors.New("not authorized")
+ if err := ensureClaimLaunchToken(t.Context(), b, rt, func(context.Context, func() error) error { return denied }); !errors.Is(err, denied) {
+ t.Fatal("denial", err)
+ }
+ dir := provision.InstanceDir(b.DataDir, host)
+ if _, err := os.Lstat(dir); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("denial created directory", err)
+ }
+ authorize := func(_ context.Context, write func() error) error { return write() }
+ if err := ensureClaimLaunchToken(t.Context(), b, rt, authorize); err != nil {
+ t.Fatal("first claimed wake", err)
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, host+".env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "CARLOS_ADMIN_TOKEN=") || !strings.Contains(string(raw), "CARLOS_INSTANCE_KEY=") {
+ t.Fatal("missing initialized launch credentials")
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ victim := t.TempDir()
+ if err := os.Symlink(victim, dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensureClaimLaunchToken(t.Context(), b, rt, authorize); err == nil {
+ t.Fatal("symlinked instance accepted")
+ }
+ entries, err := os.ReadDir(victim)
+ if err != nil || len(entries) != 0 {
+ t.Fatal("symlink target changed", entries, err)
+ }
+}
diff --git a/internal/host/claimassembly.go b/internal/host/claimassembly.go
index afaab668b..d54c9f48e 100644
--- a/internal/host/claimassembly.go
+++ b/internal/host/claimassembly.go
@@ -184,7 +184,6 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
a.Reconcile.RemoteRetire = func(ctx context.Context, target hostguard.FenceTarget) error {
return RetireClaimOverSocket(ctx, controlsock.Path, target)
}
- a.Provision = a.Reconcile.Configure
check := claimBoundIdentity(a.Identity.Verify, actor)
data := hostbox.RequestStore{Store: a.Data, Check: check}
writer := hostbox.Writer{HomeRegion: actor.HomeRegion, Loss: a.LossAdmission, Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
@@ -193,9 +192,11 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
if err := local.bind(ctx, &a); err != nil {
return ClaimProviders{}, err
}
+ a.Reconcile.Reconciler.Prepare = local.Prepare
source.Prepare = local.Prepare
a.Reconcile.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: source}
}
+ a.Provision = a.Reconcile.Configure
if a.moveRead != nil {
if err := a.moveRead.bind(ctx, source); err != nil {
return ClaimProviders{}, err
diff --git a/internal/host/claimparticipation.go b/internal/host/claimparticipation.go
index 05d4cac97..1d33e6a76 100644
--- a/internal/host/claimparticipation.go
+++ b/internal/host/claimparticipation.go
@@ -426,5 +426,11 @@ func (p *productionClaimLocal) participateHost(ctx context.Context, actor hostbo
}
}
}
- return p.prepareWith(ctx, actor, o)
+ if err := p.prepareWith(ctx, actor, o); err != nil {
+ return err
+ }
+ if p.legacyParticipation {
+ return nil
+ }
+ return p.acknowledgeOperation(ctx, actor, o)
}
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index bdf15b060..dc703ccef 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -547,7 +547,7 @@ func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminT
return claimBoundSnapshot(ctx, backend, rt, func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error) { return unit, nil })
}
}
- if err := backend.EnsureClaimAdminToken(ctx, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
+ if err := ensureClaimLaunchToken(ctx, backend, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
return ClaimServingLaunch{}, err
}
snapshot, err := backend.SelectClaimLaunch(ctx, rt)
diff --git a/internal/host/claimtoken_directory.go b/internal/host/claimtoken_directory.go
new file mode 100644
index 000000000..413e284b4
--- /dev/null
+++ b/internal/host/claimtoken_directory.go
@@ -0,0 +1,37 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func ensureClaimLaunchToken(ctx context.Context, backend *activator.ExecBackend, route registry.Route, authorize func(context.Context, func() error) error) error {
+ if authorize == nil {
+ return backend.EnsureClaimAdminToken(ctx, route, nil)
+ }
+ return backend.EnsureClaimAdminToken(ctx, route, func(call context.Context, effect func() error) error {
+ return authorize(call, func() error {
+ if layout, _ := backend.Layout(route); layout == activator.LayoutNested {
+ dir := provision.InstanceDir(backend.DataDir, route.Host)
+ parent := filepath.Dir(dir)
+ if _, err := instancefs.EnsureDirBeneath(parent, filepath.Base(dir), 0700); err != nil {
+ return err
+ }
+ fd, err := instancefs.OpenDirBeneath(parent, ".")
+ if err != nil {
+ return err
+ }
+ if err = errors.Join(fd.Sync(), fd.Close()); err != nil {
+ return err
+ }
+ }
+ return effect()
+ })
+ })
+}
diff --git a/internal/host/plan14_census_vendor_root_linux_test.go b/internal/host/plan14_census_vendor_root_linux_test.go
new file mode 100644
index 000000000..028a19e3b
--- /dev/null
+++ b/internal/host/plan14_census_vendor_root_linux_test.go
@@ -0,0 +1,70 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func TestPlan14FirstInstallWithVendorUnitsRootRunner(t *testing.T) {
+ servingRootRunner(t)
+ uid, _ := generationRunnerRegistryUser(t)
+ units, err := os.MkdirTemp("/var/lib", "plan14-vendor-census-")
+ lifecycleCheck(t, "units", err)
+ defer os.RemoveAll(units)
+ lifecycleCheck(t, "public units ancestry", os.Chmod(units, 0755))
+ f, root, q := plan14InstallRegistration(t, hostclaim.KindServing, "")
+ root.Roots["units"] = units
+ selected := hostbox.RecordSelection{Host: q.Target.Host, Key: q.RecordKey, ETag: q.RecordETag, Incarnation: q.Target.Incarnation, Family: "instance"}
+ check := func() error { return root.Reconcile.Census.CheckLocal(t.Context(), f.read(), selected) }
+ lifecycleCheck(t, "empty census", check())
+ target := "/lib/systemd/system/systemd-journald.service"
+ _, err = os.Stat(target)
+ lifecycleCheck(t, "vendor unit", err)
+ wants := filepath.Join(units, "multi-user.target.wants")
+ lifecycleCheck(t, "wants", os.Mkdir(wants, 0755))
+ link := filepath.Join(wants, "systemd-journald.service")
+ lifecycleCheck(t, "vendor link", os.Symlink(target, link))
+ lifecycleCheck(t, "template wants", os.Mkdir(filepath.Join(units, "sshd@.service.wants"), 0755))
+ lifecycleCheck(t, "template wants link", os.Symlink(target, filepath.Join(units, "sshd@.service.wants", "sshd-keygen.service")))
+ lifecycleCheck(t, "template link", os.Symlink("/lib/systemd/system/getty@.service", filepath.Join(wants, "getty@tty1.service")))
+ lifecycleCheck(t, "first installation with OS unit", check())
+ lifecycleCheck(t, "migration with OS unit", hostbox.CheckMigrationRoots(t.Context(), root.Roots, root.Reconcile.Census.SharedPaths, nil))
+ for _, attack := range []string{"service owner", "foreign target", "host alias", "carlos alias", "regular file", "writable ancestor"} {
+ t.Run(attack, func(t *testing.T) {
+ bad := filepath.Join(wants, "unexpected.service")
+ switch attack {
+ case "service owner":
+ lifecycleCheck(t, "owner", os.Lchown(link, uid, -1))
+ defer os.Lchown(link, 0, 0)
+ case "writable ancestor":
+ lifecycleCheck(t, "mode", os.Chmod(wants, 0777))
+ defer os.Chmod(wants, 0755)
+ case "foreign target":
+ lifecycleCheck(t, "link", os.Symlink("/etc/passwd", bad))
+ defer os.Remove(bad)
+ case "host alias":
+ bad = filepath.Join(wants, q.Target.Host+".service")
+ lifecycleCheck(t, "link", os.Symlink(target, bad))
+ defer os.Remove(bad)
+ case "carlos alias":
+ bad = filepath.Join(wants, "carlos-instance@other.service")
+ lifecycleCheck(t, "link", os.Symlink(target, bad))
+ defer os.Remove(bad)
+ case "regular file":
+ lifecycleCheck(t, "file", os.WriteFile(bad, []byte("[Service]\nExecStart=/bin/true\n"), 0644))
+ defer os.Remove(bad)
+ }
+ if err := check(); err == nil {
+ t.Fatal("unsafe unit inventory accepted")
+ }
+ })
+ }
+ _, err = root.commitRootInventory(t.Context(), q)
+ lifecycleCheck(t, "first production registration", err)
+}
diff --git a/internal/host/plan14_create_participation_test.go b/internal/host/plan14_create_participation_test.go
new file mode 100644
index 000000000..20a971f32
--- /dev/null
+++ b/internal/host/plan14_create_participation_test.go
@@ -0,0 +1,125 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ execlease "github.com/carlosframework/platform/internal/hostexec"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestPlan14CreateParticipationBeforeInstallation(t *testing.T) {
+ f, root, _ := plan14InstallRegistration(t, hostclaim.KindServing, "")
+ before := f.read()
+ h := before.Claim.Holder
+ rec := provision.Record{Schema: 1, Host: before.Host, Account: h.Account, App: h.App, Channel: "stable", Region: "ie", CreatedAt: f.now}
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ created, err := hostclaim.Update(t.Context(), f.claims, before, func(c *hostclaim.Claim) error {
+ *c = hostclaim.Claim{V: c.V, Host: c.Host, Gen: c.Gen}
+ return hostclaim.BeginSelectedCreate(hostclaim.NewHolder{Host: rec.Host, Account: rec.Account, App: rec.App, Family: "instance", Key: h.Key, Region: "ie", RecordRegion: "ie", CreatedAt: rec.CreatedAt}, body, "create", "test", f.now, "box-1", "en", "app-id", 1, "own", "record")(c)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ q := execlease.ExecutionRequest{Schema: 1, ID: "begin", Operation: "executionBegin", Host: created.Host, Incarnation: created.Claim.Holder.Incarnation, Op: created.Claim.Op.ID}
+ if !q.Valid() {
+ t.Fatal("create participation request refused")
+ }
+ a, err := root.executionAuthority(t.Context(), q, execlease.ControllerProcess{})
+ if err != nil || !a.ParticipationOnly || a.Installation != "" || a.Kind != hostclaim.KindCreating {
+ t.Fatalf("create authority: %+v %v", a, err)
+ }
+ if _, err = root.Stop.Coordinator.Guard.Root.View(t.Context(), q.Host, hostguard.Captured{Incarnation: q.Incarnation}); err == nil {
+ t.Fatal("participation installed root authority")
+ }
+ for _, attack := range []string{"session", "stamp", "operation", "enrollment", "artifact"} {
+ t.Run(attack, func(t *testing.T) {
+ request, handler := q, root
+ switch attack {
+ case "session":
+ request.Session = 1
+ case "stamp":
+ request.Stamp = 1
+ case "operation":
+ request.Op = "other"
+ case "enrollment":
+ actor, e := root.InventoryActor(t.Context())
+ if e != nil {
+ t.Fatal(e)
+ }
+ actor.Enrollment = "foreign"
+ handler.InventoryActor = func(context.Context) (hostbox.Actor, error) { return actor, nil }
+ case "artifact":
+ path := filepath.Join(root.Roots["data"], "unaccounted")
+ if err := os.WriteFile(path, []byte("old state"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(path)
+ }
+ if _, err := handler.executionAuthority(t.Context(), request, execlease.ControllerProcess{}); err == nil {
+ t.Fatal("unauthorized create participation")
+ }
+ })
+ }
+}
+
+func TestPlan14CreateAcknowledgementProductionClient(t *testing.T) {
+ f, root, _, _, local := productionExecutionFixture(t, hostclaim.KindLanding)
+ before := f.read()
+ h := before.Claim.Holder
+ rec := provision.Record{Schema: 1, Host: before.Host, Account: h.Account, App: h.App, Channel: "stable", Region: "ie", CreatedAt: f.now}
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.claims, before, func(c *hostclaim.Claim) error {
+ *c = hostclaim.Claim{V: c.V, Host: c.Host, Gen: c.Gen}
+ return hostclaim.BeginSelectedCreate(hostclaim.NewHolder{Host: rec.Host, Account: rec.Account, App: rec.App, Family: "instance", Key: h.Key, Region: "ie", RecordRegion: "ie", CreatedAt: rec.CreatedAt}, body, "create", "test", f.now, "box-1", "en", "app-id", 1, "own", "record")(c)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ actor, err := root.InventoryActor(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = local.participateHost(t.Context(), actor, rec.Host); err != nil {
+ t.Fatal("participate and acknowledge", err)
+ }
+ observed := f.read()
+ var ack hostclaim.ExecutionAcknowledgement
+ if err = json.Unmarshal([]byte(observed.Claim.Op.Journal["exec_ack."+actor.Box]), &ack); err != nil {
+ t.Fatal("missing production acknowledgement", err)
+ }
+ state, err := local.slots[rec.Host].Controller.Ready(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ack.Op != state.Op || ack.Enrollment != state.Enrollment || ack.LeaseETag != state.LeaseETag || ack.ControllerID != state.ControllerID || ack.BootID != state.BootID || !ack.Expires.Equal(state.Expires) {
+ t.Fatal("acknowledgement differs from root lease", ack, state)
+ }
+ if err = local.participateHost(t.Context(), actor, rec.Host); err != nil {
+ t.Fatal("acknowledgement replay", err)
+ }
+ if got := f.read().Claim.Op.Journal["exec_ack."+actor.Box]; got != observed.Claim.Op.Journal["exec_ack."+actor.Box] {
+ t.Fatal("acknowledgement replaced")
+ }
+ completed, err := hostclaim.Update(t.Context(), f.claims, f.read(), hostclaim.CompleteCreate("create", actor.Box))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = local.prepareWith(t.Context(), actor, completed); err != nil {
+ t.Fatal("settle create before installation", err)
+ }
+}
diff --git a/internal/host/plan14_lifecycle_reboot_root_linux_test.go b/internal/host/plan14_lifecycle_reboot_root_linux_test.go
new file mode 100644
index 000000000..fabbc9fcf
--- /dev/null
+++ b/internal/host/plan14_lifecycle_reboot_root_linux_test.go
@@ -0,0 +1,76 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+)
+
+type lifecycleRemoteCheckpoint struct {
+ Boot, Snapshot string
+ Objects []seedRebootObject
+}
+
+func lifecycleSaveBucket(t *testing.T, model *leasetest.VersionedBucket) {
+ t.Helper()
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ lifecycleCheck(t, "checkpoint boot", err)
+ saved := lifecycleRemoteCheckpoint{Boot: strings.TrimSpace(string(boot)), Snapshot: model.Snapshot()}
+ versions, err := model.ListVersions(t.Context(), "data", "")
+ lifecycleCheck(t, "checkpoint versions", err)
+ for _, version := range versions {
+ object := seedRebootObject{Version: version}
+ if !version.DeleteMarker {
+ object.Body, err = model.GetVersion(t.Context(), "data", version.Key, version.VersionID)
+ lifecycleCheck(t, "checkpoint bytes", err)
+ }
+ saved.Objects = append(saved.Objects, object)
+ }
+ raw, err := json.Marshal(saved)
+ lifecycleCheck(t, "checkpoint JSON", err)
+ lifecycleCheck(t, "checkpoint remote service", os.WriteFile(lifecycleHome+"/remote.json", raw, 0600))
+}
+
+func lifecycleRestoreBucket(t *testing.T, model *leasetest.VersionedBucket) {
+ t.Helper()
+ raw, err := os.ReadFile(lifecycleHome + "/remote.json")
+ lifecycleCheck(t, "read remote checkpoint", err)
+ var saved lifecycleRemoteCheckpoint
+ lifecycleCheck(t, "decode remote checkpoint", json.Unmarshal(raw, &saved))
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ lifecycleCheck(t, "read reboot identity", err)
+ if saved.Boot == "" || saved.Boot == strings.TrimSpace(string(boot)) {
+ t.Fatal("lifecycle runner did not reboot")
+ }
+ sort.Slice(saved.Objects, func(i, j int) bool { return saved.Objects[i].Version.VersionID < saved.Objects[j].Version.VersionID })
+ next := 1
+ for _, object := range saved.Objects {
+ n, err := strconv.Atoi(strings.TrimPrefix(object.Version.VersionID, "v"))
+ lifecycleCheck(t, "version number", err)
+ for next < n {
+ id := model.Put("data", "checkpoint-gap", nil)
+ lifecycleCheck(t, "preserve version gap", model.DeleteVersion(t.Context(), "data", "checkpoint-gap", id))
+ next++
+ }
+ var id string
+ if object.Version.DeleteMarker {
+ id = model.BareDelete("data", object.Version.Key)
+ } else {
+ id = model.Put("data", object.Version.Key, object.Body)
+ }
+ if id != object.Version.VersionID {
+ t.Fatal("remote version identity changed", id, object.Version.VersionID)
+ }
+ next++
+ }
+ if model.Snapshot() != saved.Snapshot {
+ t.Fatal("remote state changed across reboot")
+ }
+}
diff --git a/internal/host/plan14_lifecycle_retire_root_linux_test.go b/internal/host/plan14_lifecycle_retire_root_linux_test.go
new file mode 100644
index 000000000..27b16d3d4
--- /dev/null
+++ b/internal/host/plan14_lifecycle_retire_root_linux_test.go
@@ -0,0 +1,124 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func lifecycleDeleteDriver(t *testing.T, phase string, raw release.Store, key ed25519.PrivateKey, actor hostbox.Actor, pointer release.SystemPointer) func() {
+ t.Helper()
+ var operation *hostops.OperationHandle
+ var controller hostops.Controller
+ ctx := t.Context()
+ serial := 0
+ return func() {
+ if phase != "2" {
+ t.Fatal("unexpected deletion before reboot")
+ }
+ if operation == nil {
+ var control *hostops.ControlWriter
+ controller, _, control = lifecycleController(t, raw, key, actor, pointer)
+ admission, err := hostops.ReadCanonicalAdmission(ctx, raw, "bab", "lifecycle")
+ lifecycleCheck(t, "delete admission", err)
+ operation, err = control.BeginExclusive(ctx, "bab", "lifecycle", admission.Value.AppID, hostops.AppOperation{ID: "delete-lifecycle", Kind: hostops.OpDeleting, AppID: admission.Value.AppID, Intent: map[string]string{"host": lifecycleHost}}, hostops.RegistrationOwner{Console: "console", Enrollment: strings.Repeat("d", 32), Controller: "console-controller"})
+ lifecycleCheck(t, "exclusive deletion", err)
+ ctx = operation.Lifecycle(ctx)
+ }
+ obs, err := hostclaim.Read(ctx, controller.Store, lifecycleHost)
+ lifecycleCheck(t, "delete observation", err)
+ if obs.Claim.Free() {
+ return
+ }
+ switch obs.Claim.Op.Kind {
+ case hostclaim.KindServing:
+ _, err = controller.BeginRetire(ctx, obs.Claim.Holder.Key, "runner", obs.Claim.Holder.Incarnation)
+ case hostclaim.KindDeleting:
+ serial++
+ _, err = operation.Runner(time.Now).Run(ctx, hostops.Effect{ID: "retire-" + strconv.Itoa(serial), Resource: "record:" + lifecycleHost, Action: "retire"}, func(call context.Context) (string, error) {
+ e := controller.Retire(call, lifecycleHost, obs.Claim.Op.ID)
+ if errors.Is(e, hostops.ErrPending) {
+ current, rerr := hostclaim.Read(call, controller.Store, lifecycleHost)
+ if rerr == nil && current.Claim.Op.Kind == hostclaim.KindParking {
+ return "parked", nil
+ }
+ return "", errors.Join(hostops.ErrNotApplied, e)
+ }
+ return "retired", e
+ })
+ case hostclaim.KindParking:
+ if len(obs.Claim.Obligations) == 0 {
+ err = controller.Free(ctx, lifecycleHost, obs.Claim.Op.ID)
+ }
+ default:
+ t.Fatal("unexpected retirement phase", obs.Claim.Op.Kind)
+ }
+ if err != nil && !errors.Is(err, hostops.ErrPending) && !errors.Is(err, hostops.ErrNotApplied) {
+ lifecycleCheck(t, "delete transition", err)
+ }
+ }
+}
+
+func lifecycleVerifyRetiredPins(t *testing.T, store release.Store, key ed25519.PrivateKey) {
+ t.Helper()
+ ctx := t.Context()
+ recordKey := provision.RecordKey("bab", "lifecycle", lifecycleHost)
+ _, _, err := store.Get(ctx, recordKey)
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("deleted record retained", err)
+ }
+ source := productionRootGenerationPins()
+ lock, err := rootstate.Open(rootReleasePrivate, false, false)
+ lifecycleCheck(t, "release lock", err)
+ defer lock.Close()
+ lifecycleCheck(t, "production pins after actual retirement", rootstate.WithRelease(ctx, lock, func() error {
+ pins, err := source.StagedPins(ctx, release.OptDir())
+ if err != nil {
+ return err
+ }
+ pinned, err := pins.Refers(release.VersionPath(release.OptDir(), "bab", "v1", "lifecycle"))
+ if err != nil {
+ return err
+ }
+ if pinned {
+ t.Fatal("retired lifecycle generation remains pinned")
+ }
+ return nil
+ }))
+ body := []byte("signed sibling")
+ manifest := release.Manifest{App: "sibling", Version: "v9", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
+ lifecycleCheck(t, "ship sibling", release.Ship(ctx, store, "bab", manifest, map[string][]byte{release.AdoptPlatform(): body}))
+ lifecycleCheck(t, "promote sibling", release.Promote(ctx, store, key, "bab", "sibling", "stable", "v9"))
+ lifecycleCheck(t, "stage sibling", release.StageArtifact(release.OptDir(), "bab", "v9", "sibling", body))
+ path := release.StagedManifestPath(release.OptDir(), "bab", "v9", "sibling")
+ lifecycleCheck(t, "damage sibling manifest", os.WriteFile(path, []byte("broken sibling manifest"), 0644))
+ lifecycleCheck(t, "repair sibling with production pins", rootstate.WithRelease(ctx, lock, func() error {
+ proof := release.NewRootProofGate(filepath.Join(t.TempDir(), "policy.json"), filepath.Join(t.TempDir(), "bakes"))
+ _, e := release.StageHeldVerifiedWithPins(ctx, store, key.Public().(ed25519.PublicKey), nil, release.OptDir(), registry.Route{Host: "sibling.example", Account: "bab", App: "sibling", Kind: "instance", Channel: "stable", Status: "landing", HoldOp: "repair"}, "v9", proof, source)
+ return e
+ }))
+ repaired, err := os.ReadFile(path)
+ if err != nil || string(repaired) == "broken sibling manifest" {
+ t.Fatal("sibling repair failed", err)
+ }
+ _, _, err = store.Get(ctx, recordKey)
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("record resurrected", err)
+ }
+}
diff --git a/internal/host/plan14_lifecycle_root_linux_test.go b/internal/host/plan14_lifecycle_root_linux_test.go
index 62386f8c9..a9c4f1a5b 100644
--- a/internal/host/plan14_lifecycle_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_root_linux_test.go
@@ -6,11 +6,13 @@ import (
"bytes"
"context"
"crypto/ed25519"
+ "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "errors"
"io"
"net"
"net/http"
@@ -29,6 +31,7 @@ import (
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/instancectl"
@@ -157,14 +160,45 @@ func lifecycleEnvironment(t *testing.T, endpoint string) {
}
func TestPlan14LifecycleColdWakeRootRunner(t *testing.T) {
+ t.Setenv("PLAN14_LIFECYCLE_PHASE", "probe")
+ lifecycleRun(t)
+}
+
+func TestRootRebootPlan14LifecycleEndToEndPhase1(t *testing.T) {
+ t.Setenv("PLAN14_LIFECYCLE_PHASE", "1")
+ TestPlan14LifecycleEndToEndRootRunner(t)
+}
+
+func TestRootRebootPlan14LifecycleEndToEndPhase2(t *testing.T) {
+ t.Setenv("PLAN14_LIFECYCLE_PHASE", "2")
+ TestPlan14LifecycleEndToEndRootRunner(t)
+}
+
+func TestPlan14LifecycleEndToEndRootRunner(t *testing.T) {
+ if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "" {
+ t.Fatal("lifecycle E2E requires root-runner --reboot Plan14LifecycleEndToEnd")
+ }
+ lifecycleRun(t)
+}
+
+func lifecycleRun(t *testing.T) {
if os.Getenv("PLAN14_LIFECYCLE_AGENT") == "1" {
lifecycleAgent(t)
return
}
+ phase := os.Getenv("PLAN14_LIFECYCLE_PHASE")
+ resume := phase == "2"
uid, gid := generationRunnerRegistryUser(t)
for _, path := range []string{lifecycleHome, "/data", "/opt/carlos", "/run/carlos", "/run/carlos-control", "/etc/carlos", "/var/lib/carlos-claims", "/var/lib/carlos-release"} {
- lifecycleCheck(t, "exclusive fixture "+path, os.Mkdir(path, 0755))
- t.Cleanup(func() { lifecycleCheck(t, "cleanup "+path, os.RemoveAll(path)) })
+ if !resume || strings.HasPrefix(path, "/run/") {
+ lifecycleCheck(t, "exclusive fixture "+path, os.Mkdir(path, 0755))
+ }
+ t.Cleanup(func() {
+ if phase == "1" && !t.Failed() {
+ return
+ }
+ lifecycleCheck(t, "cleanup "+path, os.RemoveAll(path))
+ })
}
lifecycleCheck(t, "private authority", os.Chmod("/var/lib/carlos-claims", 0700))
lifecycleCheck(t, "input service owner", os.Chown(lifecycleHome, uid, gid))
@@ -179,6 +213,9 @@ func TestPlan14LifecycleColdWakeRootRunner(t *testing.T) {
}
model := leasetest.NewVersionedBucket()
model.AddBucket("data")
+ if resume {
+ lifecycleRestoreBucket(t, model)
+ }
handler, _ := leasetest.NewS3Server(model)
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
@@ -188,60 +225,62 @@ func TestPlan14LifecycleColdWakeRootRunner(t *testing.T) {
identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", ControlBucket: "deployment"})
actor, err := identity.Verify(t.Context())
lifecycleCheck(t, "identity", err)
- roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)).Public().(ed25519.PublicKey), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
- raw, err := release.SignRootEnvelope(release.RootSigner(key), roster)
- lifecycleCheck(t, "sign roster", err)
- _, err = store.Put(t.Context(), hostactors.Key, raw, "")
- lifecycleCheck(t, "roster", err)
- filesSeedConstructionMigration(t, "/opt/carlos", actor.ControlBucket, actor.Box, actor.Enrollment)
- state, err := rootstate.Open(rootparticipation.Namespace, true, false)
- lifecycleCheck(t, "census state", err)
- census := rootparticipation.Census{Schema: 1, Box: actor.Box, Enrollment: actor.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}
- raw, err = json.Marshal(census)
- lifecycleCheck(t, "census encode", err)
- lifecycleCheck(t, "census", state.Write("census.json", raw))
- state.Close()
- failures, err := rootstate.Open(rollout.RootFailuresDir, true, false)
- lifecycleCheck(t, "failure state", err)
- _, err = rollout.InitializeRootFailures(failures, rootUpdatesMigration(census), nil)
- lifecycleCheck(t, "initialize migration failures", err)
- failures.Close()
- pins, err := rootstate.Open(release.RootGenerationsDir, true, false)
- lifecycleCheck(t, "generation store", err)
- pins.Close()
+ if !resume {
+ roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)).Public().(ed25519.PublicKey), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ raw, err := release.SignRootEnvelope(release.RootSigner(key), roster)
+ lifecycleCheck(t, "sign roster", err)
+ _, err = store.Put(t.Context(), hostactors.Key, raw, "")
+ lifecycleCheck(t, "roster", err)
+ filesSeedConstructionMigration(t, "/opt/carlos", actor.ControlBucket, actor.Box, actor.Enrollment)
+ state, err := rootstate.Open(rootparticipation.Namespace, true, false)
+ lifecycleCheck(t, "census state", err)
+ census := rootparticipation.Census{Schema: 1, Box: actor.Box, Enrollment: actor.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}
+ raw, err = json.Marshal(census)
+ lifecycleCheck(t, "census encode", err)
+ lifecycleCheck(t, "census", state.Write("census.json", raw))
+ state.Close()
+ failures, err := rootstate.Open(rollout.RootFailuresDir, true, false)
+ lifecycleCheck(t, "failure state", err)
+ _, err = rollout.InitializeRootFailures(failures, rootUpdatesMigration(census), nil)
+ lifecycleCheck(t, "initialize migration failures", err)
+ failures.Close()
+ pins, err := rootstate.Open(release.RootGenerationsDir, true, false)
+ lifecycleCheck(t, "generation store", err)
+ pins.Close()
+ }
binary, err := os.Executable()
lifecycleCheck(t, "test binary", err)
image, err := os.ReadFile(binary)
lifecycleCheck(t, "test image", err)
- lifecycleCheck(t, "executor", os.WriteFile("/opt/carlos/carlos", image, 0755))
- lifecycleCheck(t, "guard executor", os.WriteFile("/opt/carlos/system/guard/carlos", image, 0755))
+ if !resume {
+ lifecycleCheck(t, "executor", os.WriteFile("/opt/carlos/carlos", image, 0755))
+ lifecycleCheck(t, "guard executor", os.WriteFile("/opt/carlos/system/guard/carlos", image, 0755))
+ }
sum := sha256.Sum256(image)
platform := runtime.GOOS + "-" + runtime.GOARCH
pointer := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: hex.EncodeToString(sum[:])}}}}
- signed, err := release.SignRootEnvelope(release.RootSigner(key), pointer)
- lifecycleCheck(t, "sign executor", err)
- identities, err := rootstate.Open("/opt/carlos/system/guard/identities", false, true)
- lifecycleCheck(t, "executor identities", err)
- lifecycleCheck(t, "executor identity", identities.Write("v2.0.0.json", signed))
- identities.Close()
- // Create uses the lifecycle transition functions and ordinary record writer;
- // it never inserts a fence, launch, settings or installation record.
+ if !resume {
+ signed, err := release.SignRootEnvelope(release.RootSigner(key), pointer)
+ lifecycleCheck(t, "sign executor", err)
+ identities, err := rootstate.Open("/opt/carlos/system/guard/identities", false, true)
+ lifecycleCheck(t, "executor identities", err)
+ lifecycleCheck(t, "executor identity", identities.Write("v2.0.0.json", signed))
+ identities.Close()
+ }
+
now := time.Now().UTC()
rec := provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "ie", CreatedAt: now}
- raw, err = json.Marshal(rec)
- lifecycleCheck(t, "record input", err)
- recordKey := provision.RecordKey(rec.Account, rec.App, rec.Host)
- before, err := hostclaim.Read(t.Context(), store, rec.Host)
- lifecycleCheck(t, "absent claim", err)
- created, err := hostclaim.Update(t.Context(), store, before, hostclaim.BeginCreate(hostclaim.NewHolder{Host: rec.Host, Account: rec.Account, App: rec.App, Family: "instance", Key: recordKey, Region: "ie", RecordRegion: "ie", CreatedAt: now}, raw, "create", "runner", now))
- lifecycleCheck(t, "begin create", err)
- _, _, err = provision.WriteRecord(provision.WithRecordSequence(t.Context()), store, recordKey, []byte(`{}`), raw, "")
- lifecycleCheck(t, "create record", err)
- _, err = hostclaim.Update(t.Context(), store, created, hostclaim.CompleteCreate("create", actor.Box))
- lifecycleCheck(t, "complete create", err)
- manifest := release.Manifest{App: rec.App, Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
- lifecycleCheck(t, "ship", release.Ship(t.Context(), store, rec.Account, manifest, map[string][]byte{release.AdoptPlatform(): image}))
- lifecycleCheck(t, "promote", release.Promote(t.Context(), store, key, rec.Account, rec.App, "stable", "v1"))
+ completeCreate := func() error { return nil }
+ if !resume {
+ completeCreate = lifecycleCreate(t, store, key, actor, pointer, rec)
+ manifest := release.Manifest{App: rec.App, Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
+ lifecycleCheck(t, "ship", release.Ship(t.Context(), store, rec.Account, manifest, map[string][]byte{release.AdoptPlatform(): image}))
+ lifecycleCheck(t, "promote", release.Promote(t.Context(), store, key, rec.Account, rec.App, "stable", "v1"))
+ } else {
+ found, _, e := provision.FindRecordByHost(t.Context(), store, lifecycleHost)
+ lifecycleCheck(t, "retained record", e)
+ rec = *found
+ }
// Only deployment inputs are service-owned. Root authority remains private.
lifecycleCheck(t, "deployment owner", filepath.Walk(lifecycleHome+"/deployment", func(path string, info os.FileInfo, err error) error {
if err != nil {
@@ -278,14 +317,22 @@ func TestPlan14LifecycleColdWakeRootRunner(t *testing.T) {
lifecycleCheck(t, "root recovery", rootHTTP.RecoverPending(ctx))
rootHTTP.Start(ctx)
lifecycleInstallUnits(t)
- lifecycleStartAgent(t, server.URL, func() {
+ lifecycleStartAgent(t, server.URL, completeCreate, func() {
_, _, err = release.StageSignedHead(ctx, store, key.Public().(ed25519.PublicKey), nil, "/opt/carlos", registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Channel: "stable"})
lifecycleCheck(t, "signed stage", err)
lifecycleCheck(t, "point initial version", release.PointLive("/opt/carlos", rec.Host, rec.Account, "v1", rec.App))
- })
+ }, lifecycleDeleteDriver(t, phase, store, key, actor, pointer))
+ if phase == "1" {
+ lifecycleSaveBucket(t, model)
+ }
+ if phase == "2" {
+ lifecycleVerifyRetiredPins(t, store, key)
+ t.Fatal("lifecycle crash recovery checkpoints remain required")
+ }
+
}
-func lifecycleStartAgent(t *testing.T, endpoint string, stage func()) {
+func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() error, stage func(), deletion func()) {
t.Helper()
unit, err := unitsFS.ReadFile("units/carlos-edge.service")
lifecycleCheck(t, "edge template", err)
@@ -298,7 +345,7 @@ func lifecycleStartAgent(t *testing.T, endpoint string, stage func()) {
}
lines = append(lines, line)
}
- lines = append(lines, "[Service]", "Restart=no", "ExecStart=/opt/carlos/carlos -test.run=^TestPlan14LifecycleColdWakeRootRunner$ -test.v -test.timeout=4m", "Environment=PLAN14_LIFECYCLE_AGENT=1", "Environment=CARLOS_S3_ENDPOINT="+endpoint, "Environment=TMPDIR=/run/carlos", "ReadWritePaths="+lifecycleHome)
+ lines = append(lines, "[Service]", "Restart=no", "ExecStart=/opt/carlos/carlos -test.run=^TestPlan14LifecycleEndToEndRootRunner$ -test.v -test.timeout=4m", "Environment=PLAN14_LIFECYCLE_AGENT=1", "Environment=PLAN14_LIFECYCLE_PHASE="+os.Getenv("PLAN14_LIFECYCLE_PHASE"), "Environment=CARLOS_S3_ENDPOINT="+endpoint, "Environment=TMPDIR=/run/carlos", "ReadWritePaths="+lifecycleHome)
path := "/run/systemd/system/carlos-edge.service"
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
lifecycleCheck(t, "edge exclusive", err)
@@ -315,8 +362,17 @@ func lifecycleStartAgent(t *testing.T, endpoint string, stage func()) {
out, err = exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput()
lifecycleCheck(t, string(out), err)
deadline := time.Now().Add(3 * time.Minute)
- staged := false
+ staged, created := false, false
for time.Now().Before(deadline) {
+ if !created {
+ err := completeCreate()
+ if err == nil {
+ created = true
+ lifecycleCheck(t, "create ack", os.WriteFile("/run/carlos/lifecycle-created", []byte("ready"), 0644))
+ } else if !errors.Is(err, hostops.ErrPending) {
+ lifecycleCheck(t, "complete create", err)
+ }
+ }
if !staged {
if _, err := os.Stat("/run/carlos/lifecycle-stage"); err == nil {
stage()
@@ -324,6 +380,9 @@ func lifecycleStartAgent(t *testing.T, endpoint string, stage func()) {
staged = true
}
}
+ if _, err := os.Stat("/run/carlos/lifecycle-delete"); err == nil {
+ deletion()
+ }
raw, err := os.ReadFile("/run/carlos/lifecycle-result")
if err == nil {
if string(raw) != "pass" {
@@ -363,6 +422,16 @@ func lifecycleAgent(t *testing.T) {
defer assembly.Close(context.Background())
providers, err := assembly.Build(ctx)
lifecycleCheck(t, "agent build", err)
+ deadline := time.Now().Add(time.Minute)
+ for {
+ if _, err := os.Stat("/run/carlos/lifecycle-created"); err == nil {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("create acknowledgement timeout")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
cfg := provision.ReconcileConfig{Region: "ie", DataDir: "/data", Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }}
providers.Provision(&cfg)
lifecycleCheck(t, "provision", provision.Reconcile(ctx, store, reg, cfg))
@@ -382,13 +451,41 @@ func lifecycleAgent(t *testing.T) {
}
time.Sleep(20 * time.Millisecond)
}
- for wake := 0; wake < 2; wake++ {
- lifecycleWake(t, ctx, providers.Wake, *route)
+ wakes := 2
+ if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" {
+ wakes = 1
+ }
+ for wake := 0; wake < wakes; wake++ {
+ route, err = reg.Get(lifecycleHost)
+ lifecycleCheck(t, "fresh route for wake", err)
+ if route == nil {
+ t.Fatal("route disappeared before wake")
+ }
+ lifecycleWake(t, ctx, providers.Wake, assembly.Wake, *route)
t.Logf("completed cold wake %d", wake+1)
}
+ if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" {
+ lifecycleCheck(t, "request deletion", os.WriteFile("/run/carlos/lifecycle-delete", []byte("ready"), 0600))
+ deadline := time.Now().Add(time.Minute)
+ for time.Now().Before(deadline) {
+ if e := provision.Reconcile(ctx, store, reg, cfg); e != nil {
+ t.Log("retirement reconcile", e)
+ }
+ if e := providers.Regional.(hostbox.RegionalWorker).Run(ctx); e != nil {
+ t.Log("regional retirement", e)
+ }
+ obs, e := hostclaim.Read(ctx, store, lifecycleHost)
+ lifecycleCheck(t, "retirement observation", e)
+ if obs.Claim.Free() {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Fatal("retirement did not complete")
+ }
}
-func lifecycleWake(t *testing.T, ctx context.Context, runtime *activator.ClaimRuntime, route registry.Route) {
+func lifecycleWake(t *testing.T, ctx context.Context, runtime *activator.ClaimRuntime, wake *ClaimWakeAssembly, route registry.Route) {
t.Helper()
observed, err := runtime.Read(ctx, lifecycleHost)
lifecycleCheck(t, "read for wake", err)
@@ -431,7 +528,9 @@ func lifecycleWake(t *testing.T, ctx context.Context, runtime *activator.ClaimRu
}
lifecycleCheck(t, "stop tenant", settle(ctx))
lifecycleCheck(t, "stop replication", rep.Close(ctx))
- lifecycleCheck(t, "close admission", runtime.FailedAdmission(ctx, admitted, hostclaim.ErrWrongState))
+ current, err = runtime.Read(ctx, lifecycleHost)
+ lifecycleCheck(t, "read stopped session", err)
+ lifecycleCheck(t, "stop session", wake.Stop(ctx, current))
}
func lifecycleInstallUnits(t *testing.T) {
@@ -460,3 +559,121 @@ func lifecycleInstallUnits(t *testing.T) {
}
})
}
+
+type lifecycleActorEvidence struct {
+ box, console hostactors.Report
+ identity []byte
+}
+
+func (e lifecycleActorEvidence) BoxReport(context.Context, string, hostactors.Box) (hostactors.Report, error) {
+ return e.box, nil
+}
+func (e lifecycleActorEvidence) ConsoleReport(context.Context, string, hostactors.Console) (hostactors.Report, error) {
+ return e.console, nil
+}
+func (e lifecycleActorEvidence) LocalConsole(context.Context) (hostactors.Report, error) {
+ return e.console, nil
+}
+func (e lifecycleActorEvidence) Identity(context.Context, string) ([]byte, error) {
+ return e.identity, nil
+}
+func (e lifecycleActorEvidence) Fence(context.Context, hostactors.Observed, string, time.Time) (bool, error) {
+ return false, nil
+}
+
+func lifecycleCreate(t *testing.T, raw release.Store, key ed25519.PrivateKey, actor hostbox.Actor, pointer release.SystemPointer, rec provision.Record) func() error {
+ t.Helper()
+ ctx := t.Context()
+ signer := release.RootSigner(key)
+ directory := hostactors.Directory{Store: raw, PublicKey: key.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket}
+ lifecycleCheck(t, "publish test deployment floor", hostfloor.Publish(ctx, raw, signer, actor.ControlBucket, hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"}))
+ floor, err := hostfloor.Read(ctx, raw, directory.PublicKey, actor.ControlBucket)
+ lifecycleCheck(t, "read deployment floor", err)
+ roster, err := directory.Read(ctx)
+ lifecycleCheck(t, "read actor roster", err)
+ roster.Roster.Publication, roster.Roster.FloorSHA256 = "published", floor.Digest
+ roster.Roster.Regions = map[string]hostactors.Region{actor.Region: {Account: actor.Cloud.Account, AWSRegion: actor.Cloud.Region, Namespaces: []string{actor.Namespace}, Pools: []string{"default"}}}
+ roster.Roster.Consoles["console"] = hostactors.Console{Enrollment: strings.Repeat("d", 32), InstanceID: "console", Namespaces: []string{actor.Namespace}, DeploymentBucket: actor.ControlBucket}
+ _, err = directory.Replace(ctx, signer, roster, roster.Roster)
+ lifecycleCheck(t, "publish actors", err)
+ controller, authority, control := lifecycleController(t, raw, key, actor, pointer)
+ data := controller.Store
+ appID, err := control.EnrollNewApp(ctx, rec.Account, rec.App)
+ lifecycleCheck(t, "enroll app", err)
+ before, err := hostclaim.Read(ctx, data, rec.Host)
+ lifecycleCheck(t, "read before registration", err)
+ id := "create-lifecycle"
+ start, err := control.RegisterStart(ctx, rec.Account, rec.App, appID, id, hostops.Registration{Kind: "create", Host: rec.Host, Owner: hostops.RegistrationOwner{Console: "console", Enrollment: strings.Repeat("d", 32), Controller: "console-controller"}, AppID: appID, Epoch: 1, Claim: hostops.SavedObservation(before)}, time.Now())
+ lifecycleCheck(t, "register create", err)
+ rec.Acquisition = id
+ body, err := json.Marshal(rec)
+ lifecycleCheck(t, "encode create", err)
+ record := hostops.Record{Holder: hostclaim.NewHolder{Host: rec.Host, Account: rec.Account, App: rec.App, Family: "instance", Key: provision.RecordKey(rec.Account, rec.App, rec.Host), Region: rec.Region, RecordRegion: rec.Region, CreatedAt: rec.CreatedAt, Acquisition: id}, Body: body}
+ incarnation, err := record.Incarnation()
+ lifecycleCheck(t, "create incarnation", err)
+ lifecycleCheck(t, "bind registration", start.Bind(ctx, rec.CreatedAt, incarnation, time.Now()))
+ createCtx := hostops.WithStartHandle(start.Lifecycle(ctx), start)
+ observed, err := authority.StartCreate(createCtx, before, record, id, "runner")
+ lifecycleCheck(t, "production begin create", err)
+ lifecycleCheck(t, "production record effects", authority.RecordEffects(createCtx, observed, record))
+ return func() error {
+ _, err := controller.Complete(createCtx, rec.Host, id)
+ if err != nil {
+ return err
+ }
+ return control.SettleRegistration(ctx, rec.Account, rec.App, appID, id, time.Now())
+ }
+}
+
+func TestPlan14LifecycleCreateController(t *testing.T) {
+ store := release.NewDirStore(t.TempDir())
+ key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
+ actor := hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Namespace: "platform", Region: "ie", ControlBucket: "deployment", Cloud: hostbox.CloudIdentity{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}}
+ roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ raw, err := release.SignRootEnvelope(release.RootSigner(key), roster)
+ lifecycleCheck(t, "sign roster", err)
+ _, err = store.Put(t.Context(), hostactors.Key, raw, "")
+ lifecycleCheck(t, "write roster", err)
+ platform := release.AdoptPlatform()
+ pointer := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: strings.Repeat("a", 64)}}}}
+ lifecycleCreate(t, store, key, actor, pointer, provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "ie", CreatedAt: time.Now().UTC()})
+ record, err := hostops.ReadRecord(t.Context(), store, provision.RecordKey("bab", "lifecycle", lifecycleHost))
+ lifecycleCheck(t, "read created record", err)
+ if record.Holder.Acquisition == "" {
+ t.Fatal("create did not retain acquisition")
+ }
+}
+
+func lifecycleController(t *testing.T, raw release.Store, key ed25519.PrivateKey, actor hostbox.Actor, pointer release.SystemPointer) (hostops.Controller, *hostops.ProductionAuthority, *hostops.ControlWriter) {
+ t.Helper()
+ ctx := t.Context()
+ signer := release.RootSigner(key)
+ directory := hostactors.Directory{Store: raw, PublicKey: key.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket}
+ floor, err := hostfloor.Read(ctx, raw, directory.PublicKey, actor.ControlBucket)
+ lifecycleCheck(t, "controller floor", err)
+ platform := release.AdoptPlatform()
+ pointer.Current.ConsoleArtifacts = map[string]release.SystemArtifact{platform: {Key: "bootstrap/console/v2.0.0/carlos-" + platform, SHA256: pointer.Current.Artifacts[platform].SHA256}}
+ signed, err := release.SignRootEnvelope(signer, pointer)
+ lifecycleCheck(t, "sign actor release", err)
+ report := hostactors.Report{Component: "router", GeneratedAt: time.Now(), Actor: actor.Box, Enrollment: actor.Enrollment, InstanceID: actor.Cloud.InstanceID, Release: "v2.0.0", ReleaseTag: "v2.0.0", Platform: platform, BinarySHA256: pointer.Current.Artifacts[platform].SHA256, MovesProtocol: 1, BootID: "external-box-boot", FloorSHA256: floor.Digest, FloorGuardReady: true, HostClaimEnabled: true}
+ console := report
+ console.Component, console.Actor, console.InstanceID, console.Enrollment, console.ProcessID = "console", "console", "console", strings.Repeat("d", 32), "console-process"
+ control, err := hostops.NewControlWriter(raw, directory.PublicKey, time.Now)
+ lifecycleCheck(t, "control writer", err)
+ check, err := hostops.NewProductionMutationCheck(raw, control)
+ lifecycleCheck(t, "mutation check", err)
+ records, err := hostops.NewConditionalRecordStore(raw, directory.PublicKey)
+ lifecycleCheck(t, "record boundary", err)
+ data, err := hostops.NewObservedMutationBoundary(true, records, check, nil)
+ lifecycleCheck(t, "mutation boundary", err)
+ directory.Store = data
+ authority := &hostops.ProductionAuthority{Store: data, Control: control, Directory: directory, ConsoleID: "console", Enrollment: console.Enrollment, Namespace: actor.Namespace, Signer: signer, Now: time.Now, Evidence: lifecycleActorEvidence{report, console, signed}}
+ controller := hostops.Controller{Store: data, Control: control, Authority: authority, Now: time.Now, NewID: func() string {
+ var id [24]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ t.Fatal(err)
+ }
+ return hex.EncodeToString(id[:])
+ }}
+ return controller, authority, control
+}
diff --git a/internal/host/rootexecution_client.go b/internal/host/rootexecution_client.go
index e8e8f99a2..d1d25e85d 100644
--- a/internal/host/rootexecution_client.go
+++ b/internal/host/rootexecution_client.go
@@ -203,3 +203,37 @@ func (p *productionClaimLocal) Check(ctx context.Context, actor hostbox.Actor, c
}
return client.Check(ctx, executionExpectation(c))
}
+
+func (p *productionClaimLocal) acknowledgeOperation(ctx context.Context, actor hostbox.Actor, o hostclaim.Observed) error {
+ c := o.Claim
+ if c == nil || c.Holder == nil || (c.Op.Kind != hostclaim.KindCreating && c.Op.Kind != hostclaim.KindDeleting && c.Op.Kind != hostclaim.KindPurging) || !slices.Contains(c.Op.Participants, actor.Box) || c.Op.Journal["exec_ack."+actor.Box] != "" {
+ return nil
+ }
+ p.mu.Lock()
+ slot := p.slots[o.Host]
+ p.mu.Unlock()
+ if slot == nil {
+ return execlease.ErrInactive
+ }
+ select {
+ case <-slot.Ready:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ if slot.Err != nil {
+ return slot.Err
+ }
+ if slot.Controller == nil {
+ return execlease.ErrInactive
+ }
+ state, err := slot.Controller.Ready(ctx)
+ if err != nil {
+ return err
+ }
+ if state.State != "active" || state.Host != o.Host || state.Incarnation != c.Holder.Incarnation || state.Op != c.Op.ID || state.Box != actor.Box || state.Enrollment != actor.Enrollment {
+ return execlease.ErrLost
+ }
+ ack := hostclaim.ExecutionAcknowledgement{Op: state.Op, Enrollment: state.Enrollment, BootID: state.BootID, ControllerID: state.ControllerID, LeaseETag: state.LeaseETag, Expires: state.Expires}
+ _, err = hostclaim.Update(ctx, p.Store, o, hostclaim.AcknowledgeExecution(actor.Box, ack))
+ return err
+}
diff --git a/internal/hostbox/census_vendor_linux.go b/internal/hostbox/census_vendor_linux.go
new file mode 100644
index 000000000..7df4928ab
--- /dev/null
+++ b/internal/hostbox/census_vendor_linux.go
@@ -0,0 +1,49 @@
+//go:build linux
+
+package hostbox
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func censusVendorUnitLink(root, path string) bool {
+ rel, err := filepath.Rel(root, path)
+ if err != nil || !filepath.IsLocal(rel) {
+ return false
+ }
+ parts := strings.Split(rel, string(filepath.Separator))
+ if len(parts) > 2 || (len(parts) == 2 && !strings.HasSuffix(parts[0], ".wants") && !strings.HasSuffix(parts[0], ".requires")) {
+ return false
+ }
+ for _, part := range parts {
+ if strings.HasPrefix(part, "carlos") {
+ return false
+ }
+ }
+ target, err := rootstate.ReadTrustedLink(path)
+ if err != nil {
+ return false
+ }
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(filepath.Dir(path), target)
+ }
+ target = filepath.Clean(target)
+ if filepath.Dir(target) != "/usr/lib/systemd/system" && filepath.Dir(target) != "/lib/systemd/system" {
+ return false
+ }
+ if strings.HasPrefix(filepath.Base(target), "carlos") {
+ return false
+ }
+ resolved, err := filepath.EvalSymlinks(target)
+ if err != nil || filepath.Dir(resolved) != "/usr/lib/systemd/system" && filepath.Dir(resolved) != "/lib/systemd/system" {
+ return false
+ }
+ f, err := rootstate.OpenTrustedFile(resolved)
+ if err != nil {
+ return false
+ }
+ return f.Close() == nil
+}
diff --git a/internal/hostbox/census_vendor_other.go b/internal/hostbox/census_vendor_other.go
new file mode 100644
index 000000000..1863589b7
--- /dev/null
+++ b/internal/hostbox/census_vendor_other.go
@@ -0,0 +1,5 @@
+//go:build !linux
+
+package hostbox
+
+func censusVendorUnitLink(string, string) bool { return false }
diff --git a/internal/hostbox/empty_census.go b/internal/hostbox/empty_census.go
index 1f754dd07..ea6c9a236 100644
--- a/internal/hostbox/empty_census.go
+++ b/internal/hostbox/empty_census.go
@@ -126,7 +126,7 @@ func (c EmptyInstallationCensus) checkLocal(ctx context.Context, obs hostclaim.O
return ErrChanged
}
} else {
- if h.Stamp == 0 {
+ if h.Stamp == 0 && (obs.Claim.Op.Kind != hostclaim.KindCreating || obs.Claim.Op.Phase != "" || obs.Claim.Op.Exec != "" || h.Session != 0 || h.WriterBox != "" || len(obs.Claim.Op.Participants) != 1 || obs.Claim.Op.Participants[0] != c.Box || obs.Claim.Op.Intent["selected_executor"] != c.Box || obs.Claim.Op.Intent["executor_enrollment"] != c.Enrollment) {
return ErrChanged
}
in, err := c.Inventory.ReadInstallation(ctx, selected.Host, h.Incarnation, h.Stamp)
@@ -208,6 +208,9 @@ func (c EmptyInstallationCensus) checkLocal(ctx context.Context, obs hostclaim.O
if rule, ok := rules[rel]; ok {
return rule.check(path, d)
}
+ if name == "units" && censusVendorUnitLink(root, path) {
+ return nil
+ }
if censusInTree(rules, rel) {
// Shared release content: directories and regular files only.
if d.IsDir() || d.Type().IsRegular() {
diff --git a/internal/hostbox/migration_census_linux.go b/internal/hostbox/migration_census_linux.go
index 593af809e..61201d49e 100644
--- a/internal/hostbox/migration_census_linux.go
+++ b/internal/hostbox/migration_census_linux.go
@@ -81,6 +81,9 @@ func CheckMigrationRoots(ctx context.Context, roots map[string]string, shared ma
if rule, ok := rules[rel]; ok {
return rule.check(path, d)
}
+ if name == "units" && censusVendorUnitLink(root, path) {
+ return nil
+ }
if censusInTree(rules, rel) && (d.IsDir() || d.Type().IsRegular()) {
return nil
}
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 3fcc81e75..821bd5b0f 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -20,6 +20,7 @@ type RecordSelection struct {
}
type Reconciler struct {
PrepareInventory func(context.Context, RecordSelection, hostguard.Captured) error
+ Prepare func(context.Context, hostclaim.Observed) error
HomeRegion string
Claims release.Store
Guard hostguard.Guard
@@ -300,6 +301,11 @@ func (r Reconciler) installWithWork(ctx context.Context, selected RecordSelectio
if dec != nil && dec.holdOnly {
return r.holdOnly(ctx, selected, dec.row)
}
+ if r.Prepare != nil {
+ if err := r.Prepare(ctx, obs); err != nil {
+ return err
+ }
+ }
// Activate's participant callback is not an admission validator for executors.
// The reader validates EVERY fresh read while Activate holds hostguard.
guard := r.Guard
diff --git a/internal/hostbox/reconcile_test.go b/internal/hostbox/reconcile_test.go
index 71f87c248..fcbc02795 100644
--- a/internal/hostbox/reconcile_test.go
+++ b/internal/hostbox/reconcile_test.go
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/park"
"testing"
@@ -39,3 +40,38 @@ func TestReconcileDryRunAndExecutorValidation(t *testing.T) {
t.Fatal("row materialized")
}
}
+
+func TestInstallSettlesParticipationBeforeInventory(t *testing.T) {
+ for _, refuse := range []bool{false, true} {
+ t.Run(map[bool]string{false: "settled", true: "refused"}[refuse], func(t *testing.T) {
+ source, store, obs := workFixture(t)
+ h := obs.Claim.Holder
+ selected := RecordSelection{Host: obs.Host, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}}
+ prepared, inventory, written := false, false, false
+ denied := errors.New("participation still owns prior operation")
+ r := Reconciler{Claims: store, Guard: source.Guard, Local: park.Local{Guard: source.Guard, Claims: store, DataDir: source.Guard.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: func() time.Time { return time.Unix(100, 0) }, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil }}
+ r.Prepare = func(context.Context, hostclaim.Observed) error {
+ prepared = true
+ if refuse {
+ return denied
+ }
+ return nil
+ }
+ r.PrepareInventory = func(context.Context, RecordSelection, hostguard.Captured) error {
+ if !prepared {
+ t.Fatal("inventory preceded participation settlement")
+ }
+ inventory = true
+ return nil
+ }
+ err := r.Install(t.Context(), selected, func() error { written = true; return nil })
+ if refuse {
+ if !errors.Is(err, denied) || inventory || written {
+ t.Fatal("unsettled participation reached effects", err, inventory, written)
+ }
+ } else if err != nil || !prepared || !inventory || !written {
+ t.Fatal("settled installation", err, prepared, inventory, written)
+ }
+ })
+ }
+}
diff --git a/internal/hostexec/participation.go b/internal/hostexec/participation.go
index dc8b85af5..9417b3966 100644
--- a/internal/hostexec/participation.go
+++ b/internal/hostexec/participation.go
@@ -15,6 +15,12 @@ func PreinstallationParticipant(c *hostclaim.Claim, box, enrollment string) erro
}
return nil
}
+ if c.Op.Kind == hostclaim.KindCreating {
+ if c.Op.Phase != "" || c.Op.ID == "" || c.Op.Exec != "" || c.Holder.Stamp != 0 || c.Holder.Session != 0 || c.Holder.WriterBox != "" || len(c.Op.Participants) != 1 || c.Op.Participants[0] != box || c.Op.Intent["selected_executor"] != box || c.Op.Intent["executor_enrollment"] != enrollment {
+ return ErrInactive
+ }
+ return nil
+ }
if !slices.Contains(c.Op.Participants, box) || c.Op.ID == "" || c.Holder.Family != "instance" {
return ErrInactive
}
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index 4d2af6612..4750fc7d5 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -94,7 +94,7 @@ internal/host/systemapply.go systemApplier.restoreVersion authority F,J4 rev3=PR
internal/host/systemapply.go systemApplier.verifyPointer authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover, root systemd units or offline root CLI, uid 0;basis=signed pointer verified and independently cached for census target execution before earlier history exists 50961a77b1c9b4e915302c6b95cd57d9f0bbc238e7bd00b94f06a5960ab541c1
internal/host/units/carlos-system-recover.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 f16b260868c4b258c804b3a4720e17f02f6c020eda9dcc2a67d2ca032a077b45
internal/host/units/carlos-system-update.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 cfcaeea6308021ce5a7cb088464e8df3abc6b03ffaa0c7a0153c6d91bf1e3fd6
-internal/hostbox/migration_census_linux.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=checks retained materialized roots against persistent filesystem evidence before census migration accepts coverage;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Lstat@932,file:WalkDir@1202 a9491adef92ee5948b6d8840b047f2ead713c043c65c499c124d5bda62ce51fc
+internal/hostbox/migration_census_linux.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;run=18;identity=offline root migration uid 0;basis=accounts for trusted vendor systemd links separately from retained installation artifacts without exempting unknown files or CARLOS unit aliases file:Lstat@932,file:WalkDir@1202 a1d328f353b814f6f26b5e71f749e1cc3ca10a02bfe7c9ae8833da0ebd755d77
internal/hostguard/guard.go record.admits authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=root admission retains migration denial until the exact predecessor session has a completed retained receipt 5864d1fa79a66ca6f69057416079b46524ed4bfdf1ce1de88124b5a24a428418
internal/hostguard/root.go Guard.CommitRootInventoryArtifacts authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=root inventory commit admits a newer migration writer only after its exact predecessor completed; old sessions stay denied 08e42f8810e2645a875b5177fc9e104ed4b307c7d80dc77d204e2516b1bd66de
internal/hostguard/root.go RootAuthority.read authority C,G,J4 rev3=PROPOSED-p3;run=15;identity=root control/startup/generation uid 0;basis=validates active stamp equality; inactive identity is the exact last root-retained retired tuple, including repair of older records with cleared installation stamp file:Read@175 5c2c9ab014458e086f6df9255e1d967a96fd78cd1e599b891d8369c8506421f8
@@ -201,14 +201,14 @@ internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory aut
@coverage internal/activator 0a01bc096c64f1886f810f4856d60f9a2642074a8b2ac0c56cbed77d122e76c8
@coverage internal/console 6f4998c39e6d66cd73ea98500000cbecac251dd21dafd58f51a85c2291e0c533
@coverage internal/edgesfile 7b48368150a427e4620f1c9ded49942b688c9e6c9cd983b7da43d3f2c0810b72
-@coverage internal/host a51ef433e83138e17482d44181a32650db1980f68283492cf7e53ac2cdce39b1
+@coverage internal/host 96bc0192539a4d93cbe0b1013e053b8b7710a358ac25acdd4cb3c5ad61914a9a
@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461
-@coverage internal/hostbox e1115353432aca616ea15aaa1a08ddba92d793dfa6552f4a02e72ad02fd3a656
+@coverage internal/hostbox 4229685edc3f0850fc590cb14582637c75cc5972d3dfd4e808a2bcad5e692f95
@coverage internal/hostfloor 696e5ea4fa37b924b68e8e262235b99298ee19342d875213ba8209e5287dd3c6
@coverage internal/hostguard d115a22e2a8410adef697af3e73f420b663337dc827fcca82e181e15fdeb8834
@coverage internal/release 1c219907db7ac4ab5d7f5e416c37cd623045e5b5ef759f121ab327effa4017c3
-@coverage internal/rootparticipation ffa18f535e706793976bb235426fa5852e65baba211be5077666c1dc738e7f0b
-@coverage internal/rootstate 2cedbf9f0230a1e6e2ed41afe421a33796a58cdf50a37f0f709dabea24c9088b
+@coverage internal/rootparticipation 7798d05a815af052b9b40d4ff381834a58b2740082e8f673c173e34cb3ce4f22
+@coverage internal/rootstate 292c259ffe1e7144029af096cc39691d6239b2dbc5f01583d76d2e3f4653b2cb
@coverage internal/safefile 7030f312f7a60487381f0dbae2cdecaaa83625b639b6af15104f912592550816
internal/host/rootgeneration_kernel_linux.go generationKernel.absent authority E2 E21: bounded kernel reader retains executable, sibling, descriptor, mapping and mount references; checks controller boot/start and cgroup emptiness. Unknown nonroot tasks, foreign namespaces, missing kernel state or renamed trees refuse. Fixtures: TestPlan14GenerationKernelReferences, TestPlan14GenerationKernelMappingIdentity, TestPlan14GenerationKernelControllerIdentity, TestPlan14GenerationKernelRunningExecutable, TestPlan14GenerationKernelCgroup, TestPlan14GenerationKernelRetainedFiles, TestPlan14GenerationKernelSiblingAlias. Real proc executable tests are not full privileged namespace acceptance.;rev3=PROPOSED-p3;run=8;identity=root generation kernel census uid 0;basis=uses current-boot scanner identities only after retained filesystem and handle verification; immutable reservations remain unchanged;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Readlink@312 0dd1a357c428ea9ebab2fc808d8fb602bbd42f73af595fd3dd16319ed7336ff3
internal/host/rootgenerations_launch.go productionRootGenerations authority E2,E3 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=connects the census root generation source to independent runtime failure and served settlement;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Open@386 e5a3f19159348abed074b2e2c01f9e611e47783395e5f3d063b7c1a657145a92
@@ -328,7 +328,7 @@ internal/host/rootmigration_startup.go readRootMigrationState authority G rev3=P
internal/host/rootmovesfloor_boot.go sameRootFloorImage authority F rev3=PROPOSED-p3;run=13;identity=root boot/recovery uid 0;basis=compares freshly verified signed digest, release and protocols, not saved boot-local file numbers 532078238827331f9282d85d1e17e146ed4842a215db057238aa5f6cb40ba137
internal/host/rootreleaselock.go withClaimReleaseCensus authority E2 rev3=PROPOSED-p3;run=15;identity=root adopt/guard uid 0;basis=no census invokes main effect directly without opening release state, creating a file or waiting on any migration lock; participating effects retain release exclusion 35bcc5e18e89f9755cf1f749bbaa4cbf493380b75500183687476abd71c946b6
internal/host/claimactivation_preflight.go claimActivationLocalUnits authority G rev3=PROPOSED-p3;run=15;identity=agent uid carlos; root migration/adopt/control uid 0;basis=refusal-only inventory of backing unit instances, local named services and drop-ins, legacy app environment configuration and both systemd backing state directories; registry omission cannot confer eligibility file:ReadDir@215,file:ReadFile@1206,file:ReadFile@1289,file:WalkDir@603 bfe1faa17fcbe6d2ea25252698e925e26616b6cdaef55759de0c291b1b30749b
-internal/host/claimassembly.go ClaimAssembly.Build authority C,G rev3=PROPOSED-p3;run=15;identity=agent uid carlos; root control/adopt/recovery uid 0;basis=supplies root registration client, guard, enrollment and typed inventory; local root obligations and exact intention bind inventory revision; service SQL contributes refusals only file:Prepare@3854,file:Prepare@3871,file:Prepare@4583,file:Read@4513,whole:Timer@2044 b77068fb6ba255c31031e3328b6a7ae976369cd5098c51602b0c235effbed253
+internal/host/claimassembly.go ClaimAssembly.Build authority C,G rev3=PROPOSED-p3;run=18;identity=agent uid carlos; root instance-control uid 0;basis=captures provisioning only after participation hooks are bound; settles the previous operation before inventory activation and checks again before selected writes file:Prepare@3817,file:Prepare@3850,file:Prepare@3866,file:Prepare@3883,file:Prepare@4632,file:Read@4562,whole:Timer@2044 f75fa7f91404c318a30755cd7935e7e404bbe8f41b9cfd71184cd12518f15e0e
internal/host/rootgeneration_inventory.go readProductionGenerationInventory authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner 8810b61878f66ec0c1e31e13918454e5b696129639b8512091f883a038c940fd
internal/host/rootmigration_production_linux.go verifyOfflineRootMigration authority G rev3=PROPOSED-p3;run=15;identity=offline root migration uid 0;basis=requires offline masked inactive adopt, stage, config-sync, restart, update and recovery services and absence of concurrent legacy root mutators before creating census state 300a81411020b33318dd8e15418acc95654c3980eb03c8581276d40a951a1a15
internal/host/systemparticipation.go systemApplier.checkUnarmedParticipationRecovery authority G,F rev3=PROPOSED-p3;run=15;identity=root system update/recovery uid 0;basis=pending acquisition triggers production controller settlement even without an update journal; normal ledger admission stays refused until verified recovery clears the marker 4360d1c0c50c554afc06b3a9ad216acc646728f97750e00ca1fe038b1bccce51
@@ -344,3 +344,17 @@ internal/host/rootgeneration_handover.go generationReadHandovers authority E2 re
internal/host/rootgeneration_handover.go generationTransfers authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner registry:ClaimOwner@2754,registry:ClaimOwner@2802,registry:ClaimRowID@2936,registry:ClaimRowID@2986,registry:ClaimRowID@3574,registry:ClaimRowID@3615,registry:Name@3541,registry:Route@2754,registry:Route@2802,registry:Route@2936,registry:Route@2986,registry:Timers@3483,registry:Timers@3615,whole:ColdRows@2756,whole:ColdRows@2804,whole:ColdRows@2938,whole:ColdRows@2988,whole:ColdRows@3485,whole:ColdRows@3617,whole:Route@2763,whole:Route@2810,whole:Route@2945,whole:Route@2994,whole:Timer@3492,whole:Timer@3623 c0c0d2d05d6945560ee7170e6953ad3e603b5054b8f256ee49d93e36a1ad1fe9
internal/host/rootgeneration_handover.go productionGenerationHandovers authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner file:Open@172,file:Open@422 a5f5ce3ccf3f0a068b049e29bc804ced7c942d25080038942041f399e86be743
internal/hostbox/generationhandover.go DecodeGenerationHandover authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner registry:ClaimOwnership@1383,registry:ClaimOwnership@1588,registry:ClaimOwnership@1621,registry:ClaimRowID@1650,registry:Name@1704,registry:Route@1383,registry:Route@1621,registry:Timers@1255,registry:Timers@1300,registry:Timers@1571,registry:Timers@1588,registry:Timers@1650,registry:Timers@1704,whole:ClaimOwnership@1395,whole:ClaimOwnership@1604,whole:ClaimOwnership@1633,whole:ColdRows@1247,whole:ColdRows@1302,whole:ColdRows@1752,whole:ColdRows@1761,whole:Route@1389,whole:Route@1627,whole:Timer@1261,whole:Timer@1309,whole:Timer@1577,whole:Timer@1594,whole:Timer@1656,whole:Timer@1710 3992c3f45d4688db1d1ad88106f535ed3d510046fbd93aa80eccf6b82fd1063c
+internal/host/claimparticipation.go productionClaimLocal.participateHost authority D rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=opens and verifies root participation, then publishes the exact active lease acknowledgement for ordinary create/delete/purge; legacy participation remains unchanged file:Read@121 ef3af30f918a0c15a7e97caa00013851fd7b749e1faf3459db977dc0db78f0dd
+internal/host/claimproduction.go claimServingLaunchSource authority E2 rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=uses authorized claimed-token initialization including a fresh nested instance directory before snapshot; named-unit path and final root launch validation are unchanged registry:Host@697,whole:Route@1139,whole:Route@328,whole:Route@457,whole:Route@614,whole:Route@821 a40211c3f78ad318d0f92f93da2a3acb9e23b3143012f64a5b0d9643cce26a65
+internal/host/claimtoken_directory.go ensureClaimLaunchToken authority E2 rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=creates only the classified nested instance directory inside the existing claim authorization callback, using no-symlink descriptor operations and parent sync before token initialization; main legacy initializer unchanged registry:Host@519,whole:Route@231,whole:Route@289,whole:Route@426 2feb82aacf5b5f2557e3c00e5d2f17efd72b9863faee71cb353de4e54b48fabc
+internal/host/rootexecution_client.go productionClaimLocal.acknowledgeOperation authority C rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=publishes only the required participant own active root-client tuple under claim CAS; checks host incarnation operation box enrollment and leaves existing acknowledgement immutable 12c251703e5b06937af6c993068b45dd41dd8304a2b1d543eaa213dc56cfb4d9
+internal/hostbox/census_vendor_linux.go censusVendorUnitLink authority G,C rev3=PROPOSED-p3;run=18;identity=root migration/control uid 0; agent census uid carlos;basis=classifies only root-owned trusted systemd links to checked regular vendor units; rejects CARLOS names, untrusted ancestry, foreign targets and nested link paths; non-Linux stub only refuses file:EvalSymlinks@867 c4d781b5c4ed60bd16e7d20db119ba6029c039fe60bbacba0219c407deb3ee0d
+internal/hostbox/census_vendor_other.go censusVendorUnitLink authority G,C rev3=PROPOSED-p3;run=18;identity=root migration/control uid 0; agent census uid carlos;basis=classifies only root-owned trusted systemd links to checked regular vendor units; rejects CARLOS names, untrusted ancestry, foreign targets and nested link paths; non-Linux stub only refuses 0b72813762dc54469d67e63f8e9f7e29fd2dec51855cbdbf82899a340ad6b275
+internal/hostbox/empty_census.go EmptyInstallationCensus.checkLocal authority C,G rev3=PROPOSED-p3;run=18;identity=root control uid 0; agent census uid carlos;basis=permits exact selected zero-stamp creating participation without installation authority; known trusted OS unit links no longer make a fresh installation appear occupied; host-scoped and unknown artifacts still refuse file:Exec@1153,file:Lstat@2914,file:WalkDir@3161,registry:ClaimOwnership@2173,registry:Host@2120,whole:ClaimOwnership@2177,whole:ClaimOwnership@2195,whole:Route@2108,whole:Timer@2337 0e7b667d0b7acf5c93dbc5f3f7aec1254a7cf70778d322cc751762074c738085
+internal/hostbox/reconcile.go Reconciler.installWithWork authority C rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=settles participation before activation and inventory creation; existing selected write checks still revalidate before each materialization file:Exec@1240,file:Prepare@1679,file:Prepare@1710,file:Read@392 d0ef9a8b5e5231acd18599287b4a44c0c934f8f9df68e6b799e3dbbadab5133d
+internal/hostbox/reconcile.go type Reconciler authority C rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=production preparation callback receives the observed claim before inventory activation; it grants no write by itself and failures stop installation file:Prepare@109 03c51a76fcfea2c8aef269488a9f69096713723a5c79fc96ceb813a9cafd8e0e
+internal/hostexec/participation.go PreinstallationParticipant authority D rev3=PROPOSED-p3;run=18;identity=root execution authorizer uid 0;basis=admits participation only for the exact sole selected creating executor with zero stamp/session and no writer; does not grant installation or launch authority file:Exec@1412,file:Exec@1684,file:Exec@276,file:Exec@507 2107f8a06975dfaccb7b6f1395dee2db670a5895532e8f01988b0b7ce2036a97
+internal/rootparticipation/request.go ExecutionRequest.Valid authority C rev3=PROPOSED-p3;run=18;identity=root execution transport uid 0; agent client uid carlos;basis=allows zero stamp only with zero session and a valid operation identity; independent root authorizer requires exact creating claim and empty local census 475acbb541b9f57ba848faa89903eac1bf20949dc5a8c3a431551641917bc2d6
+internal/rootstate/link_linux.go directory.readLink authority A,E1 rev3=PROPOSED-p3;run=18;identity=root consumers uid 0; agent census uid carlos;basis=trusted symlink reads allow the systemd template character at-sign while retaining descriptor pinning, owner, single-link and path-component checks; authority record names unchanged file:Fstat@386,file:Openat@213,file:Readlinkat@584 93b98a8fb45fd101920c3c3a01f90600711d54e0910f523fdc2c6a8774feb4fa
+internal/rootstate/state_linux.go descendSync authority A rev3=PROPOSED-p3;run=18;identity=root consumers uid 0; agent census uid carlos;basis=permits template directory names only for public noncreating traversal; retains root ownership, no writable ancestry, no symlink traversal and all creation restrictions file:Fstat@1101,file:Fstat@1570,file:Openat@597,file:Openat@845 8b6d2741139b1c9d81e5c8104eafcea3e93467399534f868f349d0d11754053b
+@coverage internal/hostexec fc41c1b20daf2f36d30932e6851faeac4b632b53c6c532d46ab8e870ccde916c
diff --git a/internal/rootparticipation/request.go b/internal/rootparticipation/request.go
index a1eadd61e..778e2d59c 100644
--- a/internal/rootparticipation/request.go
+++ b/internal/rootparticipation/request.go
@@ -6,7 +6,7 @@ import (
)
func (q ExecutionRequest) Valid() bool {
- if q.Schema != 1 || !validID(q.ID) || len(q.ID) > 128 || !controlname.ValidHostShape(q.Host) || !validID(q.Incarnation) || q.Stamp == 0 {
+ if q.Schema != 1 || !validID(q.ID) || len(q.ID) > 128 || !controlname.ValidHostShape(q.Host) || !validID(q.Incarnation) || (q.Stamp == 0 && (q.Session != 0 || !validID(q.Op))) {
return false
}
if q.Operation == "executionView" && q.Token == "" {
diff --git a/internal/rootstate/link_linux.go b/internal/rootstate/link_linux.go
index 12c3c0b8e..aa0c3023a 100644
--- a/internal/rootstate/link_linux.go
+++ b/internal/rootstate/link_linux.go
@@ -3,6 +3,7 @@ package rootstate
import (
"os"
"path/filepath"
+ "strings"
"golang.org/x/sys/unix"
)
@@ -20,7 +21,7 @@ func ReadTrustedLink(path string) (string, error) {
}
func (d *directory) readLink(name string) (string, error) {
- if !component(name) {
+ if !component(strings.ReplaceAll(name, "@", "_")) {
return "", os.ErrPermission
}
if err := d.check(); err != nil {
diff --git a/internal/rootstate/state_linux.go b/internal/rootstate/state_linux.go
index e1122aae5..32060faa2 100644
--- a/internal/rootstate/state_linux.go
+++ b/internal/rootstate/state_linux.go
@@ -68,7 +68,7 @@ func descendSync(parent *os.File, parts []string, create, public bool, owner uin
mode = 0755
}
for _, name := range parts {
- if !component(name) {
+ if !component(name) && !(public && !create && component(strings.ReplaceAll(name, "@", "_"))) {
return nil, os.ErrPermission
}
next, e := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
diff --git a/internal/rootstate/unit_link_linux_test.go b/internal/rootstate/unit_link_linux_test.go
new file mode 100644
index 000000000..50c396c9f
--- /dev/null
+++ b/internal/rootstate/unit_link_linux_test.go
@@ -0,0 +1,55 @@
+package rootstate
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestTemplateUnitLinkRead(t *testing.T) {
+ parent := t.TempDir()
+ if err := os.Chmod(parent, 0755); err != nil {
+ t.Fatal(err)
+ }
+ name := "sshd@.service.wants"
+ if err := os.Mkdir(filepath.Join(parent, name), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("/usr/lib/systemd/system/getty@.service", filepath.Join(parent, name, "getty@tty1.service")); err != nil {
+ t.Fatal(err)
+ }
+ fd, err := os.Open(parent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fd.Close()
+ s, err := descend(fd, []string{name}, false, true, uint32(os.Geteuid()))
+ if err != nil {
+ t.Fatal("trusted public template directory", err)
+ }
+ defer s.Close()
+ target, err := s.(*directory).readLink("getty@tty1.service")
+ if err != nil || target != "/usr/lib/systemd/system/getty@.service" {
+ t.Fatal("template link", target, err)
+ }
+ if err = s.Write("new@record", []byte("authority")); err == nil {
+ t.Fatal("template exception admitted record write")
+ }
+ for _, parts := range [][]string{{".."}, {"../sshd@.service.wants"}, {"sshd@.service.wants/.."}} {
+ if d, e := descend(fd, parts, false, true, uint32(os.Geteuid())); e == nil {
+ d.Close()
+ t.Fatal("traversal accepted", parts)
+ }
+ }
+ if d, e := descend(fd, []string{"new@directory"}, true, true, uint32(os.Geteuid())); e == nil {
+ d.Close()
+ t.Fatal("template exception admitted directory creation")
+ }
+ if err = os.Chmod(filepath.Join(parent, name), 0777); err != nil {
+ t.Fatal(err)
+ }
+ if d, e := descend(fd, []string{name}, false, true, uint32(os.Geteuid())); e == nil {
+ d.Close()
+ t.Fatal("writable template directory accepted")
+ }
+}