Merge branch 'fix-system-apply-dirs' into main
4 files changed,
+277
−14
internal/host/bootstrap.go+55 −8internal/host/host_test.go+81 −0internal/host/systemapply.go+41 −6internal/host/systemapply_test.go+100 −0
CI passed — run details
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 39aead0ff..89d232ffc 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -74,6 +74,15 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| // line just set. Both are 0700 so re-runs converge instead of racing. |
| "d /data/instances 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| + // The relay's one writable state dir (carlos-relay.service's |
| + // ReadWritePaths). It is in bootstrap's dirs list too, but that list |
| + // only ever runs against a FRESH box or a staging root — a self-update |
| + // apply creates no directories, it re-runs tmpfiles. Without this line |
| + // a box bootstrapped before the relay existed gets the unit, the |
| + // binary and the master file and still fails to start with "Failed to |
| + // set up mount namespacing: /data/relay: No such file or directory" |
| + // (discussion #118, hit on the Ireland box 2026-09-07). |
| + "d /data/relay 0750 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| // The readiness probe's own instance directory (design §8.5): a name |
| @@ -161,13 +170,50 @@ var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verif |
| // bootstrapPlan is what `bootstrap -root <dir> -plan-json` prints: the |
| // arming list of THIS binary, so a self-update apply arms what the new |
| // binary's bootstrap would arm and never a list an older binary remembers. |
| +// |
| +// Armed is the other half of that promise, and it was missing until |
| +// discussion #118. Enabled alone says which units to `enable`; WHICH of them |
| +// also get a `start` was decided by the applier's own systemctlPlan — code |
| +// from the OLD binary, which cannot know about a unit the new build just |
| +// introduced. That is how the relay landed on the Ireland box enabled but |
| +// never started on 2026-09-07: the applying build predated |
| +// carlos-relay.service, so its systemctlPlan had no reason to start it. |
| +// Carrying the list the TARGET build would start closes that gap for every |
| +// future unit, not just the relay. |
| +// |
| +// Additive on purpose: a plan.json written by a build older than this field |
| +// (a rollback stages carlos.prev's plan) carries no "armed" key, and arm() |
| +// then decides locally exactly as it always did. The key is NOT omitempty: |
| +// nil ("this build is too old to say") and empty ("start nothing") are |
| +// different answers, and omitempty would collapse them into the same absent |
| +// key, making "start nothing" impossible for a build to express. |
| type bootstrapPlan struct { |
| Enabled []string `json:"enabled"` |
| Inert []string `json:"inert"` |
| + Armed []string `json:"armed"` |
| } |
| func currentBootstrapPlan() bootstrapPlan { |
| - return bootstrapPlan{Enabled: append([]string(nil), enabledUnits...), Inert: append([]string(nil), inertUnits...)} |
| + return bootstrapPlan{ |
| + Enabled: append([]string(nil), enabledUnits...), |
| + Inert: append([]string(nil), inertUnits...), |
| + Armed: armedUnits(enabledUnits), |
| + } |
| +} |
| + |
| +// armedUnits is the "and start these too" rule in one place: the unit types |
| +// whose enable-without-start is a silent no-op until the next boot, plus |
| +// carlos-relay.service, whose ConditionPathExists makes starting it safe on a |
| +// box with no region master. See systemctlPlan for the long version. |
| +func armedUnits(units []string) []string { |
| + var armed []string |
| + for _, u := range units { |
| + if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || |
| + strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName { |
| + armed = append(armed, u) |
| + } |
| + } |
| + return armed |
| } |
| // systemctlPlan is the exact systemctl sequence a live bootstrap runs — and |
| @@ -206,17 +252,18 @@ func currentBootstrapPlan() bootstrapPlan { |
| // tree with no protection, until the next reboot. Starting either is |
| // idempotent. |
| func systemctlPlan(units []string) [][]string { |
| + return systemctlPlanArming(units, armedUnits(units)) |
| +} |
| + |
| +// systemctlPlanArming is systemctlPlan with the start list supplied rather |
| +// than derived — the shape a self-update apply needs, where the units come |
| +// from the target build's plan.json and so does the decision about which of |
| +// them to start (discussion #118). |
| +func systemctlPlanArming(units, armed []string) [][]string { |
| plan := [][]string{ |
| {"daemon-reload"}, |
| append([]string{"enable"}, units...), |
| } |
| - var armed []string |
| - for _, u := range units { |
| - if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || |
| - strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName { |
| - armed = append(armed, u) |
| - } |
| - } |
| if len(armed) > 0 { |
| plan = append(plan, append([]string{"start"}, armed...)) |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 38b6ebd3e..77d9f305d 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/ed25519" |
| "encoding/base64" |
| "encoding/json" |
| + "fmt" |
| "log" |
| "net" |
| "os" |
| @@ -1675,3 +1676,83 @@ func TestBootstrapWritesJournaldCap(t *testing.T) { |
| t.Errorf("drop-in gone after a re-run: %v", err) |
| } |
| } |
| + |
| +// TestTmpfilesCarriesRelayStateDir is discussion #118 as a test. The relay's |
| +// unit declares ReadWritePaths=/data/relay, and systemd refuses to start a |
| +// unit whose ReadWritePaths names a directory that is not there — "Failed to |
| +// set up mount namespacing: /data/relay: No such file or directory", which is |
| +// what the Ireland box answered on 2026-09-07. |
| +// |
| +// bootstrap's own dirs list creates it, and that is not enough: the dirs list |
| +// runs on a FRESH box or against a staging root, never against the live root |
| +// of a self-update apply. What an apply re-runs is systemd-tmpfiles. So the |
| +// directory has to be in tmpfilesConf, and this test is what keeps a future |
| +// unit's writable state dir from being added to one list and not the other. |
| +func TestTmpfilesCarriesRelayStateDir(t *testing.T) { |
| + b, err := unitsFS.ReadFile("units/" + relayUnitName) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rw string |
| + for _, line := range strings.Split(string(b), "\n") { |
| + if v, ok := strings.CutPrefix(line, "ReadWritePaths="); ok { |
| + rw = v |
| + break |
| + } |
| + } |
| + if rw == "" { |
| + t.Fatal("carlos-relay.service declares no ReadWritePaths") |
| + } |
| + for _, p := range strings.Fields(rw) { |
| + if !strings.HasPrefix(p, "/data/") { |
| + continue // /run/carlos is tmpfs and already has its own line |
| + } |
| + want := "d " + p + " " |
| + if !strings.Contains(tmpfilesConf, want) { |
| + t.Errorf("tmpfilesConf has no %q line, so an apply onto a box that predates the unit leaves it unable to start:\n%s", want, tmpfilesConf) |
| + } |
| + } |
| + // The mode must match bootstrap's dirs list, or a re-run of one flips |
| + // what the other just set (the /data/instances lesson, one line up). |
| + // Read the mode bootstrap actually uses rather than restating it: a |
| + // hardcoded "0750" here passes happily while the two lists disagree. |
| + root := t.TempDir() |
| + if err := bootstrapWith(root, nil, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(filepath.Join(root, "data", "relay")) |
| + if err != nil { |
| + t.Fatalf("bootstrap did not create /data/relay: %v", err) |
| + } |
| + want := fmt.Sprintf("d /data/relay %04o ", fi.Mode().Perm()) |
| + if !strings.Contains(tmpfilesConf, want) { |
| + t.Errorf("tmpfilesConf must carry %q to match the mode bootstrap's dirs list creates:\n%s", want, tmpfilesConf) |
| + } |
| +} |
| + |
| +// TestPlanCarriesArmedList pins the other half of discussion #118: the plan a |
| +// build prints says which units to START, not just which to enable, so an |
| +// applier running an OLDER binary arms what the new build wants rather than |
| +// what its own systemctlPlan happens to know about. |
| +func TestPlanCarriesArmedList(t *testing.T) { |
| + plan := currentBootstrapPlan() |
| + if len(plan.Armed) == 0 { |
| + t.Fatal("plan carries no armed list") |
| + } |
| + if !slices.Contains(plan.Armed, relayUnitName) { |
| + t.Errorf("armed list omits the relay: %v", plan.Armed) |
| + } |
| + if slices.Contains(plan.Armed, "carlos-edge.service") { |
| + t.Errorf("edge must stay start-by-hand: %v", plan.Armed) |
| + } |
| + for _, u := range plan.Armed { |
| + if !slices.Contains(plan.Enabled, u) { |
| + t.Errorf("armed unit %s is not in the enabled list", u) |
| + } |
| + } |
| + // The armed list and the local rule must agree for THIS build; they |
| + // diverge only across builds, which is the point of carrying it. |
| + if got, want := plan.Armed, armedUnits(enabledUnits); !slices.Equal(got, want) { |
| + t.Errorf("plan.Armed = %v, systemctlPlan would start %v", got, want) |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 5ad62425e..198a3a074 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -710,13 +710,48 @@ func (a *systemApplier) gateOnce(ctx context.Context, target string) error { |
| return nil |
| } |
| -// arm is systemctlPlan verbatim: enable all, start timers and paths, and of |
| -// the services only carlos-relay, whose ConditionPathExists makes the start |
| -// a no-op on a box with no region master (relay spec §3). It starts nothing |
| -// else — in particular not the edge, which is running this very code — which |
| -// is why apply can call it from inside itself. |
| +// arm enables every unit in the plan and starts the ones the plan says to |
| +// start: the unit types whose enable-without-start is a silent no-op until |
| +// the next boot, and of the services only carlos-relay, whose |
| +// ConditionPathExists makes the start a no-op on a box with no region master |
| +// (relay spec §3). |
| +// |
| +// It never starts the edge, which is running this very code — that is why |
| +// apply can call it from inside itself. Before #118 that was structural: the |
| +// applier derived the start list itself and could not name a service other |
| +// than the relay. Now the list comes from the target build, so the guarantee |
| +// is enforced here instead, explicitly. |
| func (a *systemApplier) arm(plan bootstrapPlan) error { |
| - for _, args := range systemctlPlan(plan.Enabled) { |
| + // plan.Armed is the TARGET build's answer to "and which of these get a |
| + // start" (discussion #118). Preferring it is what lets a build introduce |
| + // a unit that must be started at apply time rather than at the next |
| + // boot; falling back to the local rule keeps a plan.json from an older |
| + // build — which is what a rollback stages — working unchanged. |
| + armed := plan.Armed |
| + if armed == nil { |
| + armed = armedUnits(plan.Enabled) |
| + } |
| + // Two guards on a list that now comes from another build. The edge is |
| + // never started here, whatever the plan says. And a unit that is not in |
| + // Enabled is dropped rather than passed to systemctl: `start` is ONE |
| + // batched call, so a single name this box has no unit file for would |
| + // fail the whole call and leave nothing armed at all. |
| + enabled := make(map[string]bool, len(plan.Enabled)) |
| + for _, u := range plan.Enabled { |
| + enabled[u] = true |
| + } |
| + kept := armed[:0:0] |
| + for _, u := range armed { |
| + switch { |
| + case u == systemEdgeUnit || u == systemEdgeUnit+".service": |
| + a.ops.logf("system apply: arm: refusing to start %s, which is running this code", u) |
| + case !enabled[u]: |
| + a.ops.logf("system apply: arm: %s is armed by the plan but not enabled by it; skipping", u) |
| + default: |
| + kept = append(kept, u) |
| + } |
| + } |
| + for _, args := range systemctlPlanArming(plan.Enabled, kept) { |
| if _, err := a.ops.systemctl(args...); err != nil { |
| return fmt.Errorf("systemctl %s: %w", strings.Join(args, " "), err) |
| } |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index bf642a8a0..baf7b426a 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -5,9 +5,11 @@ import ( |
| "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -1253,3 +1255,101 @@ func TestRecoveryStillHandlesALegacyBackingUpJournal(t *testing.T) { |
| t.Fatalf("result = %+v, want the run to carry on to its request", r) |
| } |
| } |
| + |
| +// TestApplyArmsTheTargetBuildsList is the forward-compatibility half of |
| +// discussion #118. The applier is the OLD binary: when the target build |
| +// introduces a unit that must be started at apply time rather than at the |
| +// next boot, only the target build's own plan knows that. So arm() starts |
| +// what plan.Armed names, even a unit this binary's own rule would skip. |
| +// |
| +// Without this, a unit new to the box is enabled and left inactive — which |
| +// is exactly how carlos-relay.service reached the Ireland box on 2026-09-07 |
| +// enabled, gated, armed with a masters file, and not running. |
| +func TestApplyArmsTheTargetBuildsList(t *testing.T) { |
| + _, started := armCalls(t, bootstrapPlan{ |
| + Enabled: []string{"carlos-edge.service", "carlos-future.service"}, |
| + // A service this binary's armedUnits would never pick. |
| + Armed: []string{"carlos-future.service"}, |
| + }) |
| + if !slices.Contains(started, "carlos-future.service") { |
| + t.Errorf("apply did not start the unit the target build asked for: started %v", started) |
| + } |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("apply started the edge, which runs this very code: started %v", started) |
| + } |
| +} |
| + |
| +// The plan's armed list comes from another build, so arm() does not take it |
| +// on trust: the edge is never started whatever the plan says, and a unit the |
| +// plan arms but does not enable is dropped rather than passed to systemctl, |
| +// because one bad name in the single batched `start` would lose every unit |
| +// in it. |
| +func TestApplyRefusesToArmTheEdgeOrAnUnenabledUnit(t *testing.T) { |
| + _, started := armCalls(t, bootstrapPlan{ |
| + Enabled: []string{"carlos-adopt.timer", "carlos-edge.service"}, |
| + Armed: []string{"carlos-adopt.timer", "carlos-edge.service", "carlos-never-installed.service"}, |
| + }) |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("the edge was started from the plan's armed list: started %v", started) |
| + } |
| + if slices.Contains(started, "carlos-never-installed.service") { |
| + t.Errorf("a unit the plan never enabled was handed to systemctl: started %v", started) |
| + } |
| + if !slices.Contains(started, "carlos-adopt.timer") { |
| + t.Errorf("the good unit was lost along with the bad ones: started %v", started) |
| + } |
| +} |
| + |
| +// A plan.json from a build older than the Armed field has no armed key. That |
| +// is what a rollback stages (carlos.prev's plan), so the fallback is not a |
| +// theoretical path — arm() must decide locally exactly as it did before. |
| +func TestApplyArmsLocallyWhenPlanPredatesArmed(t *testing.T) { |
| + var plan bootstrapPlan |
| + if err := json.Unmarshal([]byte(`{"enabled":["carlos-adopt.timer","carlos-edge.service","carlos-relay.service"]}`), &plan); err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan.Armed != nil { |
| + t.Fatalf("armed should be absent, got %v", plan.Armed) |
| + } |
| + _, started := armCalls(t, plan) |
| + // Membership, not substring: the relay is batched onto the same start |
| + // line as the timer, and its bare name also appears on the enable line. |
| + // A substring check is how the first version of this test passed |
| + // against a fallback changed to drop the relay — the exact #118 failure |
| + // mode it exists to catch. |
| + for _, want := range []string{"carlos-adopt.timer", "carlos-relay.service"} { |
| + if !slices.Contains(started, want) { |
| + t.Errorf("fallback did not arm %s: started %v", want, started) |
| + } |
| + } |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("fallback started the edge: started %v", started) |
| + } |
| +} |
| + |
| +// armCalls runs arm(plan) against a recording systemd and returns the units |
| +// it enabled and the units it started. Both are read as SETS out of the two |
| +// batched systemctl lines: arm puts every unit on one `enable` and every |
| +// started unit on one `start`, so a substring check over the joined log |
| +// cannot tell "start carlos-relay.service" from the same name appearing in |
| +// the enable line, and cannot see a name that is merely batched beside |
| +// another. The first version of these tests got both wrong. |
| +func armCalls(t *testing.T, plan bootstrapPlan) (enabled, started []string) { |
| + t.Helper() |
| + a := &systemApplier{ops: sysOps{ |
| + systemctl: func(args ...string) (string, error) { |
| + switch args[0] { |
| + case "enable": |
| + enabled = append(enabled, args[1:]...) |
| + case "start": |
| + started = append(started, args[1:]...) |
| + } |
| + return "", nil |
| + }, |
| + logf: func(string, ...any) {}, |
| + }} |
| + if err := a.arm(plan); err != nil { |
| + t.Fatal(err) |
| + } |
| + return enabled, started |
| +} |