claim Plan 14 Part 1 fix round 3: no-census restarts are a superset of main
Merge ruling 4: with no root census, any route root cannot bind (no record, no root-owned source, box-local stamp, list error) takes main's exact restart path; a binding refusal never rolls a promoted fix back. Ordinary routes make no store call. Inactive and activating PID-zero units bind like failed ones, so the 2026-10-06 recipe works in either reset-failed order. PointLive keeps the running build as previous across an interrupted repoint. Legacy-lock quarantine only on hostile evidence. Census overlay merge3 records the v0.26.1 instance-key hotfix (merge ruling 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+1253
−55
internal/host/agent.go+12 −10internal/host/legacyrestart.go+105 −0internal/host/plan14_fix3_adopt_linux_test.go+44 −0internal/host/plan14_fix3_live_linux_test.go+592 −0internal/host/plan14_fix3_lock_linux_test.go+108 −0internal/host/proofbinding.go+7 −1internal/host/rootrestart.go+117 −24internal/hostguard/flock_other.go+1 −1internal/hostguard/flock_unix.go+21 −5internal/hostguard/guard.go+4 −2internal/hostguard/plan14_fix2_unix_test.go+23 −5internal/hostguard/plan14_root_fences_test.go+2 −2internal/provision/plan14_legacy_identity_test.go+45 −0internal/provision/plan14_root_reads_test.go+7 −0internal/provision/plan9_b8_census_test.go+2 −1internal/provision/testdata/plan14-root-reads-merge3.tsv+36 −0internal/release/adopt.go+30 −3internal/release/plan14_fix3_previous_test.go+77 −0internal/release/proofgate.go+3 −0internal/release/versions.go+17 −1
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 366.105s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.168s
ok github.com/carlosframework/platform/cmd/moveprobe 92.255s
ok github.com/carlosframework/platform/internal/activator 227.425s
ok github.com/carlosframework/platform/internal/agentsock 0.020s
ok github.com/carlosframework/platform/internal/alerting 0.006s
ok github.com/carlosframework/platform/internal/analytics 0.020s
ok github.com/carlosframework/platform/internal/appconfig 2.348s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.133s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.757s
? 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.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.205s
ok github.com/carlosframework/platform/internal/changelog 0.301s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 368.128s
? 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.185s
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.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.148s
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 26.338s
? 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.125s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.018s
ok github.com/carlosframework/platform/internal/geoip 0.033s
ok github.com/carlosframework/platform/internal/host 92.766s
ok github.com/carlosframework/platform/internal/hostactors 0.407s
ok github.com/carlosframework/platform/internal/hostbox 2.324s
ok github.com/carlosframework/platform/internal/hostclaim 0.508s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 5.045s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.509s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 0.980s
ok github.com/carlosframework/platform/internal/hostops 1.473s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.486s
ok github.com/carlosframework/platform/internal/hostresolve 0.149s
ok github.com/carlosframework/platform/internal/hostwrite 0.014s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 1.730s
ok github.com/carlosframework/platform/internal/instanceexec 2.734s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.017s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.465s
ok github.com/carlosframework/platform/internal/lease 9.467s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.020s
ok github.com/carlosframework/platform/internal/ledger 0.031s
ok github.com/carlosframework/platform/internal/mail 0.171s
ok github.com/carlosframework/platform/internal/maintenance 0.091s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.533s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.079s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.073s
ok github.com/carlosframework/platform/internal/moves/archive 0.785s
ok github.com/carlosframework/platform/internal/objstore 0.176s
ok github.com/carlosframework/platform/internal/ops 0.161s
ok github.com/carlosframework/platform/internal/park 0.209s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 13.809s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.002s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 19.552s
ok github.com/carlosframework/platform/internal/rollout 3.843s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.323s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.229s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.350s
ok github.com/carlosframework/platform/internal/status 0.040s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.210s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
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.001s
ok github.com/carlosframework/platform/internal/vet 1.585s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: 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.026s
=== RUN TestT4StorageProbeMinio
2026/10/13 15:42:53 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE212BC7ED4F29, 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.025s
minio contract: PASS
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a817678d8..be471d701 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4192,15 +4192,14 @@ func runAdopt(args []string, buildClaims ClaimFactory) { |
| // the rest of adopt startup, including its fatal registry diagnostics, is real. |
| func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) { |
| err := withClaimRootRelease(context.Background(), hostwrite.Enabled(os.Getenv), func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { |
| - runAdoptUnderRootRelease(args, buildClaims, ensureUnits) |
| - return nil |
| + return runAdoptUnderRootRelease(args, buildClaims, ensureUnits) |
| }) |
| if err != nil { |
| log.Fatalf("adopt: %v", err) |
| } |
| } |
| -func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func()) { |
| +func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func()) error { |
| reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf) |
| if os.Geteuid() == 0 { |
| if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil { |
| @@ -4358,7 +4357,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| log.Fatalf("adopt: list routes: %v", err) |
| } |
| restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| - return |
| + return nil |
| } |
| // Parsed here, before config sync, exactly as the full pass always has |
| // (a box with an unusable release key must die before it converges |
| @@ -4392,7 +4391,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // reason: a config write is not a restart request, and running the |
| // comparison here would only move it earlier by minutes at the cost of |
| // firing on a trigger that has nothing to do with it. |
| - return |
| + return nil |
| } |
| // The restart-request half again (issue #113), here as the FLOOR under |
| @@ -4466,12 +4465,13 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // run). -stage-only keeps it for the same reason it keeps the converge: |
| // a relayed stage is exactly the request that performs such a restart. |
| passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish), |
| + release.WithRunningVersion(restartChecks.runningVersion), |
| release.WithSelectedRestarts( |
| rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false), |
| rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, nil, restartChecks, true)), |
| - release.WithStartUnit(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), release.WithHostWrites(claims.Writes), |
| + release.WithStartUnit(rootStartRoute(reg, release.OptDir(), restartChecks)), release.WithHostWrites(claims.Writes), |
| release.WithDigestRatchet(pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv)), |
| - release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), |
| + release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, restartChecks)), |
| // D3's gate: a fresh gate per root pass, so the policy file is read |
| // at the start of this pass (BeginPass) like every other. Healthy |
| // history is root-owned; diagnostic refusals still use BakeDir. |
| @@ -4499,10 +4499,11 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| hourly: !*stageOnly, |
| logf: log.Printf, |
| } |
| - if err := pr.around(ctx, func() error { |
| + passErr := pr.around(ctx, func() error { |
| return release.Pass(ctx, reg, store, pub, release.OptDir(), restart, passOpts...) |
| - }); err != nil { |
| - log.Printf("adopt: %v", err) |
| + }) |
| + if passErr != nil { |
| + log.Printf("adopt: %v", passErr) |
| } |
| // A move's held stage (moves design §7.3, T7): the sandboxed agent |
| @@ -4523,4 +4524,5 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| adviseBackingMismatches(routes) |
| adviseHostRoleGateMissing(routes, lookupUID, nftGateUIDs) |
| } |
| + return passErr |
| } |
diff --git a/internal/host/legacyrestart.go b/internal/host/legacyrestart.go| new file mode 100644 |
| index 000000000..be4a5d070 |
| --- /dev/null |
| +++ b/internal/host/legacyrestart.go |
| @@ -0,0 +1,105 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| +) |
| + |
| +var errRestartBinding = errors.Join(errors.New("named-unit binding changed"), os.ErrPermission) |
| + |
| +// A retained census keeps the strict path even after the flag is disabled. |
| +// An unreadable census is never evidence that this is a legacy box. |
| +func legacyRootRestart() (bool, error) { |
| + l, required, err := rootparticipation.OpenForUpdate() |
| + if l.State != nil { |
| + l.State.Close() |
| + } |
| + return !required && !hostwrite.Enabled(os.Getenv) && err == nil, err |
| +} |
| + |
| +func (c rootRestartChecks) allowLegacy() (bool, error) { |
| + if c.legacy == nil { |
| + return false, nil |
| + } |
| + return c.legacy() |
| +} |
| + |
| +func (c rootRestartChecks) noteLegacy() { |
| + if c.legacyLog != nil { |
| + c.legacyLog.Do(func() { log.Print("adopt: legacy restart (no root census)") }) |
| + } |
| +} |
| + |
| +func restartEffectFailed(err error) bool { |
| + var notUp *release.UnitNotUpError |
| + return errors.As(err, ¬Up) && !errors.Is(err, errRestartBinding) |
| +} |
| + |
| +// This is main's stamped-unit path, including its fallback on Show or |
| +// ExecStart mismatch. Binding is optional until a root census exists. |
| +func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, settled func(registry.Route) error, c rootRestartChecks) error { |
| + before, err := sys.Show(rt.Unit) |
| + link := release.LiveLink(optDir, rt.Host) |
| + if err != nil || before.ExecPath != link || before.ActiveState != "active" { |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + want, err := filepath.EvalSymlinks(link) |
| + if err != nil { |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if err := sys.TryRestart(rt.Unit); err != nil { |
| + return err |
| + } |
| + err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c)) |
| + if errors.Is(err, errUnitStopped) { |
| + return nil |
| + } |
| + if err == nil && settled != nil { |
| + if e := settled(rt); e != nil { |
| + log.Print(e) |
| + } |
| + } |
| + return err |
| +} |
| + |
| +func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, c rootRestartChecks) error { |
| + before, _ := sys.Show(rt.Unit) |
| + if err := sys.ResetFailed(rt.Unit); err != nil { |
| + log.Print(err) |
| + } |
| + if err := sys.Restart(rt.Unit); err != nil { |
| + return err |
| + } |
| + want, err := filepath.EvalSymlinks(release.LiveLink(optDir, rt.Host)) |
| + if err != nil { |
| + return err |
| + } |
| + err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c)) |
| + if errors.Is(err, errUnitStopped) { |
| + return nil |
| + } |
| + return err |
| +} |
| + |
| +func legacySettleOptions(optDir string, getenv func(string) string, rt registry.Route, want string, c rootRestartChecks) settleOpts { |
| + now, sleep := c.now, c.sleep |
| + if now == nil { |
| + now = time.Now |
| + } |
| + if sleep == nil { |
| + sleep = time.Sleep |
| + } |
| + ready := routeReady(rt, 2*time.Second) |
| + if c.legacyReady != nil { |
| + ready = c.legacyReady(rt) |
| + } |
| + return settleOpts{Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second, WantDir: filepath.Dir(want), Ready: ready, Now: now, Sleep: sleep} |
| +} |
diff --git a/internal/host/plan14_fix3_adopt_linux_test.go b/internal/host/plan14_fix3_adopt_linux_test.go| new file mode 100644 |
| index 000000000..ed7cdd0fb |
| --- /dev/null |
| +++ b/internal/host/plan14_fix3_adopt_linux_test.go |
| @@ -0,0 +1,44 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestR2AdoptFoundationFailureExitsOne(t *testing.T) { |
| + if os.Getenv("PLAN14_FIX3_ADOPT_CHILD") == "1" { |
| + runAdoptWithEnsureSystemUnits([]string{"-stage-only", "-registry", os.Getenv("PLAN14_FIX3_REGISTRY")}, nil, func() {}) |
| + return |
| + } |
| + if os.Geteuid() == 0 { |
| + t.Skip("this fixture runs the local registry as an unprivileged user") |
| + } |
| + dir := t.TempDir() |
| + data := filepath.Join(dir, "data") |
| + if err := os.MkdirAll(filepath.Join(data, "channel-first-seen.json"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, pub, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestR2AdoptFoundationFailureExitsOne$", "-test.v") |
| + cmd.Dir = dir |
| + cmd.Env = isolatedAdoptOrderEnv(t, |
| + "PLAN14_FIX3_ADOPT_CHILD=1", "PLAN14_FIX3_REGISTRY="+filepath.Join(data, "registry.db"), |
| + "CARLOS_DATA_DIR="+data, "CARLOS_OPT_DIR="+filepath.Join(dir, "opt"), |
| + "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR="+filepath.Join(dir, "bucket"), |
| + "CARLOS_HOST_CLAIM=", "CARLOS_REGION_HA=", "CARLOS_MOVES_PHASE1=", "CARLOS_SECRETS_KEY=", "CARLOS_RELEASE_PUBKEY="+pub, |
| + ) |
| + out, err := cmd.CombinedOutput() |
| + var status *exec.ExitError |
| + if !errors.As(err, &status) || status.ExitCode() != 1 || !strings.Contains(string(out), "record bake clock:") { |
| + t.Fatalf("foundation failure lost exit status: %v\n%s", err, out) |
| + } |
| +} |
diff --git a/internal/host/plan14_fix3_live_linux_test.go b/internal/host/plan14_fix3_live_linux_test.go| new file mode 100644 |
| index 000000000..cadca0d4d |
| --- /dev/null |
| +++ b/internal/host/plan14_fix3_live_linux_test.go |
| @@ -0,0 +1,592 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// Model try-restart's no-op for a stopped service, and reset-failed's |
| +// transition to inactive. Restart represents the operator's subsequent start. |
| +type r2Units struct { |
| + *plan14RestartUnits |
| + showErr error |
| + tryErr error |
| + tries []string |
| +} |
| + |
| +func (u *r2Units) Show(unit string) (unitProps, error) { return u.props, u.showErr } |
| +func (u *r2Units) TryRestart(unit string) error { |
| + u.tries = append(u.tries, unit) |
| + if u.tryErr != nil { |
| + return u.tryErr |
| + } |
| + if u.props.ActiveState == "inactive" || u.props.ActiveState == "failed" { |
| + return nil |
| + } |
| + return u.Restart(unit) |
| +} |
| +func (u *r2Units) ResetFailed(unit string) error { |
| + u.effects = append(u.effects, "reset "+unit) |
| + if u.props.MainPID == 0 { |
| + u.props.ActiveState, u.props.SubState = "inactive", "dead" |
| + } |
| + return nil |
| +} |
| + |
| +type r2Live struct { |
| + e *proofRootEnv |
| + rt registry.Route |
| + u *r2Units |
| + c rootRestartChecks |
| + source *proofBindingSource |
| + pub ed25519.PublicKey |
| + key ed25519.PrivateKey |
| +} |
| + |
| +func r2Fixture(t *testing.T, sourceKind string) *r2Live { |
| + t.Helper() |
| + e, rt, base, c, _ := plan14AdoptionFixture(t) |
| + u := &r2Units{plan14RestartUnits: base} |
| + if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt.Account = "" |
| + if err := e.root.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := productionProofBindingSource(e.store, u, u.opt) |
| + s.namedPath = filepath.Join(t.TempDir(), "named-units.json") |
| + s.unitDirs = []string{filepath.Dir(u.props.FragmentPath)} |
| + s.healthy = nil |
| + put := func(path string, body []byte) { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, body, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + makeProofAncestorsPublic(t, path) |
| + } |
| + makeProofAncestorsPublic(t, s.namedPath) |
| + makeProofAncestorsPublic(t, u.exe) |
| + makeProofAncestorsPublic(t, u.props.FragmentPath) |
| + switch sourceKind { |
| + case "guard": |
| + put(filepath.Join(u.opt, "system/guard/console-ready.json"), []byte(release.LiveLink(u.opt, rt.Host))) |
| + drop := filepath.Join(s.unitDirs[0], "console.service.d/90-host-floor.conf") |
| + put(drop, []byte(hostfloor.ConsoleGuardDropIn(release.LiveLink(u.opt, rt.Host)))) |
| + u.props.DropInPaths = []string{drop} |
| + u.props.ExecPath = "/opt/carlos/system/guard/carlos" |
| + put(u.props.ExecPath, []byte("guard")) |
| + case "list": |
| + body, err := json.Marshal(struct { |
| + Schema int `json:"schema"` |
| + Units []namedUnit `json:"units"` |
| + }{1, []namedUnit{{Host: rt.Host, App: rt.App, Unit: rt.Unit, Addr: rt.Addr, Health: &provision.Health{Path: "/ready"}}}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + put(s.namedPath, body) |
| + } |
| + c.resolve, c.host, c.rollback, c.prepareRollback, c.dispatch, c.targetFile = s.resolve, s.forHost, s.rollback, s.prepareRollback, s.needsRestart, rootstate.OpenTrustedFile |
| + c.legacy, c.legacyLog, c.legacyReady = legacyRootRestart, &sync.Once{}, c.ready |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.store.Delete(t.Context(), release.ChannelKey("bab", rt.App, rt.Channel)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &r2Live{e: e, rt: rt, u: u, c: c, source: s, pub: pub, key: key} |
| + f.promote(t, "v1") |
| + f.promote(t, "v2") |
| + return f |
| +} |
| +func (f *r2Live) promote(t *testing.T, v string) { |
| + t.Helper() |
| + if err := release.Promote(t.Context(), f.e.store, f.key, "bab", f.rt.App, f.rt.Channel, v); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func (f *r2Live) restart() func(string) error { |
| + return rootRestartRouteWith(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, nil, f.c) |
| +} |
| +func (f *r2Live) pass(t *testing.T) error { |
| + return release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, f.restart(), release.WithRunningVersion(f.c.runningVersion), release.WithRestartFailurePropagation(), release.WithStartUnit(rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, f.c)), release.WithRollbackRestart(rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, f.c))) |
| +} |
| +func (f *r2Live) wantLive(t *testing.T, v string) { |
| + t.Helper() |
| + got, err := release.LiveVersion(f.u.opt, f.rt.Host) |
| + if err != nil || got != v { |
| + t.Fatalf("live=%s, err=%v; want %s", got, err, v) |
| + } |
| +} |
| +func (f *r2Live) wantRunning(t *testing.T, v string) { |
| + t.Helper() |
| + if f.u.props.MainPID <= 0 || f.u.exe != release.VersionPath(f.u.opt, "", v, f.rt.App) { |
| + t.Fatalf("not running %s: %+v", v, f.u) |
| + } |
| +} |
| + |
| +func TestR2UnguardedConsoleUpdate(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, kind := range []string{"none", "list"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := r2Fixture(t, kind) |
| + var logged bytes.Buffer |
| + previousLog := log.Writer() |
| + log.SetOutput(&logged) |
| + defer log.SetOutput(previousLog) |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v2") |
| + if len(f.u.effects) != 1 { |
| + t.Fatal(f.u.effects) |
| + } |
| + // The same callback serves config sync and restart-only requests. |
| + rt := f.rt |
| + rt.Backing = registry.BackingUnit |
| + if err := f.e.root.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := appconfig.SetEnv(t.Context(), f.e.store, "bab", rt.App, map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := t.TempDir() |
| + for i := 0; i < 2; i++ { |
| + if err := appconfig.Sync(t.Context(), f.e.root.reg, f.e.store, nil, dir, f.restart(), nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if len(f.u.effects) != 2 { |
| + t.Fatal("config sync missed or repeated restart", f.u.effects) |
| + } |
| + routes, err := f.e.root.reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + marks := filepath.Join(t.TempDir(), "restart.json") |
| + restartPass(t.Context(), routes, f.e.store, marks, f.restart()) |
| + askRestart(t, t.Context(), f.e.store, "bab", rt.App) |
| + restartPass(t.Context(), routes, f.e.store, marks, f.restart()) |
| + restartPass(t.Context(), routes, f.e.store, marks, f.restart()) |
| + if len(f.u.effects) != 3 { |
| + t.Fatal("restart-only missed or repeated restart", f.u.effects) |
| + } |
| + if kind == "none" && strings.Count(logged.String(), "legacy restart (no root census)") != 1 { |
| + t.Fatal("legacy restart diagnostic was not once per pass", logged.String()) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2UnguardedIncidentRecipe(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, kind := range []string{"none", "guard", "list"} { |
| + for _, order := range []string{"reset before adoption", "reset after adoption", "never failed"} { |
| + t.Run(kind+"/"+order, func(t *testing.T) { |
| + f := r2Fixture(t, kind) |
| + if err := release.StageArtifact(f.u.opt, "", "v2", f.rt.App, []byte("new build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PointLive(f.u.opt, f.rt.Host, "", "v2", f.rt.App); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.e.root.reg.SetVersions(f.rt.Host, "v2", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.u.exe = release.VersionPath(f.u.opt, "", "v2", f.rt.App) |
| + f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0 |
| + if order == "never failed" { |
| + f.u.props.ActiveState, f.u.props.SubState = "inactive", "dead" |
| + } |
| + if order == "reset before adoption" { |
| + if err := f.u.ResetFailed(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before := len(f.u.effects) |
| + if kind != "none" { |
| + if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil { |
| + t.Fatal("PID-zero binding rejected", err) |
| + } |
| + f.c.legacy = nil |
| + } |
| + f.promote(t, "v1") |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v1") |
| + if order != "reset after adoption" && (f.u.props.MainPID != 0 || len(f.u.effects) != before) { |
| + t.Fatal("woke inactive unit", f.u.effects) |
| + } |
| + if order == "reset after adoption" { |
| + if err := f.u.ResetFailed(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.u.Restart(f.rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantRunning(t, "v1") |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestR2GuardedAutoRestartFixForward(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, state := range []unitProps{{ActiveState: "activating", SubState: "auto-restart"}, {ActiveState: "activating", SubState: "start"}} { |
| + t.Run(state.SubState, func(t *testing.T) { |
| + f := r2Fixture(t, "guard") |
| + f.c.legacy = nil |
| + f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = state.ActiveState, state.SubState, 0 |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v2") |
| + if len(f.u.effects) != 2 || f.u.effects[0] != "reset console.service" { |
| + t.Fatal(f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2BoxLocalUnitStamp(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, recordUnit := range []string{"", "custom.service"} { |
| + t.Run(recordUnit, func(t *testing.T) { |
| + f := r2Fixture(t, "none") |
| + // A socket-derived record cannot authorize a custom named unit. The |
| + // no-census fallback still uses main's box-local stamp. |
| + plan14PutRecord(t, f.e.store, provision.Record{Schema: 1, Host: f.rt.Host, Account: "bab", App: f.rt.App, Channel: f.rt.Channel, Unit: recordUnit}) |
| + f.rt.Unit = "titogo-canary@t.service" |
| + f.u.props.Name = f.rt.Unit |
| + if err := f.e.root.reg.Upsert(f.rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantRunning(t, "v2") |
| + if len(f.u.tries) != 1 || f.u.tries[0] != f.rt.Unit { |
| + t.Fatal(f.u.tries) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type r2ListFailure struct { |
| + release.Store |
| + lists int |
| +} |
| + |
| +func (s *r2ListFailure) List(context.Context, string) ([]string, error) { |
| + s.lists++ |
| + return nil, os.ErrPermission |
| +} |
| +func TestR2OrdinaryRestartWithListFailure(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, kind := range []string{"unit", "exec", "derived stamp"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := r2Fixture(t, "none") |
| + st := &r2ListFailure{Store: f.e.store} |
| + f.source.store = st |
| + f.rt.Unit = "" |
| + f.rt.Backing = registry.BackingUnit |
| + if kind == "exec" { |
| + f.rt.Backing = "exec" |
| + } |
| + if kind == "derived stamp" { |
| + f.rt.Unit = activator.UnitName(f.rt.Host) |
| + } |
| + if err := f.e.root.reg.Delete(f.rt.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.e.root.reg.Upsert(f.rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v2") |
| + if st.lists != 0 || len(f.u.tries) != 1 || f.u.tries[0] != activator.UnitName(f.rt.Host) { |
| + t.Fatal(st.lists, f.u.tries) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2NamedRestartWithListFailure(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + f := r2Fixture(t, "none") |
| + f.source.store = &r2ListFailure{Store: f.e.store} |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v2") |
| +} |
| + |
| +func TestR2OrdinaryRestartMatchesMain(t *testing.T) { |
| + for _, scenario := range []string{"show error", "exec mismatch", "activating", "inactive", "missing row", "registry error", "try-restart error"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + e, rt, base, c, _ := plan14AdoptionFixture(t) |
| + u := &r2Units{plan14RestartUnits: base} |
| + rt.Unit = "" |
| + rt.Backing = registry.BackingUnit |
| + if err := e.root.reg.Delete(rt.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.root.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.legacy = func() (bool, error) { return true, nil } |
| + c.dispatch = func(context.Context, registry.Route) (bool, error) { |
| + t.Fatal("ordinary restart consulted records") |
| + return false, nil |
| + } |
| + switch scenario { |
| + case "show error": |
| + u.showErr = os.ErrPermission |
| + case "exec mismatch": |
| + u.props.ExecPath = "/different" |
| + case "activating": |
| + u.props.ActiveState = "activating" |
| + case "inactive": |
| + u.props.ActiveState = "inactive" |
| + case "missing row": |
| + if err := e.root.reg.Delete(rt.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "registry error": |
| + e.root.reg.Close() |
| + case "try-restart error": |
| + u.tryErr = os.ErrPermission |
| + } |
| + if err := rootRestartRouteWith(e.root.reg, u, u.opt, nil, nil, c)(rt.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(u.tries) != 1 || u.tries[0] != activator.UnitName(rt.Host) { |
| + t.Fatal(u.tries) |
| + } |
| + if scenario == "inactive" && len(u.effects) != 0 { |
| + t.Fatal("woke inactive ordinary unit") |
| + } |
| + if err := rollbackUnitRestart(e.root.reg, u, u.opt, nil, c)(rt.Host); err != nil { |
| + t.Fatal("ordinary rollback reports outage", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2BindingRefusalKeepsPromotedFix(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, during := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "before restart", true: "after restart"}[during], func(t *testing.T) { |
| + f := r2Fixture(t, "list") |
| + if during { |
| + f.u.afterRestart = func() { f.source.namedPath = filepath.Join(t.TempDir(), "missing") } |
| + } else { |
| + f.source.unitDirs = []string{"/wrong"} |
| + } |
| + if err := f.pass(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v2") |
| + }) |
| + } |
| +} |
| + |
| +func TestR2CrashBeforeRestartCapture(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, fail := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "forward", true: "rollback"}[fail], func(t *testing.T) { |
| + f := r2Fixture(t, "list") |
| + crashed := errors.New("crash after repoint before restart capture") |
| + func() { |
| + defer func() { |
| + if recover() != crashed { |
| + t.Fatal("missing crash") |
| + } |
| + }() |
| + _ = release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, func(string) error { panic(crashed) }, release.WithRunningVersion(f.c.runningVersion)) |
| + }() |
| + f.wantLive(t, "v2") |
| + f.wantRunning(t, "v1") |
| + state, err := f.c.attempts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := readRollbackAttempt(state, f.rt.Host); !os.IsNotExist(err) { |
| + t.Fatal("attempt already captured", err) |
| + } |
| + m := release.Manifest{App: f.rt.App, Version: "v3", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| + if err := release.Ship(t.Context(), f.e.store, "bab", m, map[string][]byte{release.AdoptPlatform(): []byte("third build")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.promote(t, "v3") |
| + // A new resolver and restart callback model the next root process. |
| + f.source.index = nil |
| + if fail { |
| + f.u.afterEffect = func() { |
| + if f.u.exe == release.VersionPath(f.u.opt, "", "v3", f.rt.App) { |
| + f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0 |
| + } |
| + } |
| + } |
| + err = f.pass(t) |
| + if fail { |
| + if err == nil { |
| + t.Fatal("failed build accepted") |
| + } |
| + f.wantLive(t, "v1") |
| + f.wantRunning(t, "v1") |
| + } else { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.wantLive(t, "v3") |
| + f.wantRunning(t, "v3") |
| + prior, err := os.Readlink(filepath.Join(f.u.opt, "previous", f.rt.Host)) |
| + if err != nil || prior != release.VersionPath(f.u.opt, "", "v1", f.rt.App) { |
| + t.Fatal("lost running authority", prior, err) |
| + } |
| + pins, err := release.LivePins(f.u.opt) |
| + if err != nil || !pins["v1"] { |
| + t.Fatal("rollback build not pinned", pins, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2RetainedCensusKeepsStrictRestart(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, body := range []string{"{}", "corrupt"} { |
| + t.Run(body, func(t *testing.T) { |
| + f := r2Fixture(t, "none") |
| + s, err := rootstate.Open(rootparticipation.Namespace, true, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Write("census.json", []byte(body)); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { s.Remove("census.json"); s.Close() }() |
| + if legacy, err := legacyRootRestart(); err != nil || legacy { |
| + t.Fatal("retained census selected legacy", legacy, err) |
| + } |
| + if err := f.restart()(f.rt.Host); err == nil || len(f.u.effects) != 0 { |
| + t.Fatal("unbound restart passed retained census", err, f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestR2UnguardedCrashRollback(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + f := r2Fixture(t, "none") |
| + f.u.afterEffect = func() { |
| + if f.u.exe == release.VersionPath(f.u.opt, "", "v2", f.rt.App) { |
| + f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0 |
| + } |
| + } |
| + if err := f.pass(t); err == nil { |
| + t.Fatal("crashed build accepted") |
| + } |
| + f.wantLive(t, "v1") |
| + f.wantRunning(t, "v1") |
| + if len(f.u.effects) != 3 { |
| + t.Fatal("rollback did not reset and restart", f.u.effects) |
| + } |
| +} |
| + |
| +func TestR2NamedFallbackMatchesMain(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, scenario := range []string{"show error", "exec mismatch", "inactive", "failed", "activating", "try-restart error"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + f := r2Fixture(t, "none") |
| + switch scenario { |
| + case "show error": |
| + f.u.showErr = os.ErrPermission |
| + case "exec mismatch": |
| + f.u.props.ExecPath = "/other" |
| + case "inactive", "failed", "activating": |
| + f.u.props.ActiveState = scenario |
| + f.u.props.MainPID = 0 |
| + case "try-restart error": |
| + f.u.tryErr = os.ErrPermission |
| + } |
| + err := f.restart()(f.rt.Host) |
| + if (scenario == "try-restart error") != errors.Is(err, os.ErrPermission) { |
| + t.Fatal("fallback changed try-restart result", err) |
| + } |
| + if len(f.u.tries) != 1 || f.u.tries[0] != f.rt.Unit { |
| + t.Fatal(f.u.tries) |
| + } |
| + if (scenario == "inactive" || scenario == "failed") && len(f.u.effects) != 0 { |
| + t.Fatal("try-restart woke stopped unit") |
| + } |
| + if scenario == "try-restart error" { |
| + starts := 0 |
| + f.c.legacyStart = func(unit string) error { |
| + if unit != f.rt.Unit { |
| + t.Fatal(unit) |
| + } |
| + starts++ |
| + return nil |
| + } |
| + if err := rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, nil, f.c)(f.rt.Host); err != nil || starts != 1 { |
| + t.Fatal("non-NotUp legacy recovery did not use main start", err, starts) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan14_fix3_lock_linux_test.go b/internal/host/plan14_fix3_lock_linux_test.go| new file mode 100644 |
| index 000000000..c7d716df2 |
| --- /dev/null |
| +++ b/internal/host/plan14_fix3_lock_linux_test.go |
| @@ -0,0 +1,108 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestR2LegacyLockContentionRetries(t *testing.T) { |
| + if !plan14RootFixture(t) { |
| + return |
| + } |
| + for _, scenario := range []string{"contention", "fifo", "foreign owner", "io error"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + g := f.root.Guard |
| + store := &rollbackDisk{dir: t.TempDir()} |
| + g.Root = hostguard.NewRootAuthority(store) |
| + in := hostguard.RootInstallation{Host: f.target.Host, Account: "bab", App: "app", Record: "record", Incarnation: f.target.Incarnation, Stamp: f.target.Stamp, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1} |
| + // The fixture's target carries installation identity; the claim remains |
| + // the authority checked by EstablishRoot and BeginFence. |
| + f.claims.set(func(c *hostclaim.Claim) { |
| + c.Holder.Account = in.Account |
| + c.Holder.App = in.App |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: f.target.Box, Region: f.target.Region, Session: f.target.Session, Generation: c.Holder.Generation}} |
| + }) |
| + if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := store.Read(in.Host + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(g.LockDir, in.Host+".lock") |
| + var held *os.File |
| + switch scenario { |
| + case "contention": |
| + held, err = os.OpenFile(path, os.O_RDWR, 0660) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer held.Close() |
| + if err := unix.Flock(int(held.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unix.Flock(int(held.Fd()), unix.LOCK_UN) |
| + case "fifo": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mkfifo(path, 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "foreign owner": |
| + if err := unix.Mount("/foreign", path, "", unix.MS_BIND, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unix.Unmount(path, 0) |
| + case "io error": |
| + g.LockDir = filepath.Join(g.LockDir, "missing") |
| + } |
| + target := f.target |
| + target.FenceID = "fix3-fence" |
| + start := time.Now() |
| + _, err = g.BeginFence(t.Context(), target) |
| + if err == nil { |
| + t.Fatal("mutation crossed unavailable lock") |
| + } |
| + if time.Since(start) > time.Second { |
| + t.Fatal("lock bound exceeded") |
| + } |
| + after, readErr := store.Read(in.Host + ".json") |
| + if readErr != nil { |
| + t.Fatal(readErr) |
| + } |
| + captured := hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp} |
| + _, viewErr := g.Root.View(t.Context(), in.Host, captured) |
| + if scenario == "fifo" || scenario == "foreign owner" { |
| + if !errors.Is(err, hostguard.ErrUnsafeFile) || !errors.Is(viewErr, hostguard.ErrFenced) || bytes.Equal(before, after) { |
| + t.Fatal("hostile lock not durably quarantined", err, viewErr) |
| + } |
| + } else { |
| + if !bytes.Equal(before, after) || viewErr != nil { |
| + t.Fatal("transient lock failure changed durable state", err, viewErr) |
| + } |
| + } |
| + if scenario == "contention" { |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Flock(int(held.Fd()), unix.LOCK_UN); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal("released lock did not retry", err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/proofbinding.go b/internal/host/proofbinding.go| index a30c805a7..bb87bd0e6 100644 |
| --- a/internal/host/proofbinding.go |
| +++ b/internal/host/proofbinding.go |
| @@ -227,7 +227,7 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback |
| exe, err = release.VersionPath(s.optDir, rec.Account, rollback.Version, rec.App), nil |
| } |
| } else { |
| - if p.ActiveState == "failed" && p.MainPID == 0 { |
| + if pointerBoundUnit(p) { |
| exe, err = filepath.EvalSymlinks(release.LiveLink(s.optDir, host)) |
| } else { |
| if p.MainPID <= 0 || p.InvocationID == "" { |
| @@ -303,6 +303,12 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback |
| return proofBinding{ExecPath: execPath, Local: evidence.local, CandidateUnit: rec.Unit, History: history, Invocation: p.InvocationID, PID: p.MainPID, Start: start, Route: rt, Source: source, Network: true, Version: version, Digest: digest, Historical: historical, RecordKey: evidence.key, RecordETag: etag, Files: files}, nil |
| } |
| +// A unit without a process still has its checked unit definition and root |
| +// release pointer. Inactive units bind for repointing but must stay asleep. |
| +func pointerBoundUnit(p unitProps) bool { |
| + return p.MainPID == 0 && (p.ActiveState == "failed" || p.ActiveState == "inactive" || p.ActiveState == "activating" || p.SubState == "auto-restart") |
| +} |
| + |
| func (s *proofBindingSource) forHost(ctx context.Context, host string) (proofBinding, error) { |
| return s.binding(ctx, host, nil) |
| } |
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go| index e5cac0a64..25e5a243e 100644 |
| --- a/internal/host/rootrestart.go |
| +++ b/internal/host/rootrestart.go |
| @@ -22,6 +22,10 @@ import ( |
| ) |
| type rootRestartChecks struct { |
| + legacy func() (bool, error) |
| + legacyLog *sync.Once |
| + legacyReady func(registry.Route) func() error |
| + legacyStart func(string) error |
| targetDigest string |
| targetFile func(string) (*os.File, error) |
| dispatch func(context.Context, registry.Route) (bool, error) |
| @@ -55,7 +59,7 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck |
| cached = productionProofBindingSource(s, sys, optDir) |
| return cached, nil |
| } |
| - return rootRestartChecks{targetFile: rootstate.OpenTrustedFile, started: &sync.Map{}, |
| + return rootRestartChecks{legacy: legacyRootRestart, legacyLog: &sync.Once{}, targetFile: rootstate.OpenTrustedFile, started: &sync.Map{}, |
| dispatch: func(ctx context.Context, rt registry.Route) (bool, error) { |
| s, err := source(ctx) |
| if err != nil { |
| @@ -102,6 +106,27 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck |
| } |
| } |
| +func (c rootRestartChecks) runningVersion(ctx context.Context, rt registry.Route) (string, error) { |
| + if rt.Unit == "" || rt.Unit == activator.UnitName(rt.Host) { |
| + return "", nil |
| + } |
| + b, err := c.resolve(ctx, rt) |
| + if err != nil { |
| + legacy, checkErr := c.allowLegacy() |
| + if checkErr != nil { |
| + return "", checkErr |
| + } |
| + if legacy { |
| + return "", nil |
| + } |
| + return "", err |
| + } |
| + if b.PID > 0 { |
| + return b.Version, nil |
| + } |
| + return "", nil |
| +} |
| + |
| func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks ...rootRestartChecks) func(registry.Route) error { |
| c := productionRootRestartChecks(sys, optDir) |
| if len(checks) == 1 { |
| @@ -132,7 +157,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| if before.ActiveState == "inactive" { |
| return errUnitStopped |
| } |
| - if before.ActiveState != "failed" && (before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) { |
| + if !pointerBoundUnit(before) && (before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) { |
| return os.ErrPermission |
| } |
| target, err := filepath.EvalSymlinks(release.LiveLink(optDir, candidate.Host)) |
| @@ -160,7 +185,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| targetDigest := hex.EncodeToString(hash.Sum(nil)) |
| c.targetDigest = targetDigest |
| captured := binding |
| - if before.ActiveState == "failed" { |
| + if pointerBoundUnit(before) { |
| previous, e := filepath.EvalSymlinks(filepath.Join(optDir, "previous", candidate.Host)) |
| if e == nil && previous != target { |
| rel, e := filepath.Rel(filepath.Join(optDir, "versions"), previous) |
| @@ -207,6 +232,9 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| if errors.Is(err, errUnitStopped) { |
| return errors.Join(err, settleRollbackAttempt(s, attempt)) |
| } |
| + if errors.Is(err, errRestartBinding) { |
| + return errors.Join(err, settleRollbackAttempt(s, attempt)) |
| + } |
| attempt.Failed = true |
| var notUp *release.UnitNotUpError |
| if !errors.As(err, ¬Up) { |
| @@ -219,20 +247,20 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, binding proofBinding, rollback bool, settled func(registry.Route) error, c rootRestartChecks) error { |
| rt := binding.Route |
| if binding.Source == "" || !binding.Network || c.ready == nil { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| ready := c.ready(rt) |
| if ready == nil { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| before, err := sys.Show(rt.Unit) |
| if err != nil { |
| - return err |
| + return errors.Join(errRestartBinding, err) |
| } |
| if before.ExecPath != proofExecPath(binding, optDir) { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| - if before.ActiveState == "inactive" && before.SubState == "dead" { |
| + if before.ActiveState == "inactive" { |
| return errUnitStopped |
| } |
| want, err := filepath.EvalSymlinks(release.LiveLink(optDir, rt.Host)) |
| @@ -249,7 +277,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| if rollback { |
| current, err := c.rollback(context.Background(), binding) |
| if err != nil || current.Source != binding.Source { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| if err := sys.ResetFailed(rt.Unit); err != nil { |
| return err |
| @@ -258,20 +286,20 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| return err |
| } |
| } else { |
| - if before.ActiveState != "failed" && (before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) { |
| - return os.ErrPermission |
| + if !pointerBoundUnit(before) && (before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) { |
| + return errRestartBinding |
| } |
| if before.MainPID > 0 { |
| start, err := sys.StartTime(before.MainPID) |
| if err != nil || start != binding.Start { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| } |
| current, err := c.host(context.Background(), rt.Host) |
| if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| - if before.ActiveState == "failed" { |
| + if pointerBoundUnit(before) { |
| if err := sys.ResetFailed(rt.Unit); err != nil { |
| return err |
| } |
| @@ -286,23 +314,28 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| return err |
| } |
| var observed proofBinding |
| + var bindingErr error |
| checkedReady := func() error { |
| first, err := c.host(context.Background(), rt.Host) |
| if err != nil || first.Source != binding.Source || want != release.VersionPath(optDir, first.Route.Account, first.Version, first.Route.App) { |
| - return os.ErrPermission |
| + bindingErr = errors.Join(errRestartBinding, err) |
| + return bindingErr |
| } |
| if c.targetDigest != "" && first.Digest != c.targetDigest { |
| - return os.ErrPermission |
| + bindingErr = errRestartBinding |
| + return bindingErr |
| } |
| if rollback && (first.Version != binding.Version || first.Digest != binding.Digest) { |
| - return os.ErrPermission |
| + bindingErr = errRestartBinding |
| + return bindingErr |
| } |
| if err := ready(); err != nil { |
| return err |
| } |
| last, err := c.host(context.Background(), rt.Host) |
| if err != nil || last.Source != first.Source || last.Version != first.Version || last.Digest != first.Digest || last.Invocation != first.Invocation || last.PID != first.PID || last.Start != first.Start { |
| - return os.ErrPermission |
| + bindingErr = errors.Join(errRestartBinding, err) |
| + return bindingErr |
| } |
| observed = last |
| return nil |
| @@ -312,14 +345,17 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| WantDir: filepath.Dir(want), Ready: checkedReady, Now: c.now, Sleep: c.sleep, RequireExe: true, |
| }) |
| if err != nil { |
| + if bindingErr != nil { |
| + return bindingErr |
| + } |
| return err |
| } |
| current, err := c.host(context.Background(), rt.Host) |
| if err != nil { |
| - return err |
| + return errors.Join(errRestartBinding, err) |
| } |
| if current.Invocation != observed.Invocation || current.PID != observed.PID || current.Start != observed.Start || current.Source != binding.Source || want != release.VersionPath(optDir, current.Route.Account, current.Version, current.Route.App) || (rollback && (current.Version != binding.Version || current.Digest != binding.Digest)) { |
| - return os.ErrPermission |
| + return errRestartBinding |
| } |
| if settled != nil { |
| if err := effect(func() error { return settled(binding.Route) }); err != nil { |
| @@ -329,13 +365,35 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| return nil |
| } |
| -func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, checks ...rootRestartChecks) func(string) error { |
| +func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, checks ...rootRestartChecks) func(string) error { |
| c := productionRootRestartChecks(sys, optDir) |
| if len(checks) == 1 { |
| c = checks[0] |
| } |
| return func(host string) (result error) { |
| + legacy, err := c.allowLegacy() |
| + if err != nil { |
| + return err |
| + } |
| + if legacy { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil || rt.Unit == "" { |
| + return nil |
| + } |
| + defer func() { |
| + if errors.Is(result, errUnitStopped) { |
| + result = nil |
| + } else if result != nil && !restartEffectFailed(result) { |
| + c.noteLegacy() |
| + if c.legacyStart != nil { |
| + result = c.legacyStart(rt.Unit) |
| + } else { |
| + result = legacyRollbackRestart(sys, optDir, getenv, *rt, c) |
| + } |
| + } |
| + }() |
| + } |
| if c.attempts == nil || c.rollback == nil { |
| return os.ErrPermission |
| } |
| @@ -398,6 +456,11 @@ func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, ge |
| } |
| +func rootStartRoute(reg *registry.Registry, optDir string, checks rootRestartChecks) func(string) error { |
| + checks.legacyStart = systemctlStartUnit |
| + return rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv, checks) |
| +} |
| + |
| func rootRestartRoute(reg *registry.Registry, optDir string, settled func(registry.Route) error) func(string) error { |
| return rootRestartRouteWith(reg, systemdUnits{}, optDir, os.Getenv, settled, productionRootRestartChecks(systemdUnits{}, optDir)) |
| } |
| @@ -420,7 +483,14 @@ func rootSelectedRestart(reg *registry.Registry, sys unitSystem, optDir string, |
| func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks rootRestartChecks) func(string) error { |
| restart := checkedUnitRestart(sys, optDir, getenv, settled, checks) |
| return func(host string) error { |
| + legacy, err := checks.allowLegacy() |
| + if err != nil { |
| + return err |
| + } |
| rt, err := reg.Get(host) |
| + if legacy && (err != nil || rt == nil || rt.Unit == "" || rt.Unit == activator.UnitName(host)) { |
| + return restartOrdinaryUnit(sys, host) |
| + } |
| if err != nil { |
| return err |
| } |
| @@ -430,17 +500,40 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, |
| if checks.dispatch != nil { |
| need, err := checks.dispatch(context.Background(), *rt) |
| if err != nil { |
| + if legacy { |
| + checks.noteLegacy() |
| + return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| + } |
| return err |
| } |
| if !need { |
| - return restartOrdinaryUnit(sys, optDir, host, checks.targetFile) |
| + if legacy { |
| + checks.noteLegacy() |
| + return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| + } |
| + return restartVerifiedOrdinaryUnit(sys, optDir, host, checks.targetFile) |
| } |
| } |
| - return restart(*rt) |
| + err = restart(*rt) |
| + if errors.Is(err, errUnitStopped) { |
| + return nil |
| + } |
| + if legacy && err != nil && !restartEffectFailed(err) { |
| + checks.noteLegacy() |
| + return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks) |
| + } |
| + return err |
| } |
| } |
| -func restartOrdinaryUnit(sys unitSystem, optDir, host string, open func(string) (*os.File, error)) error { |
| +func restartOrdinaryUnit(sys unitSystem, host string) error { |
| + if err := sys.TryRestart(activator.UnitName(host)); err != nil { |
| + log.Print(err) |
| + } |
| + return nil |
| +} |
| + |
| +func restartVerifiedOrdinaryUnit(sys unitSystem, optDir, host string, open func(string) (*os.File, error)) error { |
| unit := activator.UnitName(host) |
| p, err := sys.Show(unit) |
| if err != nil || p.Name != unit || p.ExecPath != release.LiveLink(optDir, host) || p.ActiveState != "active" || open == nil { |
diff --git a/internal/hostguard/flock_other.go b/internal/hostguard/flock_other.go| index 8e0502b5a..4647869b5 100644 |
| --- a/internal/hostguard/flock_other.go |
| +++ b/internal/hostguard/flock_other.go |
| @@ -4,4 +4,4 @@ package hostguard |
| import "context" |
| -func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported } |
| +func acquire(context.Context, string, string, ...bool) (func(), error) { return nil, ErrUnsupported } |
diff --git a/internal/hostguard/flock_unix.go b/internal/hostguard/flock_unix.go| index 9ba78ef87..78a251a77 100644 |
| --- a/internal/hostguard/flock_unix.go |
| +++ b/internal/hostguard/flock_unix.go |
| @@ -11,7 +11,7 @@ import ( |
| "golang.org/x/sys/unix" |
| ) |
| -func acquire(ctx context.Context, dir, host string) (func(), error) { |
| +func acquire(ctx context.Context, dir, host string, verifyOwner ...bool) (func(), error) { |
| if ctx == nil { |
| return nil, ErrInvalidInput |
| } |
| @@ -26,6 +26,17 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| return nil, err |
| } |
| defer unix.Close(dirFD) |
| + var parent unix.Stat_t |
| + if err := unix.Fstat(dirFD, &parent); err != nil { |
| + return nil, err |
| + } |
| + unsafe := func(st unix.Stat_t) bool { |
| + return st.Mode&unix.S_IFMT != unix.S_IFREG || (len(verifyOwner) == 1 && verifyOwner[0] && st.Uid != 0 && st.Uid != parent.Uid) |
| + } |
| + var prior unix.Stat_t |
| + if err := unix.Fstatat(dirFD, host+".lock", &prior, unix.AT_SYMLINK_NOFOLLOW); err == nil && unsafe(prior) { |
| + return nil, ErrUnsafeFile |
| + } |
| flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK |
| fd, err := unix.Openat(dirFD, host+".lock", flags|unix.O_EXCL, 0660) |
| created := err == nil |
| @@ -33,6 +44,9 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| fd, err = unix.Openat(dirFD, host+".lock", flags, 0660) |
| } |
| if err != nil { |
| + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EISDIR) { |
| + return nil, ErrUnsafeFile |
| + } |
| return nil, err |
| } |
| f := os.NewFile(uintptr(fd), host+".lock") |
| @@ -41,7 +55,7 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| if err := unix.Fstat(fd, &st); err != nil { |
| return fail(err) |
| } |
| - if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + if unsafe(st) { |
| return fail(ErrUnsafeFile) |
| } |
| if created || st.Mode&07777 != 0660 { |
| @@ -49,8 +63,7 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| return fail(err) |
| } |
| } |
| - tick := time.NewTicker(10 * time.Millisecond) |
| - defer tick.Stop() |
| + delay := 10 * time.Millisecond |
| for { |
| if err := ctx.Err(); err != nil { |
| return fail(err) |
| @@ -67,10 +80,13 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) && !errors.Is(err, unix.EINTR) { |
| return fail(err) |
| } |
| + timer := time.NewTimer(delay) |
| select { |
| case <-ctx.Done(): |
| + timer.Stop() |
| return fail(ctx.Err()) |
| - case <-tick.C: |
| + case <-timer.C: |
| } |
| + delay = min(2*delay, 100*time.Millisecond) |
| } |
| } |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index 19a831554..50ea2c032 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -75,9 +75,11 @@ func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) erro |
| lockCtx, cancel = context.WithTimeout(ctx, 250*time.Millisecond) |
| } |
| defer cancel() |
| - unlock, err := acquire(lockCtx, dir, host) |
| + unlock, err := acquire(lockCtx, dir, host, g.Root != nil) |
| if err != nil { |
| - if g.Root != nil { |
| + // Contention and I/O errors are retryable, not hostile evidence. |
| + // Only a positively unsafe lock can quarantine an installation. |
| + if g.Root != nil && errors.Is(err, ErrUnsafeFile) { |
| r, readErr := g.Root.read(host) |
| if readErr == nil { |
| r.quarantined = true |
diff --git a/internal/hostguard/plan14_fix2_unix_test.go b/internal/hostguard/plan14_fix2_unix_test.go| index f9b603756..15756492e 100644 |
| --- a/internal/hostguard/plan14_fix2_unix_test.go |
| +++ b/internal/hostguard/plan14_fix2_unix_test.go |
| @@ -3,6 +3,7 @@ |
| package hostguard |
| import ( |
| + "bytes" |
| "context" |
| "errors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -22,7 +23,15 @@ func TestPlan14Fix2LegacyLockCannotStallRoot(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - defer unlock() |
| + defer func() { |
| + if unlock != nil { |
| + unlock() |
| + } |
| + }() |
| + before, err := f.g.Root.store.Read(target.Host + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| defer cancel() |
| if _, err := f.g.PendingFences(ctx); err != nil { |
| @@ -32,11 +41,20 @@ func TestPlan14Fix2LegacyLockCannotStallRoot(t *testing.T) { |
| t.Fatal("mutation crossed held legacy lock") |
| } |
| r, err := f.g.Root.read(target.Host) |
| - if err != nil || !r.quarantined { |
| - t.Fatalf("missing durable deny: %+v %v", r, err) |
| + if err != nil || r.quarantined { |
| + t.Fatalf("honest contention quarantined: %+v %v", r, err) |
| + } |
| + after, err := f.g.Root.store.Read(target.Host + ".json") |
| + if err != nil || !bytes.Equal(before, after) { |
| + t.Fatal("contention wrote durable state", err) |
| + } |
| + if _, err := f.g.Root.View(ctx, target.Host, captured); err != nil { |
| + t.Fatal("contention fenced running installation", err) |
| } |
| - if _, err := f.g.Root.View(ctx, target.Host, captured); !errors.Is(err, ErrFenced) { |
| - t.Fatal("quarantined view admitted", err) |
| + unlock() |
| + unlock = nil |
| + if _, err := f.g.BeginFence(ctx, target); err != nil { |
| + t.Fatal("released contention did not retry", err) |
| } |
| } |
| func TestPlan14Fix2PendingIntentCanBeAbandoned(t *testing.T) { |
diff --git a/internal/hostguard/plan14_root_fences_test.go b/internal/hostguard/plan14_root_fences_test.go| index 1ee1c82c3..3b4736de3 100644 |
| --- a/internal/hostguard/plan14_root_fences_test.go |
| +++ b/internal/hostguard/plan14_root_fences_test.go |
| @@ -281,8 +281,8 @@ func TestPlan14FenceViewWhileRootWaitsForAgent(t *testing.T) { |
| go func() { _, err := root.Root.View(ctx, target.Host, captured); viewDone <- err }() |
| select { |
| case err := <-viewDone: |
| - if !errors.Is(err, ErrFenced) { |
| - t.Fatal(err) |
| + if err != nil { |
| + t.Fatal("honest contention fenced the view", err) |
| } |
| case <-ctx.Done(): |
| t.Fatal("fence view waited on root holding a lock needed by the agent") |
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index 7320c17a6..6166ecb63 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -106,3 +106,48 @@ func TestPlan14MergeOverlayPreservesLegacySeal(t *testing.T) { |
| t.Fatal("new freeze entry accepted without a ruling") |
| } |
| } |
| + |
| +func TestPlan14Merge3OverlayPreservesLegacySeal(t *testing.T) { |
| + root := censusRoot(t) |
| + inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv"} { |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if errs := checkRootReadLegacy(inv, "c3351f27f6467fc33b319b36a7e64b75b2d992232f956d9c6243cc49e0c05396"); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + original := make(map[string]bool, len(inv.Legacy)) |
| + for hash := range inv.Legacy { |
| + original[hash] = true |
| + } |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-merge3.tsv", &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + for hash := range original { |
| + if !inv.Legacy[hash] { |
| + t.Fatalf("merge3 removed original freeze %s", hash) |
| + } |
| + } |
| + if len(inv.Legacy) != len(original)+3 { |
| + t.Fatal("merge ruling 3 permits exactly three new frozen declarations") |
| + } |
| + const seal = "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + for _, key := range []string{"internal/host/instancekeys.go:backfillInstanceKeys", "internal/host/instancekeys.go:keyBackfillInSlot.BackfillInstanceKey", "internal/host/layoutleg.go:activatorStart.Run"} { |
| + e := inv.Entries[key] |
| + if e.Class != "legacy" || e.Task != "accepted-legacy" || !strings.HasPrefix(e.Basis, "accepted-legacy;rev3=merge-ruling-3;") { |
| + t.Fatalf("wrong hotfix disposition: %+v", e) |
| + } |
| + } |
| + if inv.Entries["internal/host/agent.go:runAgent"].Class != "authority" { |
| + t.Fatal("runAgent lost kept authority") |
| + } |
| + inv.Legacy[strings.Repeat("0", 64)] = true |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) == 0 { |
| + t.Fatal("unapproved legacy fingerprint accepted") |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 986aef08f..c76b527ec 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -621,6 +621,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-merge3.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 8ef5e26a8..e5947356a 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -487,7 +487,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostbox/journalservice.go", "journalRequestHash", "key-site", "key:RecordKey", "8c07978658bb060c28ad88477dcbb10d8591ef194cac283cb54eb6a4e833ac76"}, |
| {"internal/hostbox/journalservice.go", "type JournalRequest", "key-site", "key:RecordKey", "14baf4c57a342a8b2dadaa95c965de4650ee352183de3a8c56768f47642ff5ab"}, |
| {"internal/hostbox/journalservice.go", "validateJournalRequest", "key-site", "key:RecordKey", "724be9634772f00bf0d9da2c39c8049763c9aad6f3075c10f31c97ab69c15103"}, |
| - {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "455e47321aad1c671f1d0ec468d0389b2cd2386b24f99cc3dd7c9251e5108bee"}, |
| + // Fix3 extends PID-zero binding states; RecordKey remains an identity read, with no record write. |
| + {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "8824e9a950f7d92ddbef2315938ec7702ec93814be24f067c6b8df1009ff4835"}, |
| {"internal/host/proofbinding.go", "type proofBinding", "key-site", "key:RecordKey", "418f61bc5a4192d99821b315baeb1a5b4619593303f8a02e9b13ef824003a613"}, |
| {"internal/host/rootroute.go", "proofBindingSource.claimIdentity", "key-site", "key:RecordKey", "ad6e0fc3f2cd360473a5d0a2267d2dc7944b45d80947613f791db8d573f8fee6"}, |
| {"internal/provision/rootrecord.go", "IndexRecords", "key-site", "key:ParseRecordKey,lit:instances", "2e45f3c659a61943727ac1413d431c8320dbc1d4e89cb4f43df60c819764abf9"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-merge3.tsv b/internal/provision/testdata/plan14-root-reads-merge3.tsv| new file mode 100644 |
| index 000000000..ddf7cc1dd |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-merge3.tsv |
| @@ -0,0 +1,36 @@ |
| +# Plan 14 fix3: merge ruling 3 records the released hotfix; merge ruling 4 restores no-census main behavior. Earlier overlays are immutable. |
| +internal/host/agent.go runAdoptUnderRootRelease authority B3,C rev3=merge-ruling-4;basis=Retain the verified running pointer before adoption; share per-pass legacy diagnostics and return errors only after minting and held-stage work. whole:Route@10432,whole:Route@17040,whole:Route@17076,whole:Route@7852 07e79632ccb0e66323fb10fb486308664109da813048dc5301d1225c5cbcd9c9 |
| +internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F rev3=merge-ruling-4;basis=Propagate final adoption error after releasing pass resources so foundation failures exit 1 without skipping remaining work. file:Open@220 21abd9a81113b038cfa5dbed17da9fe4da3cfafa8b80215dd68b85bd15344460 |
| +internal/host/agent.go runAgent authority B,C,D,F rev3=merge-ruling-3;basis=Only Route and preparation Slot wiring changed; claims-on bypasses legacy key backfill and all kept root decisions are unchanged. file:Exec@13447,file:Open@569,registry:App@9838,whole:Route@20948,whole:Route@9830 91afa8ed00ee9f26f047a03f651670ff693aa5bfcb119ac1bc2b7df585ddf897 |
| +@legacy 0655b2d76b56f3c4eb62c9254ae60a968978d8e7c91b53bb0fb549987538f12c internal/host/instancekeys.go backfillInstanceKeys legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Released v0.26.1 backfill runs after Start only with claims off; skipped or fenced rows defer to their next launch. registry:Host@516,registry:Host@664,registry:Host@830,whole:Route@251,whole:Route@283,whole:Route@362 72b84a844c91718c379ffe6a644adfaaccc449bc6893b9264bf43029142f2c2b |
| +internal/host/instancekeys.go backfillInstanceKeys legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Released v0.26.1 backfill runs after Start only with claims off; skipped or fenced rows defer to their next launch. registry:Host@516,registry:Host@664,registry:Host@830,whole:Route@251,whole:Route@283,whole:Route@362 72b84a844c91718c379ffe6a644adfaaccc449bc6893b9264bf43029142f2c2b |
| +@legacy f48611141d27534443aa79140c0c8b52a0bb2f3934d05ac47545a8286caf41da internal/host/instancekeys.go keyBackfillInSlot.BackfillInstanceKey legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-off legacy key backfill rereads registry Host and whole Route inside the per-host preparation slot; claims-on returns before backfill. registry:Host@199,registry:Host@258,whole:Route@351,whole:Route@386,whole:Route@550 4bc87bdff90d1c13fbd6210023377e99303318b0e1d24fa15a143298eb480f05 |
| +internal/host/instancekeys.go keyBackfillInSlot.BackfillInstanceKey legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-off legacy key backfill rereads registry Host and whole Route inside the per-host preparation slot; claims-on returns before backfill. registry:Host@199,registry:Host@258,whole:Route@351,whole:Route@386,whole:Route@550 4bc87bdff90d1c13fbd6210023377e99303318b0e1d24fa15a143298eb480f05 |
| +@legacy c78b9ec186c0661d7bff3719593e17e98b9f5a40de827fe44563fc8ccd2abc8b internal/host/layoutleg.go activatorStart.Run legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-on starts and returns before the background backfill; legacy layout recovery precedes Start and each backfill row uses the preparation slot. whole:Route@492 b7045ebf921fb26573077273c2cfeef7dc99a73f036a414e8dbf6ce2cbf2eb3c |
| +internal/host/layoutleg.go activatorStart.Run legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-on starts and returns before the background backfill; legacy layout recovery precedes Start and each backfill row uses the preparation slot. whole:Route@492 b7045ebf921fb26573077273c2cfeef7dc99a73f036a414e8dbf6ce2cbf2eb3c |
| +internal/host/legacyrestart.go legacyCheckedUnitRestart authority B3 rev3=merge-ruling-4;basis=No-census fallback matches main stamped-unit try-restart and settle behavior; Show or ExecStart mismatch still restarts and history errors are diagnostic. file:EvalSymlinks@378,registry:Host@241,registry:Unit@198,registry:Unit@352,registry:Unit@447,registry:Unit@485,registry:Unit@547,whole:Route@613,whole:Route@733 884d01a2b30cc90df5010341aacc52f5c7a7d3f9c2f9bf98b6dd868fae623133 |
| +internal/host/legacyrestart.go legacyRollbackRestart authority B3 rev3=merge-ruling-4;basis=No-census D1 recovery resets and restarts the stamped unit on the restored pointer and verifies readiness as main does. file:EvalSymlinks@312,registry:Host@359,registry:Unit@157,registry:Unit@193,registry:Unit@259,registry:Unit@425,whole:Route@491 b99366bdf6a7530d83a5dd9d086028a628a33cd1b860ab57cfc4f341d341c8b7 |
| +internal/host/legacyrestart.go legacyRootRestart authority B3,G rev3=merge-ruling-4;basis=Only absent root census with claims off permits main restart fallback; retained or unreadable root state remains strict. b2cf26dd5de78dd8fe57b6aac71b33df8602e9b5b0a0031736261e29f23738db |
| +internal/host/legacyrestart.go legacySettleOptions authority B3 rev3=merge-ruling-4;basis=Legacy settle uses main readiness inputs and limits; these do not authorize root health history. whole:Route@265,whole:Route@335 4bf5a4a5173c9f7d703502ae75d3aa5f8ef8fab7631625ecea2b1341ad855bc9 |
| +internal/host/legacyrestart.go restartEffectFailed authority B3 rev3=merge-ruling-4;basis=Separate actual restart and readiness failure from optional binding refusal before selecting fallback. d847a3853f83e0b4a08183c57294c9e06955cdcabbebbfc62767ca8a05fa880f |
| +internal/host/legacyrestart.go rootRestartChecks.allowLegacy authority B3,G rev3=merge-ruling-4;basis=Evaluate the explicit census selector; omitted test selector remains strict. eee21cfff9fbe73b79fd4da4aa034bf5bebc9db8591663f3ec2e122907590097 |
| +internal/host/legacyrestart.go rootRestartChecks.noteLegacy authority B3 rev3=merge-ruling-4;basis=Log the no-census legacy restart once through the pass-shared diagnostic latch. d2b7aad19cfb90d75fc599cf780195c3726060ac8cb8d677a9caa0ae4be267f6 |
| +internal/host/proofbinding.go pointerBoundUnit authority B3 rev3=merge-ruling-4;basis=Only PID-zero stopped or restarting states may bind without process identity; inactive units are never woken. 7a6ef2f2281f2af98db43cf247d6b3625c3c29b9cad48cbfd555ecc0185bd6ec |
| +internal/host/proofbinding.go proofBindingSource.binding authority B3 rev3=merge-ruling-4;basis=Inactive and PID-zero activating units bind from checked current pointers with the same unit and source checks as failed units. file:EvalSymlinks@2524,file:EvalSymlinks@2763,file:EvalSymlinks@3182,file:EvalSymlinks@3491,registry:Unit@461,registry:Unit@4694,registry:Unit@544,registry:Unit@717,whole:Route@5174,whole:Route@5181 8824e9a950f7d92ddbef2315938ec7702ec93814be24f067c6b8df1009ff4835 |
| +internal/host/rootrestart.go checkedUnitRestart authority B3 rev3=merge-ruling-4;basis=Retain verified rollback binding for active or restarting units; optional binding refusal is distinguished from a failed build. file:EvalSymlinks@1054,file:EvalSymlinks@1831,registry:Account@1400,registry:Account@2199,registry:App@1432,registry:App@2231,registry:Host@1101,registry:Host@1887,registry:Host@2774,registry:Host@3041,registry:Host@3684,registry:Host@510,registry:Unit@3566,registry:Unit@711,whole:Route@1408,whole:Route@1440,whole:Route@2207,whole:Route@2239,whole:Route@3574,whole:Route@638,whole:Route@719 b2abe0330fb6ce30c3f47283cf8ecb5501c60440654af7a05fa78abcce1bac4f |
| +internal/host/rootrestart.go productionRootRestartChecks authority B3 rev3=merge-ruling-4;basis=Production selects fallback from root census and never opens the record store for ordinary or derived-unit routes. file:Open@934,whole:Route@1568,whole:Route@1835,whole:Route@767 15e0904d572fedf1c6aa7925b981a4bfbc02e844a2b70913dd1e7069c52c3a38 |
| +internal/host/rootrestart.go restartBoundUnit authority B3 rev3=merge-ruling-4;basis=PID-zero activating units reset and restart; inactive units stay stopped; binding-only errors cannot roll back a promoted fix without a census. file:EvalSymlinks@635,registry:Account@2355,registry:Account@3825,registry:App@2391,registry:App@3865,registry:Host@1618,registry:Host@2257,registry:Host@2851,registry:Host@3564,registry:Host@682,registry:Unit@1104,registry:Unit@1172,registry:Unit@1927,registry:Unit@1995,registry:Unit@2037,registry:Unit@3200,registry:Unit@395,whole:Route@198,whole:Route@2361,whole:Route@2397,whole:Route@317,whole:Route@3833,whole:Route@3873,whole:Route@4084 9bf816e686f934cc2284ebfdc4e5be8157fd1b11a71bdd3f00174c27571a4c77 |
| +internal/host/rootrestart.go restartOrdinaryUnit authority B3 rev3=merge-ruling-4;basis=No-census derived-unit try-restart is unconditional and nonfatal, matching main for absent rows and systemd lookup failures. 93d374f29ba477a7698ab3360e64eca412667098a48a4078d9a33d53674bb23a |
| +internal/host/rootrestart.go restartVerifiedOrdinaryUnit authority B3 rev3=merge-ruling-4;basis=Retain the checked ordinary-unit path for strict census-backed operation. 1c43012a5dd6791d16dc96e79593a82f6b5e422460e11016e08f85dcf47307dc |
| +internal/host/rootrestart.go rollbackUnitRestart authority B3 rev3=merge-ruling-4;basis=Retained census keeps strict rollback; no-census missing binding falls back to main stamped start or D1 restart and ordinary rows start nothing. file:Read@1458,registry:Host@1750,registry:Unit@1852,registry:Unit@2153,registry:Unit@435,registry:Unit@692,whole:Route@1758,whole:Route@1860,whole:Route@2161,whole:Route@422,whole:Route@774 1121f62300b314b4eb9a7df92ecc65c8bc61f7387732a143760a84080ecd53ba |
| +internal/host/rootrestart.go rootRestartChecks.runningVersion authority B3 rev3=fix3-Astra-P2;basis=Before PointLive replaces previous, resolve the running build against checked root pointers; absent binding cannot block a no-census promotion. registry:Host@151,registry:Unit@104,registry:Unit@121,whole:Route@208 e5c1f372325482e101e6fac6da462578015c8d804dbe8dc73b2f92478ba944bb |
| +internal/host/rootrestart.go rootRestartRouteWith authority B3 rev3=merge-ruling-4;basis=No-census ordinary routes use main without store reads; stamped routes fall back on binding refusal and operator stops preserve the promoted link. registry:Unit@439,registry:Unit@456,whole:Route@1123,whole:Route@1320,whole:Route@426,whole:Route@581,whole:Route@709,whole:Route@832,whole:Route@993 110b8180846fff0a47e20963a86b95bcaec41884abbecce34cc6336e8fa6c105 |
| +internal/host/rootrestart.go rootStartRoute authority B3 rev3=merge-ruling-4;basis=Use main systemctl start for legacy non-NotUp recovery while retaining checked rollback when authority binds. bb4a59f4359808db51f47fb7eff1b89eea4b4d6d927cbd5d517a4caccff56e5f |
| +internal/hostguard/flock_unix.go acquire authority C,J2,J4 rev3=merge-ruling-4;basis=Descriptor checks classify nonregular or foreign-owner locks as unsafe; honest contention waits with bounded exponential backoff. file:Fstat@1356,file:Fstat@398,file:Fstatat@665,file:Openat@1024,file:Openat@897 3f027811822f7c0d967227b3dea6df3c17d0630233559cc016c3dcacfd9db488 |
| +internal/hostguard/guard.go Guard.locked authority C rev3=merge-ruling-4;basis=Only positively unsafe legacy locks durably quarantine; timeout and I/O errors return without changing root installation state. b4cd2e2344a4668cad288cc78b9092e7144505d15c36d5a56faf22e1edb66354 |
| +internal/release/adopt.go WithRunningVersion authority B3 rev3=fix3-Astra-P2;basis=Root-only hook supplies verified current or previous running authority before replacing pointers. d5834f585168970bb17868fdc05cc8eadd9d8b461bb7d831e0327926e9ba23a3 |
| +internal/release/adopt.go adoptHost authority B3,E1 rev3=fix3-Astra-P2;basis=Repoint retains a verified running previous build and uses it as rollback target when a crash left an intermediate live link. registry:App@10271,registry:App@10431,registry:App@11460,registry:App@11917,registry:App@13283,registry:App@17743,registry:App@23119,registry:App@30878,registry:App@3114,registry:App@6423,registry:App@7697,registry:App@7891,registry:Hibernate@31557,registry:Host@10151,registry:Host@10184,registry:Host@10284,registry:Host@10444,registry:Host@10546,registry:Host@10578,registry:Host@10643,registry:Host@11093,registry:Host@11226,registry:Host@11546,registry:Host@12007,registry:Host@12755,registry:Host@13056,registry:Host@13269,registry:Host@13585,registry:Host@17305,registry:Host@17413,registry:Host@17711,registry:Host@18507,registry:Host@18665,registry:Host@19127,registry:Host@19179,registry:Host@19299,registry:Host@19386,registry:Host@20492,registry:Host@20687,registry:Host@2075,registry:Host@21347,registry:Host@21732,registry:Host@21873,registry:Host@2264,registry:Host@23087,registry:Host@23214,registry:Host@2345,registry:Host@24556,registry:Host@25206,registry:Host@28439,registry:Host@29057,registry:Host@29382,registry:Host@29456,registry:Host@29703,registry:Host@29854,registry:Host@31035,registry:Host@31142,registry:Host@3127,registry:Host@31370,registry:Host@31513,registry:Host@32258,registry:Host@32384,registry:Host@32472,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7583,registry:Host@7751,registry:Host@7877,registry:Host@7996,registry:Host@8474,registry:Host@8861,registry:Host@9087,registry:Host@9183,registry:Kind@552,registry:Unit@29602,registry:Unit@577,whole:Route@10266,whole:Route@10426,whole:Route@11450,whole:Route@11907,whole:Route@13786,whole:Route@14878,whole:Route@15181,whole:Route@17634,whole:Route@17733,whole:Route@20221,whole:Route@2176,whole:Route@23109,whole:Route@28010,whole:Route@3109,whole:Route@3554,whole:Route@6418,whole:Route@9408 2096f8ff1a3fbfbf393dbf2dbb02a9c876349d9b5f12fc47346ace43c3c75d16 |
| +internal/release/versions.go PointLive authority B3,E2 rev3=fix3-Astra-P2;basis=Optional retained running version must match current or previous; refuse dropping its last pointer, keeping existing sweep pins authoritative. file:Open@1407,file:Readlink@388,file:Readlink@923,file:Stat@147 15c007f95df3b1e29f085276459c5f5a28bc77034a374e30e9229c8efe2ea19f |
| +@coverage internal/host b68d60475aa8a0c501b9e9b3207a5cbd9b5997332ec37f16c0c57bf3470b8c46 |
| +@coverage internal/hostguard b5146fee2ef6c7fac40f140ba687e85b632687eb04bf69fac107b9936551bbc0 |
| +@coverage internal/release b88ee53f7cc078890a0bc6abba55e830542a63077bc1185dcc1f4b37c9c66f49 |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index a543f803f..4a75141c5 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -37,6 +37,7 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| type PassOption func(*passConfig) |
| type passConfig struct { |
| + runningVersion func(context.Context, registry.Route) (string, error) |
| propagateRestarts bool |
| restartAttemptSettled func(string) error |
| restartErrors *[]error |
| @@ -127,6 +128,12 @@ type passConfig struct { |
| tags map[string]map[string]string |
| } |
| +// WithRunningVersion retains root's verified running build through a repoint. |
| +// It must already be named by current or previous; PointLive checks that again. |
| +func WithRunningVersion(running func(context.Context, registry.Route) (string, error)) PassOption { |
| + return func(c *passConfig) { c.runningVersion = running } |
| +} |
| + |
| // FailedBuild reports whether host may not be moved onto build: the |
| // zero-downtime failure book (rollout.FailureBook), taken as a func because |
| // rollout imports this package. served is build == the route's GoodVersion. |
| @@ -1519,7 +1526,23 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| converged = true |
| } |
| - if err := withReleaseWork(ctx, work, id, func() error { return PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App) }); err != nil { |
| + if err := withReleaseWork(ctx, work, id, func() error { |
| + running := "" |
| + if cfg.runningVersion != nil { |
| + var err error |
| + running, err = cfg.runningVersion(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + if err := PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App, running); err != nil { |
| + return err |
| + } |
| + if running != "" { |
| + cur = running |
| + } |
| + return nil |
| + }); err != nil { |
| // The symlink did not move, so the box is still serving cur — and the |
| // unit shape on disk is the new version's. Put the old one back, or a |
| // port the running binary never asked for stays publicly bound. |
| @@ -1767,8 +1790,12 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| if cfg.restartErrors != nil { |
| *cfg.restartErrors = append(*cfg.restartErrors, serr) |
| } |
| - fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host may be serving nothing\n", |
| - rt.Host, cur, serr) |
| + if rt.Unit == "" && !isNotUp { |
| + fmt.Printf(" %s: rolled back to %s but could not start it: %v\n", rt.Host, cur, serr) |
| + } else { |
| + fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host may be serving nothing\n", |
| + rt.Host, cur, serr) |
| + } |
| } |
| } |
| if isNotUp { |
diff --git a/internal/release/plan14_fix3_previous_test.go b/internal/release/plan14_fix3_previous_test.go| new file mode 100644 |
| index 000000000..09656d4b1 |
| --- /dev/null |
| +++ b/internal/release/plan14_fix3_previous_test.go |
| @@ -0,0 +1,77 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestPlan14Fix3RetainRunningPrevious(t *testing.T) { |
| + for _, running := range []string{"v1", "v2", "v0", "../escape"} { |
| + t.Run(running, func(t *testing.T) { |
| + opt := t.TempDir() |
| + host := "console.example" |
| + for _, v := range []string{"v0", "v1", "v2", "v3"} { |
| + if err := StageArtifact(opt, "bab", v, "console", []byte(v)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, v := range []string{"v1", "v2"} { |
| + if err := PointLive(opt, host, "bab", v, "console"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err := PointLive(opt, host, "bab", "v3", "console", running) |
| + live, e := LiveVersion(opt, host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + previous, e := os.Readlink(filepath.Join(opt, "previous", host)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if running == "v0" || running == "../escape" { |
| + if err == nil || live != "v2" || previous != VersionPath(opt, "bab", "v1", "console") { |
| + t.Fatal("unpointed build acquired authority", err, live, previous) |
| + } |
| + } else { |
| + if err != nil || live != "v3" || previous != VersionPath(opt, "bab", running, "console") { |
| + t.Fatal("running pointer lost", err, live, previous) |
| + } |
| + pins, err := LivePins(opt) |
| + if err != nil || !pins[running] || !pins["v3"] { |
| + t.Fatal(pins, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14Fix3OrdinaryFailureDoesNotClaimOutage(t *testing.T) { |
| + for _, unit := range []string{"", "console.service"} { |
| + t.Run(unit, func(t *testing.T) { |
| + host := "console.example.com" |
| + ctx, reg, store, pub, opt := startUnitFixture(t, host) |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rt.Unit = unit |
| + if err := reg.Upsert(*rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + fail := func(string) error { return os.ErrPermission } |
| + var output bytes.Buffer |
| + withCapturedStdout(t, &output, func() { |
| + if err := Pass(ctx, reg, store, pub, opt, fail, WithStartUnit(fail)); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + if !strings.Contains(output.String(), "could not start it") || strings.Contains(output.String(), "may be serving nothing") != (unit != "") { |
| + t.Fatal(output.String()) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go| index 2a3fb4769..2f83a657a 100644 |
| --- a/internal/release/proofgate.go |
| +++ b/internal/release/proofgate.go |
| @@ -85,6 +85,9 @@ func NewProofGate(policyPath, bakeDir string) *ProofGate { |
| // NewRootProofGate keeps diagnostic output in bakeDir. Health authority is |
| // always read from the root-owned history, including in the agent's gate. |
| +// Do not enable a D3 requirement until both current and previous builds have |
| +// root history. Service-owned history is not imported; enabling it earlier |
| +// can block an otherwise authorized promote-previous recovery without proof. |
| func NewRootProofGate(policyPath, bakeDir string) *ProofGate { |
| g := NewProofGate(policyPath, bakeDir) |
| g.healthyDir = RootHealthyDir |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index 91068977c..829f1739b 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -282,7 +282,7 @@ func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) { |
| // PointLive atomically repoints a host's live symlink at ver's build of app. |
| // It verifies ver is staged first, so a typo can't leave a dangling link |
| // that would fail the unit's ExecStart on the next restart. |
| -func PointLive(optDir, host, acct, ver, app string) error { |
| +func PointLive(optDir, host, acct, ver, app string, runningVersion ...string) error { |
| target := VersionPath(optDir, acct, ver, app) |
| if _, err := os.Stat(target); err != nil { |
| return fmt.Errorf("version %q of %s is not staged (%s)", ver, app, target) |
| @@ -293,6 +293,22 @@ func PointLive(optDir, host, acct, ver, app string) error { |
| link := LiveLink(optDir, host) |
| if old, err := os.Readlink(link); err == nil && old != target { |
| dir := filepath.Join(optDir, "previous") |
| + // Root may have crashed after the last repoint but before restart. |
| + // Keep the verified running build's pointer instead of replacing its |
| + // last authority with an intermediate build that never ran. |
| + if len(runningVersion) == 1 && runningVersion[0] != "" { |
| + if !ValidVersion(runningVersion[0]) { |
| + return os.ErrPermission |
| + } |
| + running := VersionPath(optDir, acct, runningVersion[0], app) |
| + if running != old { |
| + prior, err := os.Readlink(filepath.Join(dir, host)) |
| + if err != nil || prior != running { |
| + return os.ErrPermission |
| + } |
| + old = running |
| + } |
| + } |
| if err := safefile.MkdirAll(dir, 0755); err != nil { |
| return err |
| } |