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>
23 files changed,
+994
−69
internal/host/claim_first_wake_linux_test.go+57 −0internal/host/claimassembly.go+2 −1internal/host/claimparticipation.go+7 −1internal/host/claimproduction.go+1 −1internal/host/claimtoken_directory.go+37 −0internal/host/plan14_census_vendor_root_linux_test.go+70 −0internal/host/plan14_create_participation_test.go+125 −0internal/host/plan14_lifecycle_reboot_root_linux_test.go+76 −0internal/host/plan14_lifecycle_retire_root_linux_test.go+124 −0internal/host/plan14_lifecycle_root_linux_test.go+273 −56internal/host/rootexecution_client.go+34 −0internal/hostbox/census_vendor_linux.go+49 −0internal/hostbox/census_vendor_other.go+5 −0internal/hostbox/empty_census.go+4 −1internal/hostbox/migration_census_linux.go+3 −0internal/hostbox/reconcile.go+6 −0internal/hostbox/reconcile_test.go+36 −0internal/hostexec/participation.go+6 −0internal/provision/testdata/plan14-root-reads-p3.tsv+20 −6internal/rootparticipation/request.go+1 −1internal/rootstate/link_linux.go+2 −1internal/rootstate/state_linux.go+1 −1internal/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") |
| + } |
| +} |