Claim activation tools, run 4: restore ordering (not for merge)
Restore keeps the edge persistently masked and adoption triggers excluded until every other unit is reconciled, records completion, then starts the edge last; 46 reboot checkpoints and real ensureSystemUnits witnesses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+778
−137
internal/host/activation_adoption_linux.go+38 −0internal/host/activation_adoption_other.go+5 −0internal/host/activation_offline_linux.go+60 −71internal/host/activation_offline_rootrunner_linux_test.go+20 −61internal/host/activation_restore_order_rootrunner_linux_test.go+634 −0internal/host/agent.go+3 −0internal/host/systemapply.go+3 −0internal/host/systemunits.go+4 −0internal/provision/testdata/plan14-root-reads-activation-tools.tsv+11 −5
CI passed — run details
CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/superfly/ltx v0.5.1
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading golang.org/x/crypto v0.53.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading golang.org/x/net v0.56.0
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.38.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
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 ./...
go: downloading golang.org/x/tools v0.47.0
go: downloading golang.org/x/mod v0.37.0
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 293.689s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.826s
ok github.com/carlosframework/platform/cmd/moveprobe 93.309s
ok github.com/carlosframework/platform/cmd/zddprobe 0.704s
ok github.com/carlosframework/platform/internal/activator 249.736s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.440s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.129s
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.921s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.011s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.699s
ok github.com/carlosframework/platform/internal/changelog 0.304s
ok github.com/carlosframework/platform/internal/claimactivation 0.022s
ok github.com/carlosframework/platform/internal/clamd 0.059s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 390.753s
? 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.152s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
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.005s
ok github.com/carlosframework/platform/internal/domains 2.318s
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.347s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.130s
ok github.com/carlosframework/platform/internal/gate 1.658s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.020s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.150s
ok github.com/carlosframework/platform/internal/host 175.108s
ok github.com/carlosframework/platform/internal/hostactors 0.345s
ok github.com/carlosframework/platform/internal/hostbox 2.559s
ok github.com/carlosframework/platform/internal/hostclaim 0.502s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 5.116s
ok github.com/carlosframework/platform/internal/hostfence 0.036s
ok github.com/carlosframework/platform/internal/hostfiles 10.949s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 1.041s
ok github.com/carlosframework/platform/internal/hostops 1.529s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostrelocate 0.433s
ok github.com/carlosframework/platform/internal/hostresolve 0.129s
ok github.com/carlosframework/platform/internal/hostwrite 0.016s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 5.267s
ok github.com/carlosframework/platform/internal/instanceexec 2.729s
ok github.com/carlosframework/platform/internal/instancefs 0.020s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.026s
ok github.com/carlosframework/platform/internal/layout 1.535s
ok github.com/carlosframework/platform/internal/lease 9.877s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.160s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.545s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.065s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.528s
ok github.com/carlosframework/platform/internal/moves/archive 0.785s
ok github.com/carlosframework/platform/internal/objstore 0.173s
ok github.com/carlosframework/platform/internal/ops 0.169s
ok github.com/carlosframework/platform/internal/park 0.197s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 83.920s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.751s
ok github.com/carlosframework/platform/internal/relay 7.302s
ok github.com/carlosframework/platform/internal/release 22.894s
ok github.com/carlosframework/platform/internal/rollout 3.750s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.057s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.568s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.490s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.245s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.135s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.220s
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.003s
ok github.com/carlosframework/platform/internal/tunnelclient 0.003s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.562s
=== 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: 48693410 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/16 17:18:25 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF122003938354, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/activation_adoption_linux.go b/internal/host/activation_adoption_linux.go| new file mode 100644 |
| index 000000000..91c261a2c |
| --- /dev/null |
| +++ b/internal/host/activation_adoption_linux.go |
| @@ -0,0 +1,38 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +// An explicit offline transaction excludes automatic unit repair through edge startup. |
| +// A process lock alone would release that exclusion on interruption or reboot. |
| +func activationAdoptionAllowed(etc string) error { |
| + statePath := filepath.Join(etc, "carlos", "claim-activation", "offline") |
| + if _, err := os.Lstat(statePath); errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } else if err != nil { |
| + return err |
| + } |
| + state, err := activationDir(statePath, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer state.Close() |
| + st, err := state.Stat() |
| + if err != nil || st.Mode().Perm() != 0700 { |
| + return errActivation |
| + } |
| + tx := activationOffline{units: filepath.Join(etc, "systemd", "system"), state: statePath} |
| + r, err := tx.read(state) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Phase != "done" { |
| + return errActivation |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/activation_adoption_other.go b/internal/host/activation_adoption_other.go| new file mode 100644 |
| index 000000000..40ec0c8bb |
| --- /dev/null |
| +++ b/internal/host/activation_adoption_other.go |
| @@ -0,0 +1,5 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +func activationAdoptionAllowed(string) error { return nil } |
diff --git a/internal/host/activation_offline_linux.go b/internal/host/activation_offline_linux.go| index 914b1e4ec..5b68264e0 100644 |
| --- a/internal/host/activation_offline_linux.go |
| +++ b/internal/host/activation_offline_linux.go |
| @@ -17,12 +17,14 @@ import ( |
| "github.com/carlosframework/platform/internal/safefile" |
| ) |
| +var activationEdgeUnit = "carlos-edge.service" |
| + |
| var activationTriggers = []string{"carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", "carlos-system-update.path"} |
| func activationOfflineUnits() []string { |
| out := append([]string(nil), activationTriggers...) |
| out = append(out, rootMigrationOfflineUnits...) |
| - return append(out, "carlos-edge.service") |
| + return append(out, activationEdgeUnit) |
| } |
| type activationOffline struct { |
| @@ -175,7 +177,7 @@ func (t activationOffline) read(state *os.File) (activationOfflineRecord, error) |
| } |
| } |
| switch r.Phase { |
| - case "prepared", "held", "seed-prepared", "restoring", "released", "done": |
| + case "prepared", "held", "seed-prepared", "restoring", "released", "complete", "done": |
| default: |
| return r, errActivation |
| } |
| @@ -417,11 +419,7 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if r.Phase == "done" { |
| return nil |
| } |
| - pending := r.Items |
| - if r.Phase == "released" { |
| - pending = append(append([]activationOfflineItem(nil), r.Items[:len(activationTriggers)]...), r.Items[len(r.Items)-1]) |
| - } |
| - for _, item := range pending { |
| + for _, item := range r.Items { |
| if err = t.checkItem(item); err != nil { |
| return err |
| } |
| @@ -429,38 +427,7 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if dry { |
| return nil |
| } |
| - if r.Phase != "released" { |
| - r.Phase = "restoring" |
| - if err = t.save(state, r); err != nil { |
| - return err |
| - } |
| - for _, item := range r.Items[:len(r.Items)-1] { |
| - if err = t.replace(units, state, item, true); err != nil { |
| - return err |
| - } |
| - } |
| - if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| - return err |
| - } |
| - if err = t.point("restore-reload"); err != nil { |
| - return err |
| - } |
| - // Once triggers run, adopt may replace a restored unit. Relinquish those |
| - // service files durably before starting any trigger. Trigger files and |
| - // their active states remain ours until done, as does the edge. |
| - r.Phase = "released" |
| - if err = t.save(state, r); err != nil { |
| - return err |
| - } |
| - } |
| - reconcile := func(item activationOfflineItem, owned bool) error { |
| - if owned { |
| - if err := t.checkItem(item); err != nil { |
| - return err |
| - } |
| - } else if !item.Active || item.Masked { |
| - return nil |
| - } |
| + reconcile := func(item activationOfflineItem) error { |
| verb := "stop" |
| if item.Active && !item.Masked { |
| verb = "start" |
| @@ -487,48 +454,73 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| } |
| return nil |
| } |
| - // Stop originally inactive triggers before starting any of the others. |
| - for _, item := range r.Items[:len(activationTriggers)] { |
| - if !item.Active || item.Masked { |
| - if err = reconcile(item, true); err != nil { |
| - return err |
| - } |
| - } |
| - } |
| - for i, item := range r.Items[:len(r.Items)-1] { |
| - if i < len(activationTriggers) && (!item.Active || item.Masked) { |
| - continue |
| + edge := r.Items[len(r.Items)-1] |
| + // The edge mask survives every pre-completion checkpoint, including reboot. |
| + // Adoption entrypoints also honor this journal until the final done record. |
| + if r.Phase != "complete" { |
| + if err = t.replace(units, state, edge, false); err != nil { |
| + return err |
| } |
| - if err = reconcile(item, i < len(activationTriggers)); err != nil { |
| + r.Phase = "restoring" |
| + if err = t.save(state, r); err != nil { |
| return err |
| } |
| - } |
| - verifyTriggers := func() error { |
| for _, item := range r.Items[:len(activationTriggers)] { |
| - if err := t.checkItem(item); err != nil { |
| + if _, err = t.systemctl(ctx, "stop", item.Name); err != nil { |
| return err |
| } |
| - load, active, err := t.status(ctx, item.Name) |
| - if err != nil { |
| + if err = t.point("hold-" + item.Name); err != nil { |
| return err |
| } |
| - wantLoad, wantActive := "loaded", "inactive" |
| - if item.Masked { |
| - wantLoad = "masked" |
| + } |
| + for _, item := range r.Items[len(activationTriggers) : len(r.Items)-1] { |
| + if err = t.replace(units, state, item, true); err != nil { |
| + return err |
| } |
| - if item.Active && !item.Masked { |
| - wantActive = "active" |
| + } |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + if err = t.point("restore-reload"); err != nil { |
| + return err |
| + } |
| + for _, item := range r.Items[len(activationTriggers) : len(r.Items)-1] { |
| + if err = reconcile(item); err != nil { |
| + return err |
| } |
| - if load != wantLoad || active != wantActive { |
| - return errActivation |
| + } |
| + r.Phase = "released" |
| + if err = t.save(state, r); err != nil { |
| + return err |
| + } |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if err = t.replace(units, state, item, true); err != nil { |
| + return err |
| } |
| } |
| - return nil |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + if err = t.point("trigger-reload"); err != nil { |
| + return err |
| + } |
| } |
| - if err = verifyTriggers(); err != nil { |
| - return err |
| + for _, active := range []bool{false, true} { |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if (item.Active && !item.Masked) == active { |
| + if err = reconcile(item); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + } |
| + if r.Phase != "complete" { |
| + // Save completion before releasing the persistent edge mask. |
| + r.Phase = "complete" |
| + if err = t.save(state, r); err != nil { |
| + return err |
| + } |
| } |
| - edge := r.Items[len(r.Items)-1] |
| if err = t.replace(units, state, edge, true); err != nil { |
| return err |
| } |
| @@ -538,10 +530,7 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if err = t.point("edge-reload"); err != nil { |
| return err |
| } |
| - if err = reconcile(edge, true); err != nil { |
| - return err |
| - } |
| - if err = verifyTriggers(); err != nil { |
| + if err = reconcile(edge); err != nil { |
| return err |
| } |
| r.Phase = "done" |
diff --git a/internal/host/activation_offline_rootrunner_linux_test.go b/internal/host/activation_offline_rootrunner_linux_test.go| index 2a522d380..d71932438 100644 |
| --- a/internal/host/activation_offline_rootrunner_linux_test.go |
| +++ b/internal/host/activation_offline_rootrunner_linux_test.go |
| @@ -266,8 +266,24 @@ func TestRootActivationOfflineEdgeLast(t *testing.T) { |
| } |
| restoring = true |
| started := map[string]bool{} |
| + reconciled := map[string]bool{} |
| tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + if args[0] == "stop" { |
| + reconciled[args[1]] = true |
| + } |
| if args[0] == "start" { |
| + for _, trigger := range activationTriggers { |
| + if args[1] == trigger { |
| + for _, service := range rootMigrationOfflineUnits { |
| + if !reconciled[service] { |
| + t.Fatalf("started %s before reconciling %s", trigger, service) |
| + } |
| + } |
| + } |
| + } |
| + if args[1] == "carlos-edge.service" && offlineRecord(t, tx).Phase != "complete" { |
| + t.Fatal("edge started before durable completion") |
| + } |
| if args[1] != "carlos-edge.service" { |
| target, _ := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")) |
| if target != "/dev/null" { |
| @@ -309,63 +325,6 @@ func TestRootActivationProcessDrain(t *testing.T) { |
| } |
| } |
| -func TestRootActivationOfflineResumeAfterTriggerRepair(t *testing.T) { |
| - tx, _ := offlineFixture(t) |
| - original := tx.systemctl |
| - tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| - if args[0] == "show" && args[1] == "carlos-adopt.timer" { |
| - return []byte("LoadState=loaded\nActiveState=active\n"), nil |
| - } |
| - return original(ctx, args...) |
| - } |
| - units, state, err := tx.dirs(true) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - _, err = tx.prepare(t.Context(), units, state) |
| - units.Close() |
| - state.Close() |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - tx.systemctl = original |
| - if err = tx.begin(t.Context(), false); err != nil { |
| - t.Fatal(err) |
| - } |
| - injected := errors.New("lost trigger start reply") |
| - once := false |
| - tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| - if args[0] == "start" && args[1] == "carlos-adopt.timer" { |
| - if !once { |
| - once = true |
| - // An enabled adopt trigger can repair other units immediately. |
| - path := filepath.Join(tx.units, "carlos-system-update.service") |
| - if err := os.WriteFile(path+".repair", []byte("[Unit]\nDescription=repaired by adopt\n"), 0644); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.Rename(path+".repair", path); err != nil { |
| - t.Fatal(err) |
| - } |
| - return nil, injected |
| - } |
| - } |
| - if args[0] == "show" && args[1] == "carlos-adopt.timer" { |
| - return []byte("LoadState=loaded\nActiveState=active\n"), nil |
| - } |
| - return original(ctx, args...) |
| - } |
| - if err = tx.restore(t.Context(), false); !errors.Is(err, injected) { |
| - t.Fatal("failure not reached", err) |
| - } |
| - if err = tx.restore(t.Context(), false); err != nil { |
| - t.Fatal("released unit blocks restore", err) |
| - } |
| - raw, _ := os.ReadFile(filepath.Join(tx.units, "carlos-system-update.service")) |
| - if !strings.Contains(string(raw), "repaired by adopt") { |
| - t.Fatal("restored over new owner") |
| - } |
| -} |
| - |
| func TestRootActivationRestoreInactiveTrigger(t *testing.T) { |
| for _, point := range []string{"restore-reload", "journal-released"} { |
| t.Run(point, func(t *testing.T) { |
| @@ -388,8 +347,8 @@ func TestRootActivationRestoreInactiveTrigger(t *testing.T) { |
| t.Fatal(err) |
| } |
| tx.checkpoint = nil |
| - if out, err := tx.systemctl(t.Context(), "start", "carlos-adopt.timer"); err != nil { |
| - t.Fatal(string(out), err) |
| + if out, err := tx.systemctl(t.Context(), "start", "carlos-adopt.timer"); err == nil { |
| + t.Fatal("adoption timer unmasked before service reconciliation", string(out)) |
| } |
| if err := tx.restore(t.Context(), false); err != nil { |
| t.Fatal(err) |
| @@ -472,8 +431,8 @@ func TestRootRebootActivationRestorePhase2(t *testing.T) { |
| t.Fatal("no actual reboot", err) |
| } |
| _, active, err := tx.status(t.Context(), "carlos-adopt.timer") |
| - if err != nil || active != "active" { |
| - t.Fatal("enabled timer did not start on reboot", active, err) |
| + if err != nil || active != "inactive" { |
| + t.Fatal("held timer ran on reboot", active, err) |
| } |
| if err := tx.restore(t.Context(), false); err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/activation_restore_order_rootrunner_linux_test.go b/internal/host/activation_restore_order_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..6e8ea3c6f |
| --- /dev/null |
| +++ b/internal/host/activation_restore_order_rootrunner_linux_test.go |
| @@ -0,0 +1,634 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func offlineRecord(t *testing.T, tx activationOffline) activationOfflineRecord { |
| + t.Helper() |
| + raw, err := os.ReadFile(filepath.Join(tx.state, "transaction.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var r activationOfflineRecord |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + t.Fatal(err) |
| + } |
| + return r |
| +} |
| + |
| +func TestRootActivationRestoreEveryBoundaryBoot(t *testing.T) { |
| + for cut := 1; cut < 160; cut++ { |
| + reached := false |
| + t.Run(fmt.Sprint(cut), func(t *testing.T) { |
| + tx, _ := offlineFixture(t) |
| + base := tx.systemctl |
| + active := map[string]bool{"carlos-edge.service": true} |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + switch args[0] { |
| + case "start": |
| + active[args[1]] = true |
| + case "stop": |
| + active[args[1]] = false |
| + } |
| + raw, err := base(ctx, args...) |
| + if args[0] == "show" && active[args[1]] { |
| + raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=active")) |
| + } |
| + return raw, err |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + count := 0 |
| + stop := errors.New("power loss") |
| + point := "" |
| + tx.checkpoint = func(name string) error { |
| + count++ |
| + r := offlineRecord(t, tx) |
| + if r.Phase != "complete" && r.Phase != "done" { |
| + if target, _ := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")); target != "/dev/null" { |
| + t.Fatalf("edge unmasked before complete at %s", name) |
| + } |
| + } |
| + if count == cut { |
| + reached = true |
| + point = name |
| + return stop |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil && !errors.Is(err, stop) { |
| + t.Fatal(err) |
| + } |
| + tx.checkpoint = nil |
| + // Model boot from persistent masks, including an enabled but inactive timer. |
| + for _, name := range activationOfflineUnits() { |
| + target, _ := os.Readlink(filepath.Join(tx.units, name)) |
| + active[name] = target != "/dev/null" && (name == "carlos-adopt.timer" || name == "carlos-edge.service") |
| + } |
| + r := offlineRecord(t, tx) |
| + if r.Phase != "complete" && r.Phase != "done" && active["carlos-edge.service"] { |
| + t.Fatalf("boot served at %s", point) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatalf("resume at %s: %v", point, err) |
| + } |
| + if !active["carlos-edge.service"] { |
| + t.Fatal("edge not restored") |
| + } |
| + if r.Phase != "done" && active["carlos-adopt.timer"] { |
| + t.Fatalf("inactive timer not reconciled after %s", point) |
| + } |
| + }) |
| + if !reached { |
| + break |
| + } |
| + } |
| +} |
| + |
| +func TestRootActivationRestoreRealEnsureInterrupted(t *testing.T) { |
| + tx, d := offlineFixture(t) |
| + etc := filepath.Join(d, "etc") |
| + units := filepath.Join(etc, "systemd", "system") |
| + if err := os.MkdirAll(filepath.Dir(units), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tx.units, units); err != nil { |
| + t.Fatal(err) |
| + } |
| + tx.units = units |
| + tx.state = filepath.Join(etc, "carlos", "claim-activation", "offline") |
| + active := map[string]bool{"carlos-adopt.timer": true, "carlos-edge.service": true} |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + switch args[0] { |
| + case "stop": |
| + active[args[1]] = false |
| + case "start": |
| + active[args[1]] = true |
| + case "show": |
| + load, state := "loaded", "inactive" |
| + if target, _ := os.Readlink(filepath.Join(tx.units, args[1])); target == "/dev/null" { |
| + load = "masked" |
| + } |
| + if active[args[1]] { |
| + state = "active" |
| + } |
| + return []byte("LoadState=" + load + "\nActiveState=" + state + "\n"), nil |
| + } |
| + return nil, nil |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + stop := errors.New("lost adoption timer start reply") |
| + tx.checkpoint = func(name string) error { |
| + if name == "start-carlos-adopt.timer" { |
| + return stop |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, stop) { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(tx.units, "carlos-system-update.path") |
| + before, err := os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := 0 |
| + ctl := func(args ...string) (string, error) { calls++; return "", nil } |
| + probe := func(args ...string) (string, error) { return "inactive", nil } |
| + ensureSystemUnits(etc, filepath.Join(d, "apply.lock"), ctl, probe, t.Logf) |
| + after, err := os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !os.SameFile(before, after) || calls != 0 { |
| + t.Fatalf("real ensureSystemUnits ran during interrupted restore: same inode=%v commands=%d", os.SameFile(before, after), calls) |
| + } |
| + tx.checkpoint = nil |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + ensureSystemUnits(etc, filepath.Join(d, "apply.lock"), ctl, probe, t.Logf) |
| + after, err = os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.SameFile(before, after) || calls == 0 { |
| + t.Fatal("real ensureSystemUnits did not resume after restore") |
| + } |
| +} |
| + |
| +func offlineMatrixNames(t *testing.T, n int) { |
| + t.Helper() |
| + activationEdgeUnit = fmt.Sprintf("carlos-activation-%03d-edge.service", n) |
| + activationTriggers = []string{} |
| + for _, name := range []string{"adopt.timer", "adopt.path", "stage.path", "restart.path", "system-update.path"} { |
| + activationTriggers = append(activationTriggers, fmt.Sprintf("carlos-activation-%03d-%s", n, name)) |
| + } |
| + rootMigrationOfflineUnits = []string{} |
| + for _, name := range []string{"adopt.service", "stage.service", "config-sync.service", "restart.service", "system-update.service", "system-recover.service"} { |
| + rootMigrationOfflineUnits = append(rootMigrationOfflineUnits, fmt.Sprintf("carlos-activation-%03d-%s", n, name)) |
| + } |
| +} |
| + |
| +func offlineMatrixGlobals(t *testing.T) { |
| + t.Helper() |
| + edge, triggers, services := activationEdgeUnit, activationTriggers, rootMigrationOfflineUnits |
| + t.Cleanup(func() { activationEdgeUnit, activationTriggers, rootMigrationOfflineUnits = edge, triggers, services }) |
| +} |
| + |
| +type offlineMatrixCut struct { |
| + N int |
| + Point, Phase string |
| +} |
| + |
| +func TestRootRebootActivationRestoreMatrixPhase1(t *testing.T) { |
| + offlineMatrixGlobals(t) |
| + if err := os.Mkdir(offlineRebootState, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/boot", boot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cuts := []offlineMatrixCut{} |
| + for n := 1; n < 160; n++ { |
| + offlineMatrixNames(t, n) |
| + tx := activationOffline{units: "/etc/systemd/system", state: fmt.Sprintf("%s/case-%03d", offlineRebootState, n), systemctl: offlineRealSystemctl, drained: func(context.Context) error { return nil }} |
| + for _, name := range activationOfflineUnits() { |
| + path := filepath.Join(tx.units, name) |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing", path, err) |
| + } |
| + raw := "[Unit]\nDescription=activation reboot matrix\n[Service]\nExecStart=/usr/bin/sleep infinity\n[Install]\nWantedBy=multi-user.target\n" |
| + if strings.HasSuffix(name, ".timer") { |
| + raw = "[Unit]\nDescription=activation reboot timer\n[Timer]\nOnActiveSec=1h\n[Install]\nWantedBy=timers.target\n" |
| + } |
| + if strings.HasSuffix(name, ".path") { |
| + target := strings.TrimSuffix(name, ".path") + ".service" |
| + if strings.HasSuffix(name, "adopt.path") { |
| + target = strings.TrimSuffix(name, "adopt.path") + "config-sync.service" |
| + } |
| + raw = "[Unit]\nDescription=activation reboot path\n[Path]\nPathExists=" + offlineRebootState + "/never\nUnit=" + target + "\n[Install]\nWantedBy=multi-user.target\n" |
| + } |
| + if strings.HasSuffix(name, "-stage.service") { |
| + err = os.Symlink("/dev/null", path) |
| + } else { |
| + err = os.WriteFile(path, []byte(raw), 0644) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, args := range [][]string{{"daemon-reload"}, {"enable", "--now", activationEdgeUnit}, {"enable", activationTriggers[0]}} { |
| + if out, err := tx.systemctl(t.Context(), args...); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(n, err) |
| + } |
| + count := 0 |
| + cut := offlineMatrixCut{N: n} |
| + stop := errors.New("reboot cut") |
| + tx.checkpoint = func(name string) error { |
| + count++ |
| + r := offlineRecord(t, tx) |
| + if r.Phase != "complete" && r.Phase != "done" { |
| + if target, _ := os.Readlink(filepath.Join(tx.units, activationEdgeUnit)); target != "/dev/null" { |
| + t.Fatalf("%d: edge unmasked at %s before complete", n, name) |
| + } |
| + } |
| + if count == n { |
| + cut.Point = name |
| + return stop |
| + } |
| + return nil |
| + } |
| + err = tx.restore(t.Context(), false) |
| + if err != nil && !errors.Is(err, stop) { |
| + t.Fatal(n, err) |
| + } |
| + cut.Phase = offlineRecord(t, tx).Phase |
| + cuts = append(cuts, cut) |
| + t.Logf("actual reboot fixture %d: %s phase=%s", n, cut.Point, cut.Phase) |
| + if err == nil { |
| + break |
| + } |
| + } |
| + raw, err := json.Marshal(cuts) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.WriteFile(offlineRebootState+"/cuts.json", raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRootRebootActivationRestoreMatrixPhase2(t *testing.T) { |
| + offlineMatrixGlobals(t) |
| + before, err := os.ReadFile(offlineRebootState + "/boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil || string(before) == string(after) { |
| + t.Fatal("no actual reboot", err) |
| + } |
| + raw, err := os.ReadFile(offlineRebootState + "/cuts.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var cuts []offlineMatrixCut |
| + if err = json.Unmarshal(raw, &cuts); err != nil { |
| + t.Fatal(err) |
| + } |
| + sawEdge := false |
| + for _, cut := range cuts { |
| + t.Run(fmt.Sprintf("%d-%s", cut.N, cut.Point), func(t *testing.T) { |
| + offlineMatrixNames(t, cut.N) |
| + tx := activationOffline{units: "/etc/systemd/system", state: fmt.Sprintf("%s/case-%03d", offlineRebootState, cut.N), systemctl: offlineRealSystemctl} |
| + defer func() { |
| + for _, name := range activationOfflineUnits() { |
| + tx.systemctl(t.Context(), "disable", "--now", name) |
| + os.Remove(filepath.Join(tx.units, name)) |
| + } |
| + tx.systemctl(t.Context(), "daemon-reload") |
| + os.RemoveAll(tx.state) |
| + }() |
| + load, active, err := tx.status(t.Context(), activationEdgeUnit) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if cut.Phase != "complete" && cut.Phase != "done" && (load != "masked" || active != "inactive") { |
| + t.Fatalf("edge served before complete after %s: %s/%s", cut.Point, load, active) |
| + } |
| + if cut.Point == "replace-"+activationEdgeUnit { |
| + sawEdge = true |
| + if cut.Phase != "complete" { |
| + t.Fatal("edge replacement preceded durable completion") |
| + } |
| + if load != "loaded" || active != "active" { |
| + t.Fatalf("completed edge not booted: %s/%s", load, active) |
| + } |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + _, active, err = tx.status(t.Context(), activationEdgeUnit) |
| + if err != nil || active != "active" { |
| + t.Fatal("edge not restored", active, err) |
| + } |
| + if cut.Phase != "done" { |
| + _, active, err = tx.status(t.Context(), activationTriggers[0]) |
| + if err != nil || active != "inactive" { |
| + t.Fatal("enabled but inactive timer not reconciled", active, err) |
| + } |
| + } |
| + t.Logf("actual reboot and restore passed: %s", cut.Point) |
| + }) |
| + } |
| + if !sawEdge { |
| + t.Fatal("missing immediate edge-replacement reboot") |
| + } |
| + os.RemoveAll(offlineRebootState) |
| +} |
| + |
| +func TestRootActivationRestoreEnsurePayload(t *testing.T) { |
| + if os.Getenv("CARLOS_ACTIVATION_ENSURE_PAYLOAD") != "1" { |
| + t.Fatal("invoke only through the adoption fixture") |
| + } |
| + if _, err := os.Stat(offlineRebootState + "/fire"); os.IsNotExist(err) { |
| + return |
| + } |
| + ensureSystemUnits("/etc", offlineRebootState+"/apply.lock", productionSysOps().systemctl, systemctlProbe, t.Logf) |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/fired", boot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRootActivationRestoreSystemdAdoption(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + tx.state = "/etc/carlos/claim-activation/offline" |
| + if _, err := os.Lstat("/etc/carlos/claim-activation"); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing activation state", err) |
| + } |
| + defer os.RemoveAll("/etc/carlos/claim-activation") |
| + for _, name := range []string{instanceControlSocket, claimControlUnit} { |
| + if _, err := os.Lstat(filepath.Join(tx.units, name)); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing control unit", name, err) |
| + } |
| + } |
| + if _, err := os.Lstat("/run/carlos-control"); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing control socket directory", err) |
| + } |
| + generationRunnerRegistryUser(t) |
| + defer func() { |
| + for _, name := range []string{instanceControlSocket, claimControlUnit} { |
| + tx.systemctl(t.Context(), "disable", "--now", name) |
| + os.Remove(filepath.Join(tx.units, name)) |
| + } |
| + tx.systemctl(t.Context(), "daemon-reload") |
| + os.RemoveAll("/run/carlos-control") |
| + }() |
| + bin, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := "[Unit]\nDescription=real activation adoption repair\n[Service]\nType=oneshot\nEnvironment=CARLOS_ACTIVATION_ENSURE_PAYLOAD=1\nExecStart=" + bin + " -test.run=^TestRootActivationRestoreEnsurePayload$ -test.v\nRestrictAddressFamilies=AF_UNIX\nIPAddressDeny=any\n" |
| + if err = os.WriteFile(filepath.Join(tx.units, "carlos-adopt.service"), []byte(raw), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + timer := "[Timer]\nOnBootSec=1ms\nOnUnitActiveSec=1h\n[Install]\nWantedBy=timers.target\n" |
| + if err = os.WriteFile(filepath.Join(tx.units, "carlos-adopt.timer"), []byte(timer), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, args := range [][]string{{"daemon-reload"}, {"restart", "carlos-adopt.timer"}, {"start", "carlos-adopt.service"}} { |
| + if out, err := tx.systemctl(t.Context(), args...); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/fire", nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + stop := errors.New("interrupted after adoption activation") |
| + tx.checkpoint = func(name string) error { |
| + if name == "start-carlos-adopt.timer" { |
| + return stop |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, stop) { |
| + t.Fatal(err) |
| + } |
| + if err := runAdoptUnderRootRelease(nil, nil, nil, nil); !errors.Is(err, errActivation) { |
| + t.Fatal("root adoption did not honor transaction", err) |
| + } |
| + for _, recover := range []bool{false, true} { |
| + if err := (&systemApplier{etcDir: "/etc"}).Run(t.Context(), recover); !errors.Is(err, errActivation) { |
| + t.Fatal("system apply did not honor transaction", recover, err) |
| + } |
| + } |
| + path := filepath.Join(tx.units, "carlos-system-update.path") |
| + before, err := os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := tx.systemctl(t.Context(), "start", "carlos-adopt.service"); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + if _, err := os.Stat(offlineRebootState + "/fired"); err != nil { |
| + t.Fatal("real ensure did not run", err) |
| + } |
| + after, err := os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !os.SameFile(before, after) { |
| + t.Fatal("adoption replaced the held path") |
| + } |
| + load, active, err := tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || load != "masked" || active != "inactive" { |
| + t.Fatal("edge escaped", load, active, err) |
| + } |
| + tx.checkpoint = nil |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-system-update.path") |
| + if err != nil || active != "inactive" { |
| + t.Fatal("inactive path was not restored", active, err) |
| + } |
| + if out, err := tx.systemctl(t.Context(), "start", "carlos-adopt.service"); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + after, err = os.Lstat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.SameFile(before, after) { |
| + t.Fatal("ordinary adoption still blocked after done") |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-system-update.path") |
| + if err != nil || active != "active" { |
| + t.Fatal("ordinary adoption did not arm path", active, err) |
| + } |
| +} |
| + |
| +func TestRootActivationAdoptionGate(t *testing.T) { |
| + d := activationRootFixture(t) |
| + etc := filepath.Join(d, "etc") |
| + if err := os.Mkdir(etc, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := activationAdoptionAllowed(etc); err != nil { |
| + t.Fatal("absent transaction changed adoption", err) |
| + } |
| + if _, err := os.Lstat(filepath.Join(etc, "carlos")); !os.IsNotExist(err) { |
| + t.Fatal("absent gate wrote state", err) |
| + } |
| + tx := activationOffline{units: filepath.Join(etc, "systemd", "system"), state: filepath.Join(etc, "carlos", "claim-activation", "offline")} |
| + state, err := activationDir(tx.state, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer state.Close() |
| + if err := activationAdoptionAllowed(etc); err == nil { |
| + t.Fatal("missing journal permitted adoption") |
| + } |
| + r := activationOfflineRecord{V: 1, Units: tx.units} |
| + for _, name := range activationOfflineUnits() { |
| + r.Items = append(r.Items, activationOfflineItem{Name: name}) |
| + } |
| + for _, phase := range []string{"prepared", "held", "seed-prepared", "restoring", "released", "complete", "done"} { |
| + r.Phase = phase |
| + if err := tx.save(state, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + err := activationAdoptionAllowed(etc) |
| + if (err == nil) != (phase == "done") { |
| + t.Fatal("gate phase", phase, err) |
| + } |
| + } |
| + if err := os.Chmod(tx.state, 0777); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := activationAdoptionAllowed(etc); err == nil { |
| + t.Fatal("writable state permitted adoption") |
| + } |
| + if err := os.Chmod(tx.state, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tx.state, tx.state+".original"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(tx.state+".original", tx.state); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := activationAdoptionAllowed(etc); err == nil { |
| + t.Fatal("symlink state permitted adoption") |
| + } |
| + if err := os.Remove(tx.state); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tx.state+".original", tx.state); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(tx.state, "transaction.json"), []byte("{}"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := activationAdoptionAllowed(etc); err == nil { |
| + t.Fatal("invalid journal permitted adoption") |
| + } |
| +} |
| + |
| +func TestRootRebootActivationRestoreAdoptionPhase1(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + tx.state = "/etc/carlos/claim-activation/offline" |
| + if _, err := os.Lstat("/etc/carlos/claim-activation"); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing activation state", err) |
| + } |
| + bin, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + unit := "[Unit]\nDescription=activation adoption reboot\n[Service]\nType=oneshot\nEnvironment=CARLOS_ACTIVATION_ENSURE_PAYLOAD=1\nExecStart=" + bin + " -test.run=^TestRootActivationRestoreEnsurePayload$ -test.v\nRestrictAddressFamilies=AF_UNIX\nIPAddressDeny=any\n" |
| + if err := os.WriteFile(filepath.Join(tx.units, "carlos-adopt.service"), []byte(unit), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + timer := "[Timer]\nOnBootSec=1ms\nOnUnitActiveSec=1h\n[Install]\nWantedBy=timers.target\n" |
| + if err := os.WriteFile(filepath.Join(tx.units, "carlos-adopt.timer"), []byte(timer), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, args := range [][]string{{"daemon-reload"}, {"restart", "carlos-adopt.timer"}, {"start", "carlos-adopt.service"}} { |
| + if out, err := tx.systemctl(t.Context(), args...); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/fire", nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + stop := errors.New("reboot during adoption restoration") |
| + tx.checkpoint = func(name string) error { |
| + if name == "start-carlos-adopt.timer" { |
| + return stop |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, stop) { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/boot", boot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRootRebootActivationRestoreAdoptionPhase2(t *testing.T) { |
| + tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: offlineRealSystemctl} |
| + defer cleanupOfflineReal(t, tx) |
| + defer os.RemoveAll("/etc/carlos/claim-activation") |
| + old, err := os.ReadFile(offlineRebootState + "/boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil || string(old) == string(boot) { |
| + t.Fatal("no actual reboot", err) |
| + } |
| + fired, err := os.ReadFile(offlineRebootState + "/fired") |
| + if err != nil || string(fired) != string(boot) { |
| + t.Fatal("overdue boot timer did not invoke real ensureSystemUnits this boot", string(fired), err) |
| + } |
| + path := filepath.Join(tx.units, "carlos-system-update.path") |
| + if !sameActivationFile(path, filepath.Join(tx.state, "carlos-system-update.path.original")) { |
| + t.Fatal("boot adoption replaced held path") |
| + } |
| + if _, err := os.Stat(offlineRebootState + "/apply.lock"); !os.IsNotExist(err) { |
| + t.Fatal("adoption crossed persistent gate", err) |
| + } |
| + load, active, err := tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || load != "masked" || active != "inactive" { |
| + t.Fatal("edge escaped on reboot", load, active, err) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-system-update.path") |
| + if err != nil || active != "inactive" { |
| + t.Fatal("recorded path state lost", active, err) |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || active != "active" { |
| + t.Fatal("edge not restored", active, err) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index b1c7db1b2..7623cfb28 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4340,6 +4340,9 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu |
| } |
| func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func(), census func() (rootparticipation.Ledger, bool, error), updateProviders ...release.RootUpdateOpener) error { |
| + if err := activationAdoptionAllowed("/etc"); err != nil { |
| + return err |
| + } |
| reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf) |
| if os.Geteuid() == 0 { |
| if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil { |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 9a959dadf..9bf6fb4f9 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -273,6 +273,9 @@ func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDi |
| // journal is closed, and re-firing OnFailure= over a finished recovery |
| // would only start it again with nothing left to do. |
| func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) { |
| + if err := activationAdoptionAllowed(a.etcDir); err != nil { |
| + return err |
| + } |
| defer func() { |
| if errors.Is(retErr, context.DeadlineExceeded) { |
| if required, err := a.participationDisk().ParticipationRequired(context.WithoutCancel(ctx)); err == nil && required { |
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go| index 2f97f0098..ce722e39c 100644 |
| --- a/internal/host/systemunits.go |
| +++ b/internal/host/systemunits.go |
| @@ -40,6 +40,10 @@ var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-updat |
| // failure to report, and probe asks the two QUESTIONS, where non-zero is |
| // half the answer (see the block below). |
| func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...string) (string, error), logf func(string, ...any)) { |
| + if err := activationAdoptionAllowed(etcDir); err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { |
| logf("adopt: system units: %v", err) |
| return |
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv| index ba436b9e8..3893d7b83 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv |
| @@ -1,7 +1,7 @@ |
| -# Activation-tools runs 1–3: proposals only; loaded after all existing overlays. |
| +# Activation-tools runs 1–4: proposals only; loaded after all existing overlays. |
| @coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a |
| @coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2 |
| -@coverage internal/host 6c413d6f9a104e24e0f333f9db7ea469e02b5ad02e57dd2bdfa4ad16babf0146 |
| +@coverage internal/host d80147d3615919205f195df63aa3a81a0fb3780f354160aa78c76c75a5e85336 |
| @coverage internal/hostfence 552570510fb62d2a8822a3f24fa76aeb1437c0a9516966dc5ce15ef58e3fb4c6 |
| internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@310,file:Read@5986,file:Read@6318 683059aedf27656d4b49c88102089b2bb2f2ce7fcc108ff8545e52a86a0f8385 |
| internal/claimactivation/runner.go checkedStore.Delete authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 6bc9103a30cfd53cea59d611ccacb7dcc0ca0ea620b403497ef543f5bdaa0217 |
| @@ -28,12 +28,12 @@ internal/host/activation_offline_linux.go activationOffline.dirs authority G,J4 |
| internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 34292b297a697dbb9fd4c1b067900a7cdcdd0b41dba0d7c7dc9764f633424e43 |
| internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861 |
| internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1 |
| -internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 2f782555914ab53ca36f53ed9bd881b9d8d55225c3828941b4733921d52d77a0 |
| +internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 6ffaa998c2441b9a2d7889275b526bace11feb269df6856527b199a40cfa5bec |
| internal/host/activation_offline_linux.go activationOffline.replace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb |
| -internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=released relinquishes ordinary service files only; retain trigger and edge ownership until done; reconcile inactive and active triggers and verify all before edge and completion; refuse replaced trigger cde53e59e9880e1b81209803fa5e556e817f43add08578d09070b971112e5852 |
| +internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=hold edge mask through durable complete; stop adoption triggers before service reconciliation; persistent journal excludes adoption and unit repair through done; restore triggers before complete and edge last; retain all original ownership until done f6b5f2fc9a6053e7ed39db2ac97ae5a2539b0247c7fadaf028d4edfb7f17fddc |
| internal/host/activation_offline_linux.go activationOffline.save authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7 |
| internal/host/activation_offline_linux.go activationOffline.status authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 22467426d7d4cdb3d3f41dfab898f5a5ee49c34a58d4489ab53d6f04ab213587 |
| -internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 946bc600ded790992d034479eb50b5363a3c9c50e3a3b47693e90318c357c8cc |
| +internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 372c65c44288c766e9b2aa0ece8cbc55c95fb1ccae7e9bae80a5bfcda2550b4b |
| internal/host/activation_offline_linux.go activationUnitSnapshot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@135,file:ReadFile@819,file:Readlink@539 bcb4d45f273e849f03715cfb3a6b49b25b3e128d55e88b15a9528175e433ad04 |
| internal/host/activation_offline_linux.go sameActivationFile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b |
| internal/hostfence/activation_credentials.go ActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 63ffd51457e632687bc44f776fd5c0335748a899e623ff67a0dea31be4ffa7f2 |
| @@ -60,3 +60,9 @@ internal/host/activation_seed_linux.go activationOffline.seedRemask authority G, |
| internal/host/activation_seed_linux.go const activationSeedGuardOriginal authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot de4e081ef3584a30477e20d3d30bcc0d1f3582607be533921c3896d6548440f2 |
| internal/host/activation_seed_linux.go const activationSeedGuardReplacement authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot d5d0cacb84f15b9d5098e05f41a7981852dc53add1c20b946a1a870ef3dc2736 |
| internal/host/activation_seed_linux.go activationSeedReadAt authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=protected directory descriptor; nofollow nonblocking regular-file read; root ownership and bounded bytes; retained hardlinks permitted only in seed configuration inspection file:Fstat@368,file:Openat@148,file:ReadAll@545 0fecdfd3967cc9db0f5a5f2cb81a8eb4d440ffb145ab9ef604c77f654d74e94e |
| +internal/host/activation_adoption_linux.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. file:Lstat@139,file:Stat@359 0d69ed444fc4f8f371ce13993031d209e6b872c691134251012d497a33febd66 |
| +internal/host/activation_adoption_other.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 9a70a93e601ea57fe2ce1780cc6cf878ea3cde656770fe8c400d346fbdbca361 |
| +internal/host/activation_offline_linux.go var activationEdgeUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 42c79dbb523cf08c19f23d2e4f1754902524276b86f58f0d3d5bfb6117d3fcc6 |
| +internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2,E3 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=no census selects legacy proof/history and update effects; proof observation cannot fetch signed recovery manifests on that path; participating adoption retains root release exclusion and independent generation pins whole:Route@11179,whole:Route@18368,whole:Route@18404,whole:Route@8481 2f755c7ed82fcac622a118d1e4ad0b5d746de2b97e0456e125114e12351cd0c8 |
| +internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. root-input;kept=F;scope=protected-decision-only file:OpenFile@375,file:ReadFile@3018,file:ReadFile@3151,file:ReadFile@4065,file:Stat@2927 25aa3ae39d4ccbb5aa041caceb98fe2512c4a0ccfe1a1d34776750d46eff3a06 |
| +internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. root-input;kept=D,F;scope=protected-decision-only PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. file:OpenFile@560 1a0ba9c44022eb6e624b38e99cc356bd1f42701ef48c187aa18f80e2d3312884 |