carlos / platform Public

moves: env pin fix round 2 (home part 2, 2b)

A box variable (HOSTNAME, PWD and so on) is the box's only while the
box supplies it: the agent never overwrites one, and childEnv lays the
instance's files over the inherited value. moves.Pinned now excludes
agent-owned variables always and a box variable only when inherited;
one the instance's own layers supplied is pinned like an app variable,
and T3 compares it. The composer reports the layer names
(ExecBackend.LaunchLayers), carried by the moves hooks, the launch
source (ClaimServingLaunch.Instance) and the launch inputs to the probe
owners' checks; the report names them (EnvReport.Instance).

The recovery keeps the whole local env (platform-1e's same-box rule):
recoveryInputs records moves.LocalDigest and the recovery owners check
localEnvDigest, so a box-owned change between the record and the wake
is refused. Only the cross-box pin and the probe use the portable env.

The agent-owned pin test asserts each feature configuration's exact
values and removals. Census: the home-part2 overlay re-freezes eleven
units at main's dispositions (nine legacy frozen declarations, new
seal); the record-write row for writeSourceReport is re-digested.
Ruling owed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b59c5fbb2d3f2f8c2d815190e4b0eedf1feff880 parent f4f51ba
32 files changed, +648 −196
  • internal/activator/agentowned_moves_test.go +102 −41
  • internal/activator/backend_exec.go +19 −3
  • internal/host/claimmoveaccess.go +9 −2
  • internal/host/claimowners.go +6 −1
  • internal/host/claimwakeassembly.go +5 −1
  • internal/host/movescut.go +29 −3
  • internal/host/movescut_test.go +4 −4
  • internal/host/movesenvpin_test.go +135 −17
  • internal/host/movesheldenv.go +10 −10
  • internal/host/moveshome_fixture_test.go +3 −3
  • internal/host/moveshome_target_test.go +8 −2
  • internal/host/movesland_test.go +1 −1
  • internal/host/movesparks_test.go +11 −4
  • internal/host/movespass.go +8 −6
  • internal/host/movesphase1.go +2 −2
  • internal/host/movesphase1_harness_test.go +20 −11
  • internal/host/movesphase1_test.go +1 −1
  • internal/host/movesprobeowners.go +33 −10
  • internal/host/movesprobeowners_committed_test.go +1 −1
  • internal/host/movesprobeowners_test.go +2 −2
  • internal/host/movesrecoveryowners.go +2 −2
  • internal/host/movesrecoveryowners_test.go +1 −1
  • internal/host/movesrecoveryprobe.go +1 −1
  • internal/host/movesreport.go +2 −2
  • internal/host/movesreport_test.go +10 −10
  • internal/host/moveswakesource_linux_test.go +4 −4
  • internal/host/plan10_j2_adapter_test.go +1 −1
  • internal/moves/envreport.go +99 −27
  • internal/moves/envreport_test.go +81 −12
  • internal/provision/plan14_root_reads_test.go +7 −5
  • internal/provision/plan9_b8_census_test.go +4 −1
  • internal/provision/testdata/plan14-root-reads-home-part2.tsv +27 −5

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	309.885s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.869s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.426s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.708s
ok  	github.com/carlosframework/platform/internal/activator	250.584s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.021s
ok  	github.com/carlosframework/platform/internal/appconfig	2.486s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.131s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.731s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.009s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.703s
ok  	github.com/carlosframework/platform/internal/changelog	0.308s
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	397.008s
?   	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.106s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.011s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
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.407s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.005s
ok  	github.com/carlosframework/platform/internal/edge	27.345s
?   	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.133s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.015s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/host	141.854s
ok  	github.com/carlosframework/platform/internal/hostactors	0.522s
ok  	github.com/carlosframework/platform/internal/hostbox	2.429s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.511s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	6.118s
ok  	github.com/carlosframework/platform/internal/hostfence	0.036s
ok  	github.com/carlosframework/platform/internal/hostfiles	11.036s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.013s
ok  	github.com/carlosframework/platform/internal/hostguard	0.970s
ok  	github.com/carlosframework/platform/internal/hostops	1.615s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.005s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.460s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.150s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	4.185s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.744s
ok  	github.com/carlosframework/platform/internal/instancefs	0.005s
ok  	github.com/carlosframework/platform/internal/instancekey	0.002s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.006s
ok  	github.com/carlosframework/platform/internal/layout	1.528s
ok  	github.com/carlosframework/platform/internal/lease	9.841s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.165s
ok  	github.com/carlosframework/platform/internal/maintenance	0.086s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.794s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.084s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	22.132s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.796s
ok  	github.com/carlosframework/platform/internal/objstore	0.188s
ok  	github.com/carlosframework/platform/internal/ops	0.181s
ok  	github.com/carlosframework/platform/internal/park	0.197s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	18.575s
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	5.257s
ok  	github.com/carlosframework/platform/internal/relay	7.346s
ok  	github.com/carlosframework/platform/internal/release	21.833s
ok  	github.com/carlosframework/platform/internal/rollout	4.219s
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.011s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/runs	0.586s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.582s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	13.468s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.176s
ok  	github.com/carlosframework/platform/internal/status	0.038s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.223s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.562s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46399650 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/15 17:15:14 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC35EFBB6E16B, 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.021s
minio contract: PASS
diff --git a/internal/activator/agentowned_moves_test.go b/internal/activator/agentowned_moves_test.go
index 92afa9410..b47e89908 100644
--- a/internal/activator/agentowned_moves_test.go
+++ b/internal/activator/agentowned_moves_test.go
@@ -2,6 +2,8 @@ package activator
import (
"maps"
+ "os"
+ "path/filepath"
"slices"
"testing"
@@ -11,59 +13,118 @@ import (
)
// TestAgentOwnedVarsAreMovesBoxOwned pins moves.AgentOwnedVars against
-// agentOwnedVars (home part 2, Task 2b). A move's env pin and T3 leave out
-// exactly the box-owned variables, because each box composes them for
-// itself; a variable the agent starts to set, overwrite or remove without
-// joining that set would make every cross-region move's pin differ on the
-// target again. Every variable agentOwnedVars sets on an empty file env,
-// with every feature on, and every tenant-provided variable it overwrites or
-// removes with every feature off, is in moves.AgentOwnedVars, and nothing
-// else is.
+// agentOwnedVars (home part 2, Task 2b; fix round 2). A move's env pin and
+// T3 leave the agent-owned variables out, because each box composes them
+// for itself; a variable the agent starts to set, overwrite or remove
+// without joining that set would make every cross-region move's pin differ
+// on the target again, or let an instance-supplied value slip past it.
+//
+// Each feature configuration is asserted separately, against a tenant env
+// file that names every agent-owned variable: the exact value the agent
+// sets, or the variable's absence where the agent removes it. Removing any
+// one branch's set or deletion fails the test. The variables named here
+// are the ones the test knows: a new variable the agent only ever removes
+// (never sets) appears in no configuration's output, so it must be added
+// to moves.AgentOwnedVars and to the tenant file below by hand.
func TestAgentOwnedVarsAreMovesBoxOwned(t *testing.T) {
rt := registry.Route{Host: "shop.example.com", Socket: "/run/carlos/shop.example.com.sock"}
- touched := map[string]bool{}
-
- on := &ExecBackend{Region: "uk", Cache: edge.NewCache(16, 1<<20)}
- for k := range on.agentOwnedVars(rt, rt.Socket+".control", map[string]string{}, true) {
- touched[k] = true
- }
-
- // A tenant's env file naming every CARLOS_* variable the agent might
- // own, and one it does not: what the agent overwrites or removes is
- // its own.
- tenant := map[string]string{"APP_MODE": "tenant"}
+ control := rt.Socket + ".control"
+ tenant := map[string]string{"APP_MODE": "tenant", "HOSTNAME": "tenant"}
for k := range moves.AgentOwnedVars {
tenant[k] = "tenant"
}
- for k := range touched {
- tenant[k] = "tenant"
- }
- for _, b := range []*ExecBackend{{}, on} {
- for _, control := range []bool{false, true} {
- got := b.agentOwnedVars(rt, rt.Socket+".control", tenant, control)
- for k, v := range tenant {
- if got[k] != v {
- touched[k] = true
+ cache := edge.NewCache(16, 1<<20)
+ absent := "<absent>"
+ for _, c := range []struct {
+ name string
+ b *ExecBackend
+ control bool
+ want map[string]string
+ }{
+ {"everything on", &ExecBackend{Region: "uk", Cache: cache}, true,
+ map[string]string{"CARLOS_CONTROL_SOCKET": control, "CARLOS_PURGE": "header", "CARLOS_REGION": "uk", "CARLOS_HOST": rt.Host}},
+ {"everything off", &ExecBackend{}, false,
+ map[string]string{"CARLOS_CONTROL_SOCKET": absent, "CARLOS_PURGE": absent, "CARLOS_REGION": absent, "CARLOS_HOST": rt.Host}},
+ {"no control channel", &ExecBackend{Region: "uk", Cache: cache}, false,
+ map[string]string{"CARLOS_CONTROL_SOCKET": absent, "CARLOS_PURGE": "header", "CARLOS_REGION": "uk", "CARLOS_HOST": rt.Host}},
+ {"no cache", &ExecBackend{Region: "uk"}, true,
+ map[string]string{"CARLOS_CONTROL_SOCKET": control, "CARLOS_PURGE": absent, "CARLOS_REGION": "uk", "CARLOS_HOST": rt.Host}},
+ {"a home box, no region", &ExecBackend{Cache: cache}, true,
+ map[string]string{"CARLOS_CONTROL_SOCKET": control, "CARLOS_PURGE": "header", "CARLOS_REGION": absent, "CARLOS_HOST": rt.Host}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ for _, file := range []map[string]string{{}, tenant} {
+ got := c.b.agentOwnedVars(rt, control, file, c.control)
+ for k, want := range c.want {
+ v, ok := got[k]
+ if want == absent && ok {
+ t.Errorf("file %v: %s = %q, want it removed", file, k, v)
+ }
+ if want != absent && (!ok || v != want) {
+ t.Errorf("file %v: %s = %q (present %v), want %q", file, k, v, ok, want)
+ }
}
- }
- for k := range got {
- if _, ok := tenant[k]; !ok {
- touched[k] = true
+ // Nothing else is the agent's: the tenant's own variables,
+ // a box variable among them, pass through untouched.
+ for k, v := range got {
+ if _, owned := c.want[k]; !owned && file[k] != v {
+ t.Errorf("file %v: the agent set %s = %q", file, k, v)
+ }
}
}
+ })
+ }
+ // The configurations above cover exactly moves' set, and every member
+ // is box-owned and never pinned.
+ covered := slices.Sorted(maps.Keys(map[string]string{"CARLOS_CONTROL_SOCKET": "", "CARLOS_PURGE": "", "CARLOS_REGION": "", "CARLOS_HOST": ""}))
+ if want := slices.Sorted(maps.Keys(moves.AgentOwnedVars)); !slices.Equal(covered, want) {
+ t.Fatalf("the test covers %v, moves.AgentOwnedVars is %v", covered, want)
+ }
+ for _, k := range covered {
+ if !moves.BoxOwned(k) || moves.Pinned(k, true) || moves.Class(k) == "equal" {
+ t.Errorf("%s is agent-owned but pinned or compared (class %q)", k, moves.Class(k))
+ }
+ }
+}
+
+// TestLaunchLayersNamesTheInstanceLayers (fix round 2): LaunchLayers is
+// LaunchEnvironment with the names the instance's own layers supplied, the
+// rendered configuration file's and the own .env's. A box variable an
+// instance file sets is one of them; one inherited from the agent's
+// process is not, and neither is anything the agent adds.
+func TestLaunchLayersNamesTheInstanceLayers(t *testing.T) {
+ b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"}
+ rt := registry.Route{Host: "a.go.example.com", Socket: filepath.Join(b.DataDir, "a.sock")}
+ t.Setenv("PWD", "/inherited")
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("MINE=1\nHOSTNAME=instance\nCARLOS_REGION=fake\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte("FROM_CONFIG=c\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ env, instance, shadowed, err := b.LaunchLayers(rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, plainShadowed, err := b.LaunchEnvironment(rt)
+ if err != nil || !maps.Equal(env, plain) || !slices.Equal(shadowed, plainShadowed) {
+ t.Fatalf("LaunchLayers is not LaunchEnvironment: %v", err)
+ }
+ for _, k := range []string{"MINE", "HOSTNAME", "FROM_CONFIG"} {
+ if !instance[k] {
+ t.Errorf("%s came from the instance's files but is not named", k)
}
}
- got := slices.Sorted(maps.Keys(touched))
- want := slices.Sorted(maps.Keys(moves.AgentOwnedVars))
- if !slices.Equal(got, want) {
- t.Fatalf("agentOwnedVars owns %v, moves.AgentOwnedVars says %v", got, want)
+ if env["HOSTNAME"] != "instance" || env["PWD"] != "/inherited" {
+ t.Fatalf("HOSTNAME %q PWD %q", env["HOSTNAME"], env["PWD"])
}
- for _, k := range got {
- if !moves.BoxOwned(k) || moves.Class(k) == "equal" {
- t.Errorf("%s is agent-owned but not box-owned (class %q)", k, moves.Class(k))
+ for _, k := range []string{"PWD", "CARLOS_HOST", "CARLOS_CONTROL_SOCKET"} {
+ if instance[k] {
+ t.Errorf("%s did not come from the instance's files but is named", k)
}
}
- if touched["APP_MODE"] {
- t.Fatal("a tenant's own variable was treated as agent-owned")
+ // The instance's HOSTNAME is pinned; the inherited PWD is not.
+ if !moves.Pinned("HOSTNAME", instance["HOSTNAME"]) || moves.Pinned("PWD", instance["PWD"]) {
+ t.Fatal("the pin does not follow the layers")
}
}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index c174bde08..c29416eac 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -2732,11 +2732,23 @@ func (b *ExecBackend) instanceEnvParts(rt registry.Route) (cfgEnv, ownEnv map[st
// not a reconstruction of it. The values are raw: the caller HMACs them
// and must never log or return them.
func (b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, []string, error) {
+ env, _, shadowed, err := b.LaunchLayers(rt)
+ return env, shadowed, err
+}
+
+// LaunchLayers is LaunchEnvironment with the names the instance's own
+// layers supplied: every name of the rendered configuration file and the
+// instance's own <host>.env, as mergeEnv merged them, before the agent's
+// variables are applied. A name in the composed env that is not in
+// instance came from the agent: its own variables (agentOwnedVars) or its
+// process environment (childEnv). The move's env pin needs the difference
+// (moves.ReportLaunch): a box variable such as HOSTNAME is the box's when
+// inherited and the instance's when its files set it.
+func (b *ExecBackend) LaunchLayers(rt registry.Route) (env map[string]string, instance map[string]bool, shadowed []string, err error) {
cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
if err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
- var shadowed []string
for k := range ownEnv {
if _, rendered := cfgEnv[k]; rendered {
if k == instancekey.Var && ownEnv[k] == "" {
@@ -2747,13 +2759,17 @@ func (b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, [
}
sort.Strings(shadowed)
fileEnv := mergeEnv(cfgEnv, ownEnv)
+ instance = make(map[string]bool, len(fileEnv))
+ for k := range fileEnv {
+ instance[k] = true
+ }
out := map[string]string{}
for _, kv := range childEnv(b.agentOwnedVars(rt, rt.Socket+".control", fileEnv, true)) {
if i := strings.IndexByte(kv, '='); i > 0 {
out[kv[:i]] = kv[i+1:]
}
}
- return out, shadowed, nil
+ return out, instance, shadowed, nil
}
// childEnv is the child's complete environment: the agent's own, minus
diff --git a/internal/host/claimmoveaccess.go b/internal/host/claimmoveaccess.go
index f84bb3aba..2e18929e2 100644
--- a/internal/host/claimmoveaccess.go
+++ b/internal/host/claimmoveaccess.go
@@ -169,7 +169,7 @@ func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context,
if err := declinedClaimGate(backend.OptDir, rt, snapshot); err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
}
- env, _, err := backend.LaunchEnvironment(rt)
+ env, supplied, _, err := backend.LaunchLayers(rt)
if err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
}
@@ -189,7 +189,14 @@ func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context,
}
digest := sha256.Sum256(raw)
snapshot.Identity.Config = hex.EncodeToString(digest[:])
- return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: entries, Version: snapshot.Version, Staged: snapshot.Staged, Limit: snapshot.Limit}, moves.ReportEnv(key, env), nil
+ instance := make([]string, 0, len(supplied))
+ for k := range supplied {
+ if _, ok := env[k]; ok {
+ instance = append(instance, k)
+ }
+ }
+ sort.Strings(instance)
+ return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: entries, Instance: instance, Version: snapshot.Version, Staged: snapshot.Staged, Limit: snapshot.Limit}, moves.ReportLaunch(key, env, supplied), nil
}
}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index 75bf335ae..50ef43514 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -107,7 +107,12 @@ type ClaimServingLaunch struct {
Executable string
ExecutableSHA256 string
Args, Env []string
- Version string
+ // Instance is the names in Env the instance's own layers supplied
+ // (ExecBackend.LaunchLayers), sorted; the moves launch source sets it.
+ // The move's env pin covers a box variable only when the instance
+ // supplied it (moves.Pinned).
+ Instance []string
+ Version string
// Staged is the file ExecutableSHA256 hashes and the version it is
// staged under, from one resolution of the live link
// (activator.ClaimLaunchSnapshot.Staged).
diff --git a/internal/host/claimwakeassembly.go b/internal/host/claimwakeassembly.go
index b5eb48b44..644be9bf6 100644
--- a/internal/host/claimwakeassembly.go
+++ b/internal/host/claimwakeassembly.go
@@ -34,7 +34,11 @@ type ClaimLaunchInputs struct {
Executable string
ExecutableSHA256 string
Args, Env []string
- DBPath, Version string
+ // Instance is the launch's ClaimServingLaunch.Instance, carried for the
+ // moves owners' env pin check at Materialize. It is not a launch input:
+ // ClaimLaunchDigest does not cover it.
+ Instance []string
+ DBPath, Version string
// Staged is the file ExecutableSHA256 hashes and the version it is
// staged under (ClaimServingLaunch.Staged); Prepare hands it to the
// start, which records it before the exec (settings spec §4.2).
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index 1c946ea11..f09cd89d8 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -357,8 +357,26 @@ func (c *cutRun) inputsHold(ctx context.Context) (bool, error) {
// currentInputs are the inputs the admission compares (§7.4 admitted): the
// bundle and overlay versions, the live release and the launch report's
-// digest, as this box would publish them now.
+// digest, as this box would publish them now. The digest is the
+// move-portable one (moves.PortableDigest), since the pin it is compared
+// with crosses to the target.
func (w *movesWorker) currentInputs(ctx context.Context, s release.Store, rec provision.Record) (provision.MoveInputs, error) {
+ return w.inputsDigested(ctx, s, rec, moves.PortableDigest)
+}
+
+// recoveryInputs are the inputs a recovery probe records and then checks on
+// this same box (movesrecoveryprobe.go): currentInputs' fields, with the
+// digest of the whole composed environment (moves.LocalDigest), box-owned
+// variables included. The record and the check are made on one box just
+// before the wake, so the box's own variables are part of what runs and
+// must not change between them either (fix round 2).
+func (w *movesWorker) recoveryInputs(ctx context.Context, s release.Store, rec provision.Record) (provision.MoveInputs, error) {
+ return w.inputsDigested(ctx, s, rec, func(key []byte, env map[string]string, _ map[string]bool) string {
+ return moves.LocalDigest(key, env)
+ })
+}
+
+func (w *movesWorker) inputsDigested(ctx context.Context, s release.Store, rec provision.Record, digest func(key []byte, env map[string]string, instance map[string]bool) string) (provision.MoveInputs, error) {
cfg := w.cfg
if cfg.InputVersions == nil || cfg.LaunchEnv == nil {
return provision.MoveInputs{}, errors.New("no way to read the current inputs on this box")
@@ -375,11 +393,19 @@ func (w *movesWorker) currentInputs(ctx context.Context, s release.Store, rec pr
if err != nil {
return provision.MoveInputs{}, err
}
- env, _, err := cfg.LaunchEnv(rec.Host)
+ env, instance, err := cfg.LaunchEnv(rec.Host)
if err != nil {
return provision.MoveInputs{}, fmt.Errorf("compose the launch environment: %w", err)
}
- return provision.MoveInputs{BundleETags: bundle, OverlayETag: overlay, Release: live, EnvReport: moves.ReportEnv(key, env).Digest}, nil
+ // The probe-only variables are dropped as the owners' checks drop them
+ // (launchVars): the probe sets its own, whatever the files say.
+ vars := make(map[string]string, len(env))
+ for k, v := range env {
+ if !probeOnlyVars[k] {
+ vars[k] = v
+ }
+ }
+ return provision.MoveInputs{BundleETags: bundle, OverlayETag: overlay, Release: live, EnvReport: digest(key, vars, instance)}, nil
}
// sameInputs is the admission's comparison: every bundle etag, the overlay
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go
index 3b313a18e..0f3947853 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -149,16 +149,16 @@ func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness {
// targetCfg composes with the harness's current bundle versions.
func (h *cutHarness) target() moveCfg {
c := h.targetCfg()
- c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env, err := h.heldEnv(ctx, s, rec, carried)
+ c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ env, instance, err := h.heldEnv(ctx, s, rec, carried)
if err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
b := map[string]string{}
for k, v := range h.bundle {
b[k] = v
}
- return env, nil, provision.MoveInputs{BundleETags: b}, nil
+ return env, instance, nil, provision.MoveInputs{BundleETags: b}, nil
}
return c
}
diff --git a/internal/host/movesenvpin_test.go b/internal/host/movesenvpin_test.go
index dc1116213..dd0d45fb1 100644
--- a/internal/host/movesenvpin_test.go
+++ b/internal/host/movesenvpin_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
)
@@ -71,29 +72,146 @@ func TestProbeEnvPinRefusesAnInstanceChange(t *testing.T) {
}
}
-// TestRecoveryEnvCheckIsMovePortable: the recovery owners judge the
-// recovering box's launch against the recorded recovery inputs with the
-// same move-portable digest (envDigest). In production both sides are
-// composed on the recovering box (the inputs are its currentInputs,
-// recorded just before the wake: movesrecoveryprobe.go), so their box-owned
-// variables agree there; this pins that the recovery owners hash the shared
-// portable env, so inputs that differ only in box-owned variables are the
-// same inputs, and an instance variable that differs is still refused.
-func TestRecoveryEnvCheckIsMovePortable(t *testing.T) {
- recorded := []string{"A=1", "CARLOS_REGION=uk", "CARLOS_CONTROL_SOCKET=/run/carlos/uk.sock.control", "CARLOS_HOST=" + poHost, "CARLOS_PURGE=header"}
- launched := []string{"A=1", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost, "CARLOS_MOVE_PROBE=0"}
+// TestProbeEnvPinCoversAnInstanceBoxVariable (fix round 2): a box
+// variable such as HOSTNAME is the box's only while the box supplies it.
+// The agent never overwrites it, and childEnv lays the instance's files
+// over the inherited value, so one the instance's own .env sets is what
+// runs and is pinned like an app variable. Through the production
+// composition on both boxes:
+// - the target's .env gains HOSTNAME=b after the pin (the source's came
+// from its box): refused;
+// - the source's .env sets HOSTNAME=a and the target's still does after
+// the landing: the pin holds and the landing commits;
+// - the target's .env changes it to HOSTNAME=b after the pin: refused.
+func TestProbeEnvPinCoversAnInstanceBoxVariable(t *testing.T) {
+ for name, opts := range map[string][]p1Option{
+ "named to named": {realLaunchEnv},
+ "named to home": {intoHome, realLaunchEnv},
+ } {
+ t.Run(name, func(t *testing.T) {
+ refused := func(t *testing.T, h *finalHarness, p *prodProbe) {
+ t.Helper()
+ h.land(t)
+ if len(p.admissions) != 0 {
+ t.Fatalf("a changed instance HOSTNAME admitted: %+v", p.admissions)
+ }
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted {
+ t.Fatalf("the landing after a changed instance HOSTNAME: state %s reason %q", m.State, m.Reason)
+ }
+ }
+ t.Run("the target's env file adds HOSTNAME", func(t *testing.T) {
+ h := newCloseHarness(t, opts...)
+ p := h.productionProbe(t)
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\nHOSTNAME=b\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ refused(t, h, p)
+ })
+ withHostname := append([]p1Option{sourceEnvFile("APP_MODE=prod\nHOSTNAME=a\n")}, opts...)
+ t.Run("the instance's HOSTNAME is carried and pinned", func(t *testing.T) {
+ h := newCloseHarness(t, withHostname...)
+ src := h.sourceReport(t).Env
+ if len(src.Instance) != 1 || src.Instance[0] != "HOSTNAME" {
+ t.Fatalf("the source's report does not name the instance's HOSTNAME: %v", src.Instance)
+ }
+ p := h.productionProbe(t)
+ h.land(t)
+ if len(p.admissions) != 1 {
+ t.Fatalf("the probe's admissions: %+v (move %+v)", p.admissions, h.read(t, p1Host).Move)
+ }
+ h.committedAndOpen(t)
+ })
+ t.Run("the target's env file changes HOSTNAME", func(t *testing.T) {
+ h := newCloseHarness(t, withHostname...)
+ p := h.productionProbe(t)
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\nHOSTNAME=b\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ refused(t, h, p)
+ })
+ })
+ }
+}
+
+// TestRecoveryEnvCheckIsTheWholeLocalEnv (fix round 2): the recovery's
+// inputs are recorded on the recovering box just before the wake
+// (movesrecoveryprobe.go, recoveryInputs) and checked against that box's
+// launch, so the check keeps the whole composed env (localEnvDigest, every
+// variable but CARLOS_MOVE_PROBE). A box-owned variable that changed
+// between the record and the wake is refused, as is an instance variable.
+func TestRecoveryEnvCheckIsTheWholeLocalEnv(t *testing.T) {
+ recorded := []string{"A=1", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost}
for _, kind := range []string{rcClean, rcDirty, rcRevert} {
t.Run(kind, func(t *testing.T) {
f := newRCFixture(t, kind)
- f.launch.Env = launched
- f.inputs.EnvReport = envDigest(poEnvKey, recorded)
+ f.inputs.EnvReport = localEnvDigest(poEnvKey, recorded)
+ f.launch.Env = append(append([]string(nil), recorded...), "CARLOS_MOVE_PROBE=0")
if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); err != nil {
- t.Fatalf("inputs that differ only in box-owned variables: %v", err)
+ t.Fatalf("the recorded env, launched: %v", err)
}
- f.launch.Env = append([]string{"A=2"}, launched[1:]...)
- if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); !errors.Is(err, hostbox.ErrChanged) || !strings.Contains(err.Error(), "launch environment") {
- t.Fatalf("an instance variable that differs: %v", err)
+ for name, launched := range map[string][]string{
+ "a box-owned variable appears": append(append([]string(nil), recorded...), "CARLOS_PURGE=header"),
+ "a box-owned variable changes": {"A=1", "CARLOS_REGION=uk", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost},
+ "a box variable appears": append(append([]string(nil), recorded...), "HOSTNAME=box"),
+ "an instance variable changes": {"A=2", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost},
+ } {
+ f.launch.Env = launched
+ if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); !errors.Is(err, hostbox.ErrChanged) || !strings.Contains(err.Error(), "launch environment") {
+ t.Fatalf("%s: %v", name, err)
+ }
}
})
}
}
+
+// TestRecoveryAndCutInputsDigestTheirOwnEnv (fix round 2): on one box, over
+// the production composition, the cut's currentInputs pins the
+// move-portable digest the target's probe checks (envDigest with the
+// instance's layer names), and the recovery's recoveryInputs records the
+// whole local digest its own check uses (localEnvDigest). The two differ:
+// the box's own variables are in one and not the other.
+func TestRecoveryAndCutInputsDigestTheirOwnEnv(t *testing.T) {
+ h := newCutHarness(t, realLaunchEnv)
+ rec := h.start(t, "manual")
+ ctx := t.Context()
+ w := h.w()
+ cur, err := w.currentInputs(ctx, h.s, rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rin, err := w.recoveryInputs(ctx, h.s, rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ env, instance, err := h.cfg.LaunchEnv(p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if env[moves.RegionVar] != "ie" || env["CARLOS_HOST"] != p1Host {
+ t.Fatalf("the composition carries no box-owned variables: %v", env[moves.RegionVar])
+ }
+ list := make([]string, 0, len(env))
+ for k, v := range env {
+ list = append(list, k+"="+v)
+ }
+ var names []string
+ for k := range instance {
+ names = append(names, k)
+ }
+ key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cur.EnvReport != envDigest(key, list, names) {
+ t.Fatal("currentInputs is not the probe's move-portable digest")
+ }
+ if rin.EnvReport != localEnvDigest(key, list) {
+ t.Fatal("recoveryInputs is not the recovery check's local digest")
+ }
+ if cur.EnvReport == rin.EnvReport {
+ t.Fatal("the recovery's digest left out the box's own variables")
+ }
+ if rin.Release != cur.Release || rin.OverlayETag != cur.OverlayETag || len(rin.BundleETags) != len(cur.BundleETags) {
+ t.Fatalf("the recovery's other inputs differ: %+v %+v", rin, cur)
+ }
+}
diff --git a/internal/host/movesheldenv.go b/internal/host/movesheldenv.go
index d4bf651e7..bb6d0a181 100644
--- a/internal/host/movesheldenv.go
+++ b/internal/host/movesheldenv.go
@@ -23,41 +23,41 @@ import (
// it) and the carried <host>.env, an ExecBackend over it with this box's
// region and cache, and a synthetic flat route; LaunchEnvironment composes
// what the next spawn would get.
-func composeHeldEnv(priv *ecdh.PrivateKey, region string, cache bool) func(context.Context, release.Store, provision.Record, []byte) (map[string]string, []string, provision.MoveInputs, error) {
- return func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
+func composeHeldEnv(priv *ecdh.PrivateKey, region string, cache bool) func(context.Context, release.Store, provision.Record, []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ return func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
m, v, err := appconfig.LoadMergedHostVersions(ctx, s, rec.Account, rec.App, rec.Environment, rec.Host)
if err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
var rendered []byte
if m != nil {
if rendered, err = appconfig.RenderEnv(m, priv, appconfig.Binding{Account: rec.Account, App: rec.App}); err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
}
tmp, err := os.MkdirTemp("", "carlos-move-t3-")
if err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
defer os.RemoveAll(tmp)
if err := os.WriteFile(filepath.Join(tmp, rec.Host+".config.env"), rendered, 0o600); err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
if carried != nil {
if err := os.WriteFile(filepath.Join(tmp, rec.Host+".env"), carried, 0o600); err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
}
eb := &activator.ExecBackend{DataDir: tmp, Region: region}
if cache {
eb.Cache = heldEnvCache
}
- env, shadowed, err := eb.LaunchEnvironment(registry.Route{Host: rec.Host, Socket: provision.DefaultSocket(rec.Host)})
+ env, instance, shadowed, err := eb.LaunchLayers(registry.Route{Host: rec.Host, Socket: provision.DefaultSocket(rec.Host)})
if err != nil {
- return nil, nil, provision.MoveInputs{}, err
+ return nil, nil, nil, provision.MoveInputs{}, err
}
in := provision.MoveInputs{BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, OverlayETag: v.Overlay}
- return env, shadowed, in, nil
+ return env, instance, shadowed, in, nil
}
}
diff --git a/internal/host/moveshome_fixture_test.go b/internal/host/moveshome_fixture_test.go
index 11affa0aa..0cc30da61 100644
--- a/internal/host/moveshome_fixture_test.go
+++ b/internal/host/moveshome_fixture_test.go
@@ -66,7 +66,7 @@ func TestIntoHomeFixtureIsAHomeTarget(t *testing.T) {
if tc.Region != "" || tc.Box != "home-box" || tc.OwnBucket != "data-home" || tc.OwnBucketRegion != "eu-west-1" || tc.ParkHomeRegion != "ie" {
t.Fatalf("the target's configuration: region %q box %q bucket %q/%q park home %q", tc.Region, tc.Box, tc.OwnBucket, tc.OwnBucketRegion, tc.ParkHomeRegion)
}
- env, _, _, err := tc.ComposeHeldEnv(ctx, h.s, rec, nil)
+ env, _, _, _, err := tc.ComposeHeldEnv(ctx, h.s, rec, nil)
if err != nil {
t.Fatal(err)
}
@@ -74,7 +74,7 @@ func TestIntoHomeFixtureIsAHomeTarget(t *testing.T) {
t.Fatal("the home target's held env sets CARLOS_REGION")
}
ch := newCutHarness(t, intoHome)
- env, _, _, err = ch.target().ComposeHeldEnv(ctx, ch.s, rec, nil)
+ env, _, _, _, err = ch.target().ComposeHeldEnv(ctx, ch.s, rec, nil)
if err != nil {
t.Fatal(err)
}
@@ -83,7 +83,7 @@ func TestIntoHomeFixtureIsAHomeTarget(t *testing.T) {
}
// The default fixture is unchanged: uk sets its own region.
named := newPhase1Harness(t)
- env, _, _, err = named.targetCfg().ComposeHeldEnv(ctx, named.s, rec, nil)
+ env, _, _, _, err = named.targetCfg().ComposeHeldEnv(ctx, named.s, rec, nil)
if err != nil || env["CARLOS_REGION"] != "uk" {
t.Fatalf("the named target's held env: %v %q", err, env["CARLOS_REGION"])
}
diff --git a/internal/host/moveshome_target_test.go b/internal/host/moveshome_target_test.go
index 03019b450..013f9b773 100644
--- a/internal/host/moveshome_target_test.go
+++ b/internal/host/moveshome_target_test.go
@@ -60,7 +60,10 @@ func startIntoHomeT3(t *testing.T, h *phase1Harness) {
func TestHomeTargetT3FromTheRealComposition(t *testing.T) {
h := newPhase1Harness(t)
src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src}
- h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return src.LaunchEnvironment(h.route) }
+ h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) {
+ env, instance, _, err := src.LaunchLayers(h.route)
+ return env, instance, err
+ }
startIntoHomeT3(t, h)
h.pass(t)
if _, ok := h.sourceReport(t).Env.Vars[moves.RegionVar]; !ok {
@@ -89,7 +92,10 @@ func TestHomeTargetT3FromTheRealComposition(t *testing.T) {
func TestHomeTargetT3RefusesABoxThatSetsARegion(t *testing.T) {
h := newPhase1Harness(t)
src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src}
- h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return src.LaunchEnvironment(h.route) }
+ h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) {
+ env, instance, _, err := src.LaunchLayers(h.route)
+ return env, instance, err
+ }
startIntoHomeT3(t, h)
h.pass(t)
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go
index 4e977c650..c5e52bbc9 100644
--- a/internal/host/movesland_test.go
+++ b/internal/host/movesland_test.go
@@ -590,7 +590,7 @@ func (h *landingHarness) installed(t *testing.T) moves.InstallManifest {
if body, err := os.ReadFile(filepath.Join(h.tdir, p1Host, "upload.txt")); err != nil || string(body) != "hello" {
t.Fatalf("the extracted files: %q %v", body, err)
}
- if body, err := os.ReadFile(filepath.Join(h.tdir, p1Host+".env")); err != nil || string(body) != "APP_MODE=prod\n" {
+ if body, err := os.ReadFile(filepath.Join(h.tdir, p1Host+".env")); err != nil || string(body) != h.srcEnvFile {
t.Fatalf("the extracted env: %q %v", body, err)
}
if held := h.leases.holding(); len(held) != 0 {
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go
index 492995249..504a59ed7 100644
--- a/internal/host/movesparks_test.go
+++ b/internal/host/movesparks_test.go
@@ -588,10 +588,10 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
// The held row's launch: what this box would launch, composed by
// the production composer over the target's data directory with
// the target's own region, as claimMoveLaunchSource composes it
- // (ExecBackend.LaunchEnvironment, CARLOS_MOVE_PROBE dropped), and
- // the live release.
+ // (ExecBackend.LaunchLayers, CARLOS_MOVE_PROBE dropped, the names
+ // the instance's files supplied), and the live release.
Launch: func(_ context.Context, rt registry.Route) (ClaimServingLaunch, error) {
- composed, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchEnvironment(rt)
+ composed, supplied, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchLayers(rt)
if err != nil {
return ClaimServingLaunch{}, err
}
@@ -601,7 +601,14 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
env = append(env, k+"="+v)
}
sort.Strings(env)
- return ClaimServingLaunch{Executable: "/opt/carlos/live/" + p1Host, ExecutableSHA256: strings.Repeat("a", 64), Env: env, Version: "v7"}, nil
+ var instance []string
+ for k := range supplied {
+ if _, ok := composed[k]; ok {
+ instance = append(instance, k)
+ }
+ }
+ sort.Strings(instance)
+ return ClaimServingLaunch{Executable: "/opt/carlos/live/" + p1Host, ExecutableSHA256: strings.Repeat("a", 64), Env: env, Instance: instance, Version: "v7"}, nil
},
EnvKey: key,
Admitted: MovesRecordAdmittedAttempts,
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index f7c665614..5ca6fce15 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -578,7 +578,7 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
cfg.StrictNext = eb.NextSpawnStrict
cfg.ComposeHeldEnv = composeHeldEnv(cfg.SecretsPriv, cfg.Region, eb.Cache != nil)
if reg != nil {
- cfg.LaunchEnv = func(host string) (map[string]string, []string, error) {
+ cfg.LaunchEnv = func(host string) (map[string]string, map[string]bool, error) {
rt, err := reg.Get(host)
if err != nil {
return nil, nil, err
@@ -586,7 +586,8 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
if rt == nil {
return nil, nil, fmt.Errorf("%s has no registry row on this box", host)
}
- return eb.LaunchEnvironment(*rt)
+ env, instance, _, err := eb.LaunchLayers(*rt)
+ return env, instance, err
}
}
eb.Strict = w.strictHost
@@ -700,9 +701,10 @@ type moveCfg struct {
SecretsKey string
SecretsPub *ecdh.PublicKey
SecretsPriv *ecdh.PrivateKey
- // LaunchEnv is the source's composed launch environment
- // (ExecBackend.LaunchEnvironment over the host's row).
- LaunchEnv func(host string) (env map[string]string, shadowed []string, err error)
+ // LaunchEnv is the source's composed launch environment and the names
+ // the instance's own layers supplied (ExecBackend.LaunchLayers over the
+ // host's row).
+ LaunchEnv func(host string) (env map[string]string, instance map[string]bool, err error)
// FilesDir is the files directory and <host>.env the archive carries
// (flat: <DataDir>/<host>/ and <DataDir>/<host>.env).
FilesDir func(host string) (dir, envPath string)
@@ -714,7 +716,7 @@ type moveCfg struct {
// The target's hooks: T3's composition of the held row, T4's store
// probe, T6's disk reading, T7's release, T10's lane probe and T11's
// regional S3 and disk measurements.
- ComposeHeldEnv func(ctx context.Context, s release.Store, rec provision.Record, carriedEnv []byte) (env map[string]string, shadowed []string, in provision.MoveInputs, err error)
+ ComposeHeldEnv func(ctx context.Context, s release.Store, rec provision.Record, carriedEnv []byte) (env map[string]string, instance map[string]bool, shadowed []string, in provision.MoveInputs, err error)
ProbeStore func(ctx context.Context, env map[string]string, moveID string) (prefix string, err error)
DiskFree func(dir string) (total, avail uint64, err error)
ReleaseReady func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go
index 36a6b2a2b..76d2de796 100644
--- a/internal/host/movesphase1.go
+++ b/internal/host/movesphase1.go
@@ -869,10 +869,10 @@ func (w *movesWorker) writeSourceReport(ctx context.Context, s release.Store, re
key, err := moves.ReportKeyFromSecrets(cfg.SecretsKey)
if err != nil {
sr.S1 = moves.Check{Reason: s1NoSecretsKey}
- } else if env, _, err := cfg.LaunchEnv(rec.Host); err != nil {
+ } else if env, instance, err := cfg.LaunchEnv(rec.Host); err != nil {
return nil, fmt.Errorf("compose the launch environment: %w", err)
} else {
- sr.Env = moves.ReportEnv(key, env)
+ sr.Env = moves.ReportLaunch(key, env, instance)
}
}
body, err := json.Marshal(sr)
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go
index cf66175fd..6e91d0118 100644
--- a/internal/host/movesphase1_harness_test.go
+++ b/internal/host/movesphase1_harness_test.go
@@ -388,8 +388,14 @@ type phase1Harness struct {
// with the target's own region, each box's agent-owned variables its
// own.
realEnv bool
+ // srcEnvFile is the source's own <host>.env, the instance layer the
+ // archive carries: "APP_MODE=prod\n" by default (sourceEnvFile).
+ srcEnvFile string
}
+// sourceEnvFile sets the source's own <host>.env.
+func sourceEnvFile(body string) p1Option { return func(h *phase1Harness) { h.srcEnvFile = body } }
+
// realLaunchEnv composes both boxes' environments through the production
// composer instead of the h.env fixture (home part 2, Task 2b): the source
// pins what its ExecBackend would launch, and the target composes its own,
@@ -400,17 +406,17 @@ func realLaunchEnv(h *phase1Harness) { h.realEnv = true }
// heldEnv is the target's held environment for rec: the h.env fixture with
// the target's region rule, or under realLaunchEnv the production
// composition over the carried env with the target's region.
-func (h *phase1Harness) heldEnv(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, error) {
+func (h *phase1Harness) heldEnv(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, error) {
if h.realEnv {
- env, _, _, err := composeHeldEnv(h.cfg.SecretsPriv, h.dst, false)(ctx, s, rec, carried)
- return env, err
+ env, instance, _, _, err := composeHeldEnv(h.cfg.SecretsPriv, h.dst, false)(ctx, s, rec, carried)
+ return env, instance, err
}
env := map[string]string{}
for k, v := range h.env {
env[k] = v
}
setHeldRegion(env, h.dst)
- return env, nil
+ return env, nil, nil
}
// p1Roster is the fixture's signed roster: each box's actor, as
@@ -486,7 +492,7 @@ func retainChore(region string) p1Option { return func(h *phase1Harness) { h.ret
func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness {
t.Helper()
h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}, src: "ie",
- srcBox: "ie-box", srcBucket: "data-ie", srcAWS: "eu-west-1", dst: "uk", dstBox: "uk-box", dstBucket: "data-uk", dstAWS: "eu-west-2", dstEngine: "uk"}
+ srcEnvFile: "APP_MODE=prod\n", srcBox: "ie-box", srcBucket: "data-ie", srcAWS: "eu-west-1", dst: "uk", dstBox: "uk-box", dstBucket: "data-uk", dstAWS: "eu-west-2", dstEngine: "uk"}
for _, o := range opts {
o(h)
}
@@ -544,10 +550,13 @@ func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness {
h.cfg.Staging = func(context.Context, string) (lease.StagingWriter, error) { return h.f, nil }
h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil }
h.cfg.SecretsKey, h.cfg.SecretsPriv, h.cfg.SecretsPub = privB64, priv, priv.PublicKey()
- h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return h.env, nil, nil }
+ h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return h.env, nil, nil }
if h.realEnv {
src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src}
- h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return src.LaunchEnvironment(h.route) }
+ h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) {
+ env, instance, _, err := src.LaunchLayers(h.route)
+ return env, instance, err
+ }
}
h.cfg.FilesDir = func(host string) (string, string) {
return filepath.Join(h.cfg.DataDir, host), filepath.Join(h.cfg.DataDir, host+".env")
@@ -575,7 +584,7 @@ func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness {
if err := os.WriteFile(filepath.Join(files, "upload.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host+".env"), []byte("APP_MODE=prod\n"), 0o600); err != nil {
+ if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host+".env"), []byte(h.srcEnvFile), 0o600); err != nil {
t.Fatal(err)
}
pointLiveForTest(t, h.optDir, p1Host, "v7")
@@ -733,9 +742,9 @@ func (h *phase1Harness) targetCfg() moveCfg {
}
c.OwnsRoute = func(string) bool { return false }
c.Route = func(string) (*registry.Route, error) { return nil, nil }
- c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env, err := h.heldEnv(ctx, s, rec, carried)
- return env, nil, provision.MoveInputs{BundleETags: map[string]string{"default": "e1", "environment": ""}}, err
+ c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ env, instance, err := h.heldEnv(ctx, s, rec, carried)
+ return env, instance, nil, provision.MoveInputs{BundleETags: map[string]string{"default": "e1", "environment": ""}}, err
}
c.ProbeStore = func(context.Context, map[string]string, string) (string, error) { return "", nil }
c.DiskFree = func(string) (uint64, uint64, error) { return 100 << 30, 90 << 30, nil }
diff --git a/internal/host/movesphase1_test.go b/internal/host/movesphase1_test.go
index 6a683861c..57f2bc639 100644
--- a/internal/host/movesphase1_test.go
+++ b/internal/host/movesphase1_test.go
@@ -810,7 +810,7 @@ func TestOnlyAPassingRoundRequestsTheCut(t *testing.T) {
archivePlainDigest = func(string, string) (string, int64, int, error) { return "", 0, 0, archive.ErrChanged }
},
"the launch environment cannot be composed": func(t *testing.T, h *phase1Harness) {
- h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return nil, nil, errors.New("no row") }
+ h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return nil, nil, errors.New("no row") }
},
"a staging write fails": func(t *testing.T, h *phase1Harness) {
h.f.putSized("data-ie", "instances/"+p1Host+"/"+h.inc+"/g-cur/0000/0000000000000003-0000000000000003.ltx", "new", 10)
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index 187ce139a..73d970fb8 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -339,7 +339,30 @@ var probeOnlyVars = map[string]bool{"CARLOS_MOVE_PROBE": true}
// composition sets its own box-owned variables (its region, or none on a
// home box; its socket; its purge channel), so a digest over them could
// never match a pin made on another box (home part 2, Task 2b).
-func envDigest(key []byte, env []string) string {
+//
+// instance is the names the instance's own layers supplied
+// (ClaimServingLaunch.Instance): a box variable among them is pinned like an
+// app variable, and one the box supplied is left out (moves.Pinned, fix
+// round 2).
+func envDigest(key []byte, env, instance []string) string {
+ supplied := make(map[string]bool, len(instance))
+ for _, k := range instance {
+ supplied[k] = true
+ }
+ return moves.PortableDigest(key, launchVars(env), supplied)
+}
+
+// localEnvDigest is the digest of the whole launch environment, every
+// variable but the probe-only ones (moves.LocalDigest): the recovery's
+// check against inputs this same box recorded just before the wake (fix
+// round 2, platform-1e's same-box rule).
+func localEnvDigest(key []byte, env []string) string {
+ return moves.LocalDigest(key, launchVars(env))
+}
+
+// launchVars is a launch's K=V list as a map, last value wins, the
+// probe-only variables dropped.
+func launchVars(env []string) map[string]string {
vars := map[string]string{}
for _, kv := range env {
k, v, ok := strings.Cut(kv, "=")
@@ -348,7 +371,7 @@ func envDigest(key []byte, env []string) string {
}
vars[k] = v
}
- return moves.PortableDigest(key, vars)
+ return vars
}
// manifestDigest is install.json's digest: the build writes the manifest
@@ -617,11 +640,11 @@ func (m *movesProbeOwners) Select(ctx context.Context, rt registry.Route, o host
if l.Version != pins.Release {
return ClaimLaunchInputs{}, probeInputsChanged()
}
- if pins.EnvReport == "" || envDigest(m.EnvKey, l.Env) != pins.EnvReport {
+ if pins.EnvReport == "" || envDigest(m.EnvKey, l.Env, l.Instance) != pins.EnvReport {
return ClaimLaunchInputs{}, probeInputsChanged()
}
return ClaimLaunchInputs{Roles: []string{"main"}, Identity: launchIdentity(pins), Baseline: plan, Admission: admission,
- Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: probeEnv(l.Env),
+ Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: probeEnv(l.Env), Instance: append([]string(nil), l.Instance...),
DBPath: rt.DBPath, Version: l.Version, Staged: l.Staged, Limit: l.Limit, Prepared: prepared, ReadyPath: ready}, nil
}
@@ -799,7 +822,7 @@ func (m *movesProbeOwners) Materialize(ctx context.Context, o hostclaim.Observed
if err := m.Release(ctx, rt, pins.Release, heldIdentityWork{Work: released, ID: hostwrite.Identity{Release: pins.Release}}); err != nil {
return err
}
- if envDigest(m.EnvKey, in.Env) != pins.EnvReport {
+ if envDigest(m.EnvKey, in.Env, in.Instance) != pins.EnvReport {
return probeInputsChanged()
}
m.recordFence(o.Host, h.Session, work)
@@ -1426,7 +1449,7 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout
// The committed wake runs the move's pinned inputs (InputPolicy), judged
// on the launch itself: its environment's report and its release.
pins := v.rec.Move.Inputs
- id := committedLaunchIdentity(m.EnvKey, l.Env, l.Version)
+ id := committedLaunchIdentity(m.EnvKey, l.Env, l.Instance, l.Version)
if pins.EnvReport == "" || id != pinnedLaunchIdentity(pins) {
return ClaimLaunchInputs{}, fmt.Errorf("%w: the launch is not the move's pinned inputs", hostbox.ErrChanged)
}
@@ -1435,7 +1458,7 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout
return ClaimLaunchInputs{}, err
}
return ClaimLaunchInputs{Roles: []string{"main"}, Identity: id, Baseline: plan, Admission: admission,
- Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: withoutProbeVars(l.Env),
+ Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: withoutProbeVars(l.Env), Instance: append([]string(nil), l.Instance...),
DBPath: rt.DBPath, Version: l.Version, Staged: l.Staged, Limit: l.Limit}, nil
}
@@ -1464,7 +1487,7 @@ func (m *movesProbeOwners) committedEvidence(ctx context.Context, o hostclaim.Ob
func (m *movesProbeOwners) materializeCommitted(ctx context.Context, o hostclaim.Observed, v probeLanding, in ClaimLaunchInputs, work hostwrite.Work) error {
// The identity is the launch that will run (in.Env, in.Version), and it
// is the pins'.
- if in.Identity != committedLaunchIdentity(m.EnvKey, in.Env, in.Version) || in.Identity != pinnedLaunchIdentity(v.rec.Move.Inputs) {
+ if in.Identity != committedLaunchIdentity(m.EnvKey, in.Env, in.Instance, in.Version) || in.Identity != pinnedLaunchIdentity(v.rec.Move.Inputs) {
return hostbox.ErrChanged
}
if err := writerFence(work, in.Identity)(ctx); err != nil {
@@ -1532,8 +1555,8 @@ const committedEnvPrefix = "launch-env:"
// the launch that will run (fix round 2, item 2): its environment's report
// digest under the probe's normalisation contract (envDigest: CARLOS_MOVE_PROBE
// excluded) and its release. The input policy compares it with the pins.
-func committedLaunchIdentity(key []byte, env []string, version string) hostwrite.Identity {
- return hostwrite.Identity{Config: committedEnvPrefix + envDigest(key, env), Release: version}
+func committedLaunchIdentity(key []byte, env, instance []string, version string) hostwrite.Identity {
+ return hostwrite.Identity{Config: committedEnvPrefix + envDigest(key, env, instance), Release: version}
}
// pinnedLaunchIdentity is the identity a launch matching the move's pins
diff --git a/internal/host/movesprobeowners_committed_test.go b/internal/host/movesprobeowners_committed_test.go
index 819064dfc..99190bab4 100644
--- a/internal/host/movesprobeowners_committed_test.go
+++ b/internal/host/movesprobeowners_committed_test.go
@@ -393,7 +393,7 @@ func TestMovesProbeOwnersCommittedWakeJudgesTheRealLaunch(t *testing.T) {
if _, err := f.owners.Select(ctx, f.route, o, poWake); err == nil {
t.Fatal("a changed launch environment was selected")
}
- changed := committedLaunchIdentity(poEnvKey, f.launch.Env, f.launch.Version)
+ changed := committedLaunchIdentity(poEnvKey, f.launch.Env, f.launch.Instance, f.launch.Version)
if err := f.owners.InputPolicy(ctx, c, sel, changed); err == nil {
t.Fatal("the changed launch's identity was accepted")
}
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go
index e13852d73..4b8473915 100644
--- a/internal/host/movesprobeowners_test.go
+++ b/internal/host/movesprobeowners_test.go
@@ -187,7 +187,7 @@ func newPOFixture(t *testing.T, opts ...poOption) *poFixture {
Move: &provision.MoveState{ID: poOp, Incarnation: inc, From: f.srcRegion, To: f.dstCode, State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline,
Landing: &provision.MoveLandingJournal{Attempts: 1, InstalledDBSHA256: "sha256:" + f.manifest.DBSHA256}}}
f.launch = ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:snapshot", Release: "sha256:bytes"}, Executable: "/opt/carlos/live/" + poHost, ExecutableSHA256: strings.Repeat("b", 64), Args: []string{"--socket", f.route.Socket, "--db", f.route.DBPath}, Env: []string{"A=1", "CARLOS_MOVE_PROBE=0"}, Version: pins.Release}
- pins.EnvReport = envDigest(poEnvKey, f.launch.Env)
+ pins.EnvReport = envDigest(poEnvKey, f.launch.Env, f.launch.Instance)
f.admitted = 3
f.renderETags, f.renderOverlay = pins.BundleETags, pins.OverlayETag
f.owners = &movesProbeOwners{Claims: f.claims, Data: f.bucket, Bucket: "data", Region: f.dstEngine, Box: f.dstBox, DataDir: f.dataDir,
@@ -888,7 +888,7 @@ func TestMovesProbeOwnersThroughTheClaimRuntime(t *testing.T) {
t.Fatal(err)
}
pins := mpPins()
- pins.EnvReport = envDigest(poEnvKey, []string{"A=1"})
+ pins.EnvReport = envDigest(poEnvKey, []string{"A=1"}, nil)
deadline := f.now.Add(time.Hour)
rec := &provision.Record{Host: recoveryHost, Account: "bab", App: "jam", Region: "ie",
Move: &provision.MoveState{ID: poOp, Incarnation: h.Incarnation, From: "uk", To: "ie", State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline, CutAttempts: 3,
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index 0da54738b..9ab731b8a 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -384,7 +384,7 @@ func (r movesRecoveryOwners) Select(ctx context.Context, rt registry.Route, o ho
if l.Version != v.inputs.Release {
return ClaimLaunchInputs{}, recoveryInputsChanged("live release")
}
- if v.inputs.EnvReport == "" || envDigest(m.EnvKey, l.Env) != v.inputs.EnvReport {
+ if v.inputs.EnvReport == "" || localEnvDigest(m.EnvKey, l.Env) != v.inputs.EnvReport {
return ClaimLaunchInputs{}, recoveryInputsChanged("launch environment")
}
return ClaimLaunchInputs{Roles: []string{"main"}, Identity: launchIdentity(v.inputs), Baseline: plan, Admission: admission,
@@ -503,7 +503,7 @@ func (r movesRecoveryOwners) Materialize(ctx context.Context, o hostclaim.Observ
if err := m.Release(ctx, rt, v.inputs.Release, heldIdentityWork{Work: released, ID: hostwrite.Identity{Release: v.inputs.Release}}); err != nil {
return err
}
- if envDigest(m.EnvKey, in.Env) != v.inputs.EnvReport {
+ if localEnvDigest(m.EnvKey, in.Env) != v.inputs.EnvReport {
return recoveryInputsChanged("launch environment")
}
m.recordFence(o.Host, h.Session, work)
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go
index 69d597998..752ba85af 100644
--- a/internal/host/movesrecoveryowners_test.go
+++ b/internal/host/movesrecoveryowners_test.go
@@ -71,7 +71,7 @@ type rcFixture struct {
// rcInputs are the inputs the recovery probe recorded: neither the move's
// pins nor the landing's.
func rcInputs(env []string) *provision.MoveInputs {
- return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-recovered"}, OverlayETag: "e-overlay-2", Release: "v1.3.0", EnvReport: envDigest(poEnvKey, env)}
+ return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-recovered"}, OverlayETag: "e-overlay-2", Release: "v1.3.0", EnvReport: localEnvDigest(poEnvKey, env)}
}
func newRCFixture(t *testing.T, kind string, opts ...poOption) *rcFixture {
diff --git a/internal/host/movesrecoveryprobe.go b/internal/host/movesrecoveryprobe.go
index c2ffbaba3..48c1b4269 100644
--- a/internal/host/movesrecoveryprobe.go
+++ b/internal/host/movesrecoveryprobe.go
@@ -97,7 +97,7 @@ type recoveryProbeAdapter struct {
func armRecoveryProbe(cfg *moveCfg, w *movesWorker, wake ClaimRecoveryProbeWake, ready func() bool) {
cfg.recoveryProbe = func(ctx context.Context, s release.Store, rec provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) {
c := w.cfg
- a := recoveryProbeAdapter{Box: c.Box, Claims: c.claimRecords, Route: c.Route, Current: w.currentInputs, Wake: wake, Ready: ready}
+ a := recoveryProbeAdapter{Box: c.Box, Claims: c.claimRecords, Route: c.Route, Current: w.recoveryInputs, Wake: wake, Ready: ready}
return a.probe(ctx, s, rec, req)
}
}
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index 913ac66e2..31cc0fdf3 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -500,7 +500,7 @@ func (w *movesWorker) checkEnv(ctx context.Context, s release.Store, rec provisi
if cfg.ComposeHeldEnv == nil {
return heldEnv{}, moves.Check{Reason: t3NoCompose}, nil
}
- env, shadowed, in, err := cfg.ComposeHeldEnv(ctx, s, rec, art.env)
+ env, instance, shadowed, in, err := cfg.ComposeHeldEnv(ctx, s, rec, art.env)
if err != nil {
log.Printf("moves: %s: T3: %v", rec.Host, err)
return heldEnv{}, moves.Check{Reason: t3NoCompose}, nil
@@ -514,7 +514,7 @@ func (w *movesWorker) checkEnv(ctx context.Context, s release.Store, rec provisi
// rule follows the record, never this box's region, so a second
// placementless box stays T8's failure and is not T3's pass.
targetHome := rec.Move.To == ""
- for _, d := range moves.CompareEnv(source.Env, moves.ReportEnv(key, env), true, targetHome) {
+ for _, d := range moves.CompareEnv(source.Env, moves.ReportLaunch(key, env, instance), true, targetHome) {
if d.Note() {
notes = append(notes, d.Name+" ("+d.Kind+")")
continue
diff --git a/internal/host/movesreport_test.go b/internal/host/movesreport_test.go
index 5abd3a460..59e74de6b 100644
--- a/internal/host/movesreport_test.go
+++ b/internal/host/movesreport_test.go
@@ -87,16 +87,16 @@ func TestTargetReportPassFlagsEachCheck(t *testing.T) {
want string
}{
"ComposeHeldEnv": {moves.CheckEnv, func(c *moveCfg) {
- c.ComposeHeldEnv = func(context.Context, release.Store, provision.Record, []byte) (map[string]string, []string, provision.MoveInputs, error) {
- return nil, nil, provision.MoveInputs{}, hookErr
+ c.ComposeHeldEnv = func(context.Context, release.Store, provision.Record, []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ return nil, nil, nil, provision.MoveInputs{}, hookErr
}
}, t3NoCompose},
"ComposeHeldEnv differs": {moves.CheckEnv, func(c *moveCfg) {
inner := c.ComposeHeldEnv
- c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env, sh, in, err := inner(ctx, s, rec, carried)
+ c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ env, inst, sh, in, err := inner(ctx, s, rec, carried)
env["APP_MODE"] = "staging"
- return env, sh, in, err
+ return env, inst, sh, in, err
}
}, t3Differs([]string{"APP_MODE (differs)"})},
"ProbeStore": {moves.CheckStorage, func(c *moveCfg) {
@@ -199,7 +199,7 @@ func TestComposeHeldEnvReportsShadowedNames(t *testing.T) {
t.Fatal(err)
}
rec := provision.Record{Host: p1Host, Account: "bab", App: "app"}
- env, shadowed, in, err := composeHeldEnv(priv, "uk", true)(ctx, s, rec, []byte("APP_MODE=carried\nCARRIED=1\n"))
+ env, _, shadowed, in, err := composeHeldEnv(priv, "uk", true)(ctx, s, rec, []byte("APP_MODE=carried\nCARRIED=1\n"))
if err != nil {
t.Fatal(err)
}
@@ -214,7 +214,7 @@ func TestComposeHeldEnvReportsShadowedNames(t *testing.T) {
t.Fatalf("inputs %+v, want the bundle etag %q", in, etag)
}
// No cache on the live box: no CARLOS_PURGE.
- env, _, _, err = composeHeldEnv(priv, "uk", false)(ctx, s, rec, nil)
+ env, _, _, _, err = composeHeldEnv(priv, "uk", false)(ctx, s, rec, nil)
if err != nil {
t.Fatal(err)
}
@@ -228,9 +228,9 @@ func TestComposeHeldEnvReportsShadowedNames(t *testing.T) {
h.pass(t)
tc := h.targetCfg()
inner := tc.ComposeHeldEnv
- tc.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env, _, in, err := inner(ctx, s, rec, carried)
- return env, []string{"APP_MODE"}, in, err
+ tc.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
+ env, inst, _, in, err := inner(ctx, s, rec, carried)
+ return env, inst, []string{"APP_MODE"}, in, err
}
h.targetPass(t, tc)
r := h.targetReport(t)
diff --git a/internal/host/moveswakesource_linux_test.go b/internal/host/moveswakesource_linux_test.go
index 32da58921..d9e9cb2ca 100644
--- a/internal/host/moveswakesource_linux_test.go
+++ b/internal/host/moveswakesource_linux_test.go
@@ -163,13 +163,13 @@ func mwRegister(t *testing.T, f *poFixture, box, region string, files ClaimFiles
}
if mv := f.rec.Move; mv != nil {
if mv.Inputs != nil {
- mv.Inputs.EnvReport = envDigest(key, f.launch.Env)
+ mv.Inputs.EnvReport = envDigest(key, f.launch.Env, f.launch.Instance)
}
if mv.RecoveryInputs != nil {
- mv.RecoveryInputs.EnvReport = envDigest(key, f.launch.Env)
+ mv.RecoveryInputs.EnvReport = localEnvDigest(key, f.launch.Env)
}
if mv.Rollback != nil && mv.Rollback.RecoveryInputs != nil {
- mv.Rollback.RecoveryInputs.EnvReport = envDigest(key, f.launch.Env)
+ mv.Rollback.RecoveryInputs.EnvReport = localEnvDigest(key, f.launch.Env)
}
}
return w
@@ -781,7 +781,7 @@ func TestLostTargetRecoveryRunsAgainstARealDeclaration(t *testing.T) {
t.Fatal(err)
}
inputs := *f.inputs
- inputs.EnvReport = envDigest(key, f.launch.Env)
+ inputs.EnvReport = localEnvDigest(key, f.launch.Env)
*f.inputs = inputs
g := provision.MoveGuard{ID: poOp, Incarnation: f.inc}
for _, step := range [][2]string{{provision.RbSourceWaiting, provision.RbSourceClaimed}, {provision.RbSourceClaimed, provision.RbSourceRestoring}, {provision.RbSourceRestoring, provision.RbSourceVerified}, {provision.RbSourceVerified, provision.RbSourceProbing}} {
diff --git a/internal/host/plan10_j2_adapter_test.go b/internal/host/plan10_j2_adapter_test.go
index c4b87cf39..7469ece09 100644
--- a/internal/host/plan10_j2_adapter_test.go
+++ b/internal/host/plan10_j2_adapter_test.go
@@ -447,7 +447,7 @@ func TestPlan10J2AdapterDrivesTheRecoveryPass(t *testing.T) {
return a(ctx, s, rec, req)
}
h.sw.cfg = c
- h.sw.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return map[string]string{"A": "1"}, nil, nil }
+ h.sw.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return map[string]string{"A": "1"}, nil, nil }
h.sw.cfg.InputVersions = func(context.Context, release.Store, provision.Record) (map[string]string, string, error) {
in := h.renderedInputs()
return in.BundleETags, in.OverlayETag, nil
diff --git a/internal/moves/envreport.go b/internal/moves/envreport.go
index 0605c3c0f..c4eec53e2 100644
--- a/internal/moves/envreport.go
+++ b/internal/moves/envreport.go
@@ -46,16 +46,33 @@ var (
AgentOwnedVars = map[string]bool{RegionVar: true, "CARLOS_CONTROL_SOCKET": true, "CARLOS_PURGE": true, "CARLOS_HOST": true}
)
-// BoxOwned reports a variable whose value is the box's, not the
-// instance's: an agent-owned variable or a box variable of the box's own
-// process. It is the one set every comparison of a move's environment
-// across two boxes leaves out (home part 2, Task 2b): T3 tolerates exactly
-// these (Class is not "equal"), and the env report's digest, which is the
-// move's env pin on the source and what the probe, the committed wake and
-// the recovery check on the target, covers every other variable (the
-// move-portable env).
+// BoxOwned reports a variable whose value can be the box's rather than the
+// instance's: an agent-owned variable, always the box's, or a box variable
+// of the box's own process, the box's unless the instance's own layers
+// supply it (Pinned). It is the one set every comparison of a move's
+// environment across two boxes may leave out (home part 2, Task 2b): T3
+// tolerates these (Class is not "equal") unless an instance supplied
+// them, and the env pin covers every variable Pinned keeps.
func BoxOwned(name string) bool { return AgentOwnedVars[name] || BoxVars[name] }
+// Pinned reports whether a variable is part of the move-portable env, the
+// env the move's pin covers on the source and the target's checks hash
+// (fix round 2): every variable but the agent-owned ones, which each box
+// sets for itself, and the box variables the box supplied itself. A box
+// variable the instance's own layers supply (its rendered configuration or
+// its env file: instanceSupplied) is the instance's, pinned like any app
+// variable, since the agent never overwrites it and the instance's value
+// is what runs.
+func Pinned(name string, instanceSupplied bool) bool {
+ if AgentOwnedVars[name] {
+ return false
+ }
+ if BoxVars[name] {
+ return instanceSupplied
+ }
+ return true
+}
+
// ReportKeyFromSecrets derives the report's HMAC key from
// CARLOS_SECRETS_KEY (HKDF-SHA256), so the report reveals names and
// equality only and only to boxes holding the deployment's key (§7.3).
@@ -80,26 +97,66 @@ type EnvReport struct {
V int `json:"v"`
Vars map[string]string `json:"vars"`
Digest string `json:"digest"`
+ // Instance names the box variables (BoxVars) the instance's own layers
+ // supplied, sorted: the box variables the report pins and T3 compares.
+ // Empty means every box variable in Vars is the box's.
+ Instance []string `json:"instance,omitempty"`
}
-// ReportEnv HMACs every value (bound to its name). Vars carries every
-// variable, for T3's comparison by name; Digest identifies the report's
-// move-portable env, every variable that is not BoxOwned, so the source's
-// pin and the target's composition of the same instance hash the same
+// instanceSupplied reports whether the instance's own layers supplied the
+// box variable name in this report.
+func (r EnvReport) instanceSupplied(name string) bool {
+ for _, n := range r.Instance {
+ if n == name {
+ return true
+ }
+ }
+ return false
+}
+
+// ReportLaunch HMACs every value of a composed launch environment (bound to
+// its name). instance is the names the instance's own layers supplied
+// (activator ExecBackend.LaunchLayers); nil means none of the box
+// variables were. Vars carries every variable, for T3's comparison by
+// name; Instance the box variables the instance supplied; Digest the
+// move-portable env, every variable Pinned keeps, so the source's pin and
+// the target's composition of the same instance hash the same
// (PortableDigest). Raw values never appear in the result.
-func ReportEnv(key []byte, env map[string]string) EnvReport {
+func ReportLaunch(key []byte, env map[string]string, instance map[string]bool) EnvReport {
r := EnvReport{V: 1, Vars: make(map[string]string, len(env))}
for k, v := range env {
r.Vars[k] = macVar(key, k, v)
+ if BoxVars[k] && instance[k] {
+ r.Instance = append(r.Instance, k)
+ }
}
- r.Digest = portableDigest(r.Vars)
+ sort.Strings(r.Instance)
+ r.Digest = r.portableDigest()
return r
}
-// PortableDigest is the digest of env's move-portable part: ReportEnv's
-// Digest, the move's env pin.
-func PortableDigest(key []byte, env map[string]string) string {
- return ReportEnv(key, env).Digest
+// ReportEnv is ReportLaunch for an environment no instance layer supplied
+// a box variable to.
+func ReportEnv(key []byte, env map[string]string) EnvReport {
+ return ReportLaunch(key, env, nil)
+}
+
+// PortableDigest is the digest of a composed launch environment's
+// move-portable part: ReportLaunch's Digest, the move's env pin.
+func PortableDigest(key []byte, env map[string]string, instance map[string]bool) string {
+ return ReportLaunch(key, env, instance).Digest
+}
+
+// LocalDigest is the digest of a whole composed environment, every
+// variable, box-owned or not: the digest of a comparison made on one box
+// against a record that box made (the recovery's inputs), where the box's
+// own variables are part of what runs and must not change either.
+func LocalDigest(key []byte, env map[string]string) string {
+ vars := make(map[string]string, len(env))
+ for k, v := range env {
+ vars[k] = macVar(key, k, v)
+ }
+ return digestOf(vars, func(string) bool { return true })
}
func macVar(key []byte, name, value string) string {
@@ -110,11 +167,15 @@ func macVar(key []byte, name, value string) string {
return hex.EncodeToString(mac.Sum(nil))
}
-// portableDigest hashes the reported variables that are not BoxOwned.
-func portableDigest(vars map[string]string) string {
+// portableDigest hashes the reported variables Pinned keeps.
+func (r EnvReport) portableDigest() string {
+ return digestOf(r.Vars, func(k string) bool { return Pinned(k, r.instanceSupplied(k)) })
+}
+
+func digestOf(vars map[string]string, keep func(string) bool) string {
names := make([]string, 0, len(vars))
for k := range vars {
- if !BoxOwned(k) {
+ if keep(k) {
names = append(names, k)
}
}
@@ -126,10 +187,11 @@ func portableDigest(vars map[string]string) string {
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
-// Class is T3's variable class. Every BoxOwned variable has a class other
-// than "equal", and only those do: T3 tolerates exactly the box-owned set
-// the env pin leaves out. The agent-owned variables without a rule of
-// their own (CARLOS_HOST, the row's name) are box variables.
+// Class is T3's variable class by name. Every BoxOwned variable has a
+// class other than "equal", and only those do. The agent-owned variables
+// without a rule of their own (CARLOS_HOST, the row's name) are box
+// variables. CompareEnv compares a box variable an instance supplied as
+// "equal" (classIn), as the pin covers it.
func Class(name string) string {
switch {
case name == RegionVar:
@@ -149,6 +211,16 @@ type EnvDiff struct {
Name, Kind string
}
+// classIn is Class in a comparison of two reports: a box variable either
+// side's instance supplied is the instance's, compared as "equal", as the
+// pin covers it (Pinned).
+func classIn(name string, source, target EnvReport) string {
+ if c := Class(name); c != "box" || AgentOwnedVars[name] || !(source.instanceSupplied(name) || target.instanceSupplied(name)) {
+ return c
+ }
+ return "equal"
+}
+
// Note reports a diff T3 records without failing.
func (d EnvDiff) Note() bool { return d.Kind == "advisory-differs" }
@@ -171,7 +243,7 @@ func CompareEnv(source, target EnvReport, flatLayout, targetHome bool) []EnvDiff
for name, s := range source.Vars {
seen[name] = true
tv, ok := target.Vars[name]
- switch Class(name) {
+ switch classIn(name, source, target) {
case "region":
if targetHome {
if ok {
@@ -206,7 +278,7 @@ func CompareEnv(source, target EnvReport, flatLayout, targetHome bool) []EnvDiff
if seen[name] {
continue
}
- switch Class(name) {
+ switch classIn(name, source, target) {
case "region":
// a target-only region is a named target's own (a move out of
// home); on a home target it is a box that is not the home box
diff --git a/internal/moves/envreport_test.go b/internal/moves/envreport_test.go
index ccb031379..22e3d005a 100644
--- a/internal/moves/envreport_test.go
+++ b/internal/moves/envreport_test.go
@@ -87,44 +87,113 @@ func TestBoxOwnedIsTheOneSharedSet(t *testing.T) {
}
}
-// TestPortableDigestCoversTheInstanceOnly: the pin ignores every box-owned
-// variable, present, absent or different, and still binds every instance
-// variable: its value, its presence, its name.
+// TestPortableDigestCoversTheInstanceOnly: the pin ignores every
+// agent-owned variable, and every box variable the box supplied, present,
+// absent or different, and still binds every instance variable: its value,
+// its presence, its name. A box variable the instance's own layers
+// supplied is the instance's (fix round 2): its value and its presence are
+// pinned like an app variable's.
func TestPortableDigestCoversTheInstanceOnly(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
base := map[string]string{"APP_MODE": "prod", "DATABASE_URL": "sqlite:a"}
- pin := PortableDigest(key, base)
- if pin != ReportEnv(key, base).Digest {
- t.Fatal("PortableDigest is not ReportEnv's digest")
+ pin := PortableDigest(key, base, nil)
+ if pin != ReportEnv(key, base).Digest || pin != ReportLaunch(key, base, map[string]bool{"APP_MODE": true}).Digest {
+ t.Fatal("PortableDigest is not ReportLaunch's digest")
}
- with := func(edit func(map[string]string)) string {
+ with := func(edit func(map[string]string), instance map[string]bool) string {
env := map[string]string{}
for k, v := range base {
env[k] = v
}
edit(env)
- return PortableDigest(key, env)
+ return PortableDigest(key, env, instance)
}
for name := range AgentOwnedVars {
- if got := with(func(e map[string]string) { e[name] = "box-a" }); got != pin {
+ // agent-owned: never pinned, whoever's files name it
+ if got := with(func(e map[string]string) { e[name] = "box-a" }, map[string]bool{name: true}); got != pin {
t.Errorf("%s present changed the pin", name)
}
}
for name := range BoxVars {
- if got := with(func(e map[string]string) { e[name] = "box-b" }); got != pin {
- t.Errorf("%s present changed the pin", name)
+ if got := with(func(e map[string]string) { e[name] = "box-b" }, nil); got != pin {
+ t.Errorf("%s inherited from the box changed the pin", name)
+ }
+ if got := with(func(e map[string]string) { e[name] = "box-b" }, map[string]bool{name: true}); got == pin {
+ t.Errorf("%s supplied by the instance left the pin unchanged", name)
}
}
+ a := with(func(e map[string]string) { e["HOSTNAME"] = "a" }, map[string]bool{"HOSTNAME": true})
+ if b := with(func(e map[string]string) { e["HOSTNAME"] = "b" }, map[string]bool{"HOSTNAME": true}); a == b {
+ t.Error("an instance HOSTNAME's value is not pinned")
+ }
+ if inherited := with(func(e map[string]string) { e["HOSTNAME"] = "a" }, nil); inherited == a {
+ t.Error("the same HOSTNAME pins the same whether the box or the instance supplied it")
+ }
for name, edit := range map[string]func(map[string]string){
"a value": func(e map[string]string) { e["APP_MODE"] = "staging" },
"an extra": func(e map[string]string) { e["EXTRA"] = "1" },
"a missing": func(e map[string]string) { delete(e, "DATABASE_URL") },
"a rename": func(e map[string]string) { e["APP_MODE2"] = e["APP_MODE"]; delete(e, "APP_MODE") },
} {
- if with(edit) == pin {
+ if with(edit, nil) == pin {
t.Errorf("%s instance variable left the pin unchanged", name)
}
}
+ // The local digest covers everything, box-owned variables included.
+ local := LocalDigest(key, base)
+ if local == LocalDigest(key, map[string]string{"APP_MODE": "prod", "DATABASE_URL": "sqlite:a", RegionVar: "uk"}) ||
+ local == LocalDigest(key, map[string]string{"APP_MODE": "prod", "DATABASE_URL": "sqlite:a", "HOSTNAME": "box"}) {
+ t.Error("the local digest ignored a box-owned variable")
+ }
+}
+
+// TestPinnedRule: agent-owned never, a box variable only when the instance
+// supplied it, anything else always.
+func TestPinnedRule(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ instance bool
+ want bool
+ }{
+ {RegionVar, false, false}, {RegionVar, true, false}, {"CARLOS_HOST", true, false},
+ {"HOSTNAME", false, false}, {"HOSTNAME", true, true}, {"PWD", true, true},
+ {"APP_MODE", false, true}, {"APP_MODE", true, true},
+ } {
+ if got := Pinned(c.name, c.instance); got != c.want {
+ t.Errorf("Pinned(%s, %v) = %v, want %v", c.name, c.instance, got, c.want)
+ }
+ }
+}
+
+// TestCompareEnvComparesAnInstanceBoxVariable (fix round 2): T3 compares a
+// box variable either side's instance supplied as an app variable, and
+// still ignores one both boxes supplied.
+func TestCompareEnvComparesAnInstanceBoxVariable(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ src := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "ie", "HOSTNAME": "a"}, map[string]bool{"A": true, "HOSTNAME": true})
+ if len(src.Instance) != 1 || src.Instance[0] != "HOSTNAME" {
+ t.Fatalf("Instance %v", src.Instance)
+ }
+ same := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "uk", "HOSTNAME": "a"}, map[string]bool{"A": true, "HOSTNAME": true})
+ if diffs := CompareEnv(src, same, false, false); len(diffs) != 0 {
+ t.Fatalf("the same instance HOSTNAME: %v", diffs)
+ }
+ other := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "uk", "HOSTNAME": "b"}, map[string]bool{"A": true, "HOSTNAME": true})
+ if diffs := CompareEnv(src, other, false, false); len(diffs) != 1 || diffs[0].Name != "HOSTNAME" || diffs[0].Kind != "differs" {
+ t.Fatalf("another instance HOSTNAME: %v", diffs)
+ }
+ inherited := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "uk", "HOSTNAME": "box"}, map[string]bool{"A": true})
+ if diffs := CompareEnv(src, inherited, false, false); len(diffs) != 1 || diffs[0].Kind != "differs" {
+ t.Fatalf("the instance's HOSTNAME against the box's: %v", diffs)
+ }
+ boxSrc := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "ie", "HOSTNAME": "x"}, map[string]bool{"A": true})
+ if diffs := CompareEnv(boxSrc, inherited, false, false); len(diffs) != 0 {
+ t.Fatalf("two boxes' own HOSTNAMEs: %v", diffs)
+ }
+ added := ReportLaunch(key, map[string]string{"A": "1", RegionVar: "uk", "HOSTNAME": "b"}, map[string]bool{"A": true, "HOSTNAME": true})
+ if diffs := CompareEnv(ReportLaunch(key, map[string]string{"A": "1", RegionVar: "ie"}, nil), added, false, false); len(diffs) != 1 || diffs[0].Kind != "extra" {
+ t.Fatalf("a target-only instance HOSTNAME: %v", diffs)
+ }
}
func TestEnvReportClassesAndNotes(t *testing.T) {
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 26aac696e..bafb1d2fb 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -741,14 +741,16 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
- // Census overlay home-part2 (amadan #236, moves into home part 2):
- // envelope-only, for T3's home rule (moves.CompareEnv's targetHome)
- // and the move-portable env pin (moves.BoxOwned, PortableDigest).
- // No root-read unit changed or was added, so the legacy seal stands.
- // Ruling owed: the Plan 14 owner has not yet approved it.
+ // Census overlay home-part2 (amadan #236, moves into home part 2): T3's
+ // home rule (moves.CompareEnv's targetHome) and the move-portable env
+ // pin (moves.Pinned, ReportLaunch, LocalDigest; ExecBackend.LaunchLayers).
+ // Eleven changed or new units re-frozen at main's dispositions, nine of
+ // them legacy, add nine frozen declarations. Ruling owed: the Plan 14
+ // owner has not yet approved it.
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-home-part2.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ seal = "b7e249599ad4f2a667887c7ceb82b5452abff8ace919608cd03ece04cd48b628"
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 05b5aba92..e2b6e39b1 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -774,7 +774,10 @@ var plan9B8CensusInventory = []censusEntry{
// build-state.json to the move lane's state/ prefix in the data bucket); no record key.
{"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "9f87605eaeb58cb034c2147a2f54e02b542d1cf8930b6fe3a1b78868cc8e5345"},
{"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"},
- {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"},
+ // Re-reviewed for home part 2, Task 2b fix round 2 (ruling owed): the
+ // report's env is moves.ReportLaunch with the instance's layer names; it
+ // still writes only the source report under the move's report prefix.
+ {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "585c0c5bb9b8c20d5a5d77dc08e49720268951ec30bbc1175db9c83900c817f2"},
{"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"},
{"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "9a761daeb0a9018ad409ed353c6da6f9e523c63d46ab2c248000327d5f7fcd4f"},
{"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"},
diff --git a/internal/provision/testdata/plan14-root-reads-home-part2.tsv b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
index 19381916a..46fb5e4fb 100644
--- a/internal/provision/testdata/plan14-root-reads-home-part2.tsv
+++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
@@ -1,5 +1,27 @@
-# Plan 14 census overlay home-part2 (amadan #236, moves into home part 2, Tasks 1 and 2b; loaded after hostfiles-cap-236). RULING OWED: the Plan 14 owner has not yet approved this overlay.
-# Envelope-only. Task 1: moves.CompareEnv takes targetHome, and T3 (internal/host checkEnv) passes rec.Move.To == "" for it: a home target must set no CARLOS_REGION and fails region-present when it does. Task 2b: internal/moves defines the box-owned set once (AgentOwnedVars, the variables activator agentOwnedVars sets, overwrites or removes, plus BoxVars; BoxOwned). T3's Class tolerates exactly it, ReportEnv's Digest covers every variable outside it (PortableDigest, the move-portable env), and internal/host envDigest uses it, so the source's env pin and the target's checks hash the same portable env. No root-read unit changed or was added (Select, Materialize, the recovery owners and sourceReport read nothing new), every disposition is main's, and the legacy seal stands. Earlier overlays are immutable.
-# Env digests, by where they are compared. Across boxes, now the move-portable env: the source pin sourceReport sr.Env.Digest (host/movesphase1.go:875) copied to the target report's Inputs.EnvReport (host/movesreport.go:276) and checked on the target by the probe owners' Select and Materialize (host/movesprobeowners.go:620 and :802) and by the committed wake (committedLaunchIdentity against pinnedLaunchIdentity, :1430 and :1521); the cut's currentInputs (host/movescut.go:382), compared on the source with that pin (:389). Same box, recorded and checked on the recovering box, portable because it shares currentInputs with the pin: the recovery inputs (host/movesrecoveryprobe.go:139-146, w.currentInputs on that box) checked by the recovery owners (host/movesrecoveryowners.go:387 and :506). Same box, full composed env kept: snapshot.Identity.Config (host/claimmoveaccess.go:191), discarded by movesReadLaunch (host/moveswakesource.go:207) and never read by the moves owners; ClaimLaunchDigest (host/claimoperations.go:70, host/claimowners.go:400), compared only with the attempt's own box, boot and controller (host/movesprobeattempts.go:312 and :353, host/relocationattempts.go:265, host/claimlaunch_authority.go:89-96, host/claimwakestop.go:162, activator/servingretry.go:188 and :222, activator/rolelaunch_linux.go:37 and :135, activator/rolelaunch_root_linux.go:41).
-@coverage internal/host e90500b5f15a4710ab701d0d07afd4a7c5896faba1b81eaa902271125cc11d3b
-@coverage internal/moves 6aa0d97a15bcc9d6c03682b3372bf14c0e3d67acd0854dca40d29b0668ae3cc2
+# Plan 14 census overlay home-part2 (amadan #236, moves into home part 2, Tasks 1 and 2b and 2b fix round 2; loaded after hostfiles-cap-236). RULING OWED: the Plan 14 owner has not yet approved this overlay.
+# Task 1: moves.CompareEnv takes targetHome, and T3 (internal/host checkEnv) passes rec.Move.To == "" for it: a home target must set no CARLOS_REGION and fails region-present when it does. Task 2b and its fix round 2: internal/moves owns the move-portable env rule once (AgentOwnedVars, BoxVars, Pinned: agent-owned variables never pinned, a box variable pinned only when the instance's own layers supplied it; ReportLaunch, PortableDigest), and LocalDigest, the whole composed env for a check made on the box that recorded it. The composer reports the instance's layer names (activator ExecBackend.LaunchLayers, LaunchEnvironment's composition moved into its own unit). The source pin, the cut's currentInputs and the probe owners' checks hash the portable env; the recovery records recoveryInputs and checks localEnvDigest, the whole local env.
+# Env digests, by where they are compared. Across boxes, the move-portable env: the source pin writeSourceReport sr.Env.Digest (host/movesphase1.go:875) copied to the target report's Inputs.EnvReport (host/movesreport.go:276) and checked on the target by the probe owners' Select and Materialize (host/movesprobeowners.go envDigest) and by the committed wake (committedLaunchIdentity against pinnedLaunchIdentity, selectCommitted and InputPolicy); the cut's currentInputs (host/movescut.go), compared on the source with that pin. Same box, the whole local env: the recovery's recoveryInputs (host/movescut.go, recorded through host/movesrecoveryprobe.go:139-146 on the recovering box) checked by the recovery owners' Select and Materialize (host/movesrecoveryowners.go localEnvDigest). Same box, full composed env kept and unchanged: ClaimLaunchDigest (host/claimoperations.go:70, host/claimowners.go:400), compared only with the attempt's own box, boot and controller (host/movesprobeattempts.go:312 and :353, host/relocationattempts.go:265, host/claimlaunch_authority.go:89-96, host/claimwakestop.go:162, activator/servingretry.go:188 and :222, activator/rolelaunch_linux.go:37 and :135, activator/rolelaunch_root_linux.go:41); snapshot.Identity.Config (host/claimmoveaccess.go:191), which the moves owners never read (they set their own identity from the pins). movesReadLaunch (host/moveswakesource.go:207) discards the launch source's EnvReport, not its identity.
+# Re-frozen units, every one at main's disposition (class, task and basis unchanged; kinds and digest refreshed). Authority rows, read set unchanged (only the source offsets moved): host/claimmoveaccess.go claimMoveLaunchSource (authority C,G,J1; it now calls LaunchLayers and returns the instance's layer names) and host/movespass.go armPhase1 (authority B2,C; its LaunchEnv hook now returns the layer names). Legacy rows: activator ExecBackend.LaunchEnvironment (now a wrapper of LaunchLayers) and the new ExecBackend.LaunchLayers (LaunchEnvironment's composition and reads, moved; LaunchEnvironment's disposition); host movesWorker.writeSourceReport (ReportLaunch with the layer names); movesProbeOwners.Select, Materialize, selectCommitted and materializeCommitted (envDigest with the layer names); movesRecoveryOwners.Select and Materialize (localEnvDigest). No new read and no root decision. Nine frozen declarations are added.
+@legacy 849ce111c61c3dcedd80595348d0a26c67fb47462d497fba63d52d2c94e1d5d0 internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
+internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
+@legacy 1c98a7ecd93fe84486af8cbc09bd1cb31915c69c16785c3fd3bcdd6d21eb6849 internal/activator/backend_exec.go ExecBackend.LaunchLayers legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Socket@689,whole:Route@179,whole:Route@685 a82c0f1740d9242da29248be3a009ab34a528df105e3a269b91b49e40c839261
+internal/activator/backend_exec.go ExecBackend.LaunchLayers legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Socket@689,whole:Route@179,whole:Route@685 a82c0f1740d9242da29248be3a009ab34a528df105e3a269b91b49e40c839261
+internal/host/claimmoveaccess.go claimMoveLaunchSource authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only whole:Route@395,whole:Route@718,whole:Route@853 57730ed7d713836f148b5336d3cd2823e2cb6e7baf04f743088e58b079aa021a
+internal/host/movespass.go armPhase1 authority B2,C rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with the S1 probe hook refusing a row whose import gate is set; main's B2,C authority retained registry:ImportGate@8279,whole:Route@8266,whole:Route@8408,whole:Route@8757,whole:Route@8898 b24834b54b93a21e8ba0757fa8f61a9a5eee2ea3c8fa85d634ef4ce4aba1c85d
+@legacy 6080eef9badd1adefd1a5c4e7a6dd3c64999b8cf58eb016adbe4e72ab8efbff2 internal/host/movesphase1.go movesWorker.writeSourceReport legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Stat@753 585c0c5bb9b8c20d5a5d77dc08e49720268951ec30bbc1175db9c83900c817f2
+internal/host/movesphase1.go movesWorker.writeSourceReport legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Stat@753 585c0c5bb9b8c20d5a5d77dc08e49720268951ec30bbc1175db9c83900c817f2
+@legacy 31e120a3a0d3d9bf8367d682d37bf828ff0d38de82120df7bf90ddf65160def0 internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 411a6da5dd4d7da78124b299c081289c97466b4d54e63e32f7ec1635b5651541
+internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 411a6da5dd4d7da78124b299c081289c97466b4d54e63e32f7ec1635b5651541
+@legacy 1efd44545eb606c4c5b1fb04db18062c4aed2d7bc20d0b8b42e9c89cd0b6531c internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3028,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 5a606e96b50c37070313f637d2c4d7f9453dc6f780834e6195bb975099b680fd
+internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3028,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 5a606e96b50c37070313f637d2c4d7f9453dc6f780834e6195bb975099b680fd
+@legacy 45cda5b26dc120bbd9f580493ce79219ce21501f3de390c29a859dad6801c5aa internal/host/movesprobeowners.go movesProbeOwners.materializeCommitted legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Committed landing materialization establishes C8 files before start check; payload delivery remains legacy. registry:DBPath@629,whole:Route@616,whole:Route@712 f0a7780b71f42a05595eec24ddb3654d621205a403b6d18c0cba4391209b9554
+internal/host/movesprobeowners.go movesProbeOwners.materializeCommitted legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Committed landing materialization establishes C8 files before start check; payload delivery remains legacy. registry:DBPath@629,whole:Route@616,whole:Route@712 f0a7780b71f42a05595eec24ddb3654d621205a403b6d18c0cba4391209b9554
+@legacy e6ac718f1c049c2b9e937669afb730f4152ab18cd25b571967a0b78982e5749f internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1647,whole:Route@176,whole:Route@611 af0e6f216a63265245cc5cb1db23aedf9dd8d6731383c90de4d0e8b2b53fc401
+internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1647,whole:Route@176,whole:Route@611 af0e6f216a63265245cc5cb1db23aedf9dd8d6731383c90de4d0e8b2b53fc401
+@legacy 7ba24c0aa59e9f30cdf08fdd9e7acb056ab11672ae774772df2c7bb3bbb15ac3 internal/host/movesrecoveryowners.go movesRecoveryOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@577,whole:Route@1566,whole:Route@750 a87029143a13dc05d5b873b6b4f099515be737f9e0188586fac5fe91719ccd81
+internal/host/movesrecoveryowners.go movesRecoveryOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@577,whole:Route@1566,whole:Route@750 a87029143a13dc05d5b873b6b4f099515be737f9e0188586fac5fe91719ccd81
+@legacy 98403405dd4f1aa000814d268c71477fd7b3f54fe1921270f8ad3ffc41ec7c54 internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
+internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
+@coverage internal/activator 4b4b243c37f98df546ac893baaf98a80ae805705492dcd0792f532d41976766c
+@coverage internal/host 572809ffe0af5a194c7816de18098eba21a991154a7c8af79a6ad4621ad7fbdb
+@coverage internal/moves 14b012bafa4965182c173593563004a1d872436fdfcf03d55bd0b49a0bde85d2