host: system apply refuses a pre-layout release while a row is nested, fenced, in conflict or mid-move (plan 4, task 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+269
−1
internal/host/systemapply.go+99 −1internal/host/systemapply_test.go+170 −0
Intent
Attributed to this commit: 24.4 g CO₂e (2976018 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on isolated-exec-layout, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 198a3a074..9999634c0 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -16,10 +16,15 @@ import ( |
| "os/exec" |
| "path/filepath" |
| "runtime" |
| + "slices" |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/layout" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/version" |
| ) |
| @@ -125,6 +130,19 @@ func openSystemStore(ctx context.Context) (release.Store, ed25519.PublicKey, err |
| return store, pub, nil |
| } |
| +// openSystemRegistry is the production openRegistry: the box's registry, |
| +// opened as the root passes open it so any WAL files the open creates end |
| +// up owned by the service user, and wrapped with its path so a refusal |
| +// that names an open error says which file. |
| +func openSystemRegistry(dataDir string) (*registry.Registry, error) { |
| + path := filepath.Join(dataDir, "registry.db") |
| + reg, err := openServiceRegistry(path) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", path, err) |
| + } |
| + return reg, nil |
| +} |
| + |
| // productionSysOps is the real box behind the seam. |
| func productionSysOps() sysOps { |
| return sysOps{ |
| @@ -179,7 +197,11 @@ type systemApplier struct { |
| // openStore is called once, by verifyPointer, and only on a run that |
| // has a request to answer. --recover never calls it: see |
| // openSystemStore for why that matters at boot. |
| - openStore func(ctx context.Context) (release.Store, ed25519.PublicKey, error) |
| + openStore func(ctx context.Context) (release.Store, ed25519.PublicKey, error) |
| + // openRegistry is called only when the candidate predates the instance |
| + // layout, to see whether anything on the box is still nested. Nil means |
| + // openSystemRegistry over dataDir. |
| + openRegistry func() (*registry.Registry, error) |
| channel string |
| optDir string |
| dataDir string |
| @@ -340,6 +362,17 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| if err != nil { |
| return a.record(req, fmt.Errorf("%w: candidate bootstrap: %v", errSystemRefused, err)) |
| } |
| + // A release before the instance layout ships no data-instances.mount, |
| + // so installing it disables the mount units and then serves every |
| + // nested row from an empty mountpoint directory (design §8.2, "That |
| + // order is enforced"). The candidate's own staged unit set is the |
| + // evidence of which kind of release it is. Refused here, before the |
| + // journal, so a refusal leaves the box exactly as it was. |
| + if !slices.Contains(newUnits, layoutMountUnit) { |
| + if reasons := a.layoutReversalReasons(); len(reasons) > 0 { |
| + return a.record(req, fmt.Errorf("%w: "+systemApplyLayoutRefusal, errSystemRefused, target, strings.Join(reasons, ", "))) |
| + } |
| + } |
| // Only the unit set is taken from the return: a rollback picks this |
| // staging up as it stands, plan.json and all, rather than exec'ing the |
| // previous binary again on a box that is already unhealthy. |
| @@ -505,6 +538,71 @@ func (a *systemApplier) stagePlan(ctx context.Context, binary, dir string) (boot |
| return plan, units, nil |
| } |
| +// layoutMountUnit is the unit whose presence in a candidate's staged set |
| +// marks a release that understands the nested layout. |
| +const layoutMountUnit = "data-instances.mount" |
| + |
| +// systemApplyLayoutRefusal takes the release and the rows in its way. |
| +const systemApplyLayoutRefusal = "%s predates the instance layout, and this box still has: %s. revert the layout and wait for every row to report flat before installing it" |
| + |
| +// layoutReversalReasons opens the registry and lists what stands in the |
| +// way of a release before the layout. A registry that will not open cannot |
| +// vouch that nothing is nested, so its error is itself a reason. |
| +func (a *systemApplier) layoutReversalReasons() []string { |
| + open := a.openRegistry |
| + if open == nil { |
| + open = func() (*registry.Registry, error) { return openSystemRegistry(a.dataDir) } |
| + } |
| + reg, err := open() |
| + if err != nil { |
| + return []string{err.Error()} |
| + } |
| + defer reg.Close() |
| + return layoutReversalIncomplete(reg, a.dataDir) |
| +} |
| + |
| +// layoutReversalIncomplete names, once each and sorted, every host a |
| +// release before the layout would mishandle: a row whose paths are nested |
| +// by shape (provision.NestedPaths, whatever its flags, so a row that left |
| +// management is still seen), a row the layout pass's own classification |
| +// calls fenced or in conflict, and a host with a migration journal whether |
| +// or not its row still exists. A registry that cannot be listed, or a |
| +// journal directory that cannot be read, is named as such rather than |
| +// taken for an empty box. |
| +func layoutReversalIncomplete(reg *registry.Registry, dataDir string) []string { |
| + named := map[string]bool{} |
| + var extra []string |
| + routes, err := reg.List() |
| + if err != nil { |
| + extra = append(extra, err.Error()) |
| + } |
| + pass := layout.Pass{Registry: reg, DataDir: dataDir} |
| + for _, rt := range routes { |
| + if provision.NestedPaths(dataDir, rt) { |
| + named[rt.Host] = true |
| + continue |
| + } |
| + if l, _ := pass.Classify(rt, routes); l == activator.LayoutFenced || l == activator.LayoutConflict { |
| + named[rt.Host] = true |
| + } |
| + } |
| + entries, err := os.ReadDir(filepath.Join(dataDir, "layout")) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + extra = append(extra, err.Error()) |
| + } |
| + for _, e := range entries { |
| + if host, ok := strings.CutSuffix(e.Name(), ".json"); ok && !strings.HasPrefix(host, ".") && layout.JournalPresent(dataDir, host) { |
| + named[host] = true |
| + } |
| + } |
| + reasons := make([]string, 0, len(named)+len(extra)) |
| + for h := range named { |
| + reasons = append(reasons, h) |
| + } |
| + slices.Sort(reasons) |
| + return append(reasons, extra...) |
| +} |
| + |
| func (a *systemApplier) journal(j systemJournal) error { |
| return writeJSONDurable(a.file("journal.json"), j, 0o600) |
| } |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index baf7b426a..4327daa2d 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -14,6 +14,8 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -114,6 +116,12 @@ func newApplyFixture(t *testing.T, channel, build string) *applyFixture { |
| edgeStartedThisBoot: func() bool { return f.edgeStartedThisBoot }, |
| logf: t.Logf, |
| } |
| + // The layout guard opens the registry only for a candidate that |
| + // predates the layout, and closes it; an empty one in the fake data dir |
| + // is a flat box. |
| + f.a.openRegistry = func() (*registry.Registry, error) { |
| + return registry.Open(filepath.Join(f.a.dataDir, "registry.db")) |
| + } |
| os.MkdirAll(systemRootDir(f.a.optDir), 0o755) |
| os.MkdirAll(filepath.Join(f.a.etcDir, "systemd", "system"), 0o755) |
| // The on-disk binary answers `version` with build. |
| @@ -1353,3 +1361,165 @@ func armCalls(t *testing.T, plan bootstrapPlan) (enabled, started []string) { |
| } |
| return enabled, started |
| } |
| + |
| +// seedRoutes writes rows into the fixture's registry and closes it, so the |
| +// guard's own open is the only one held when Run reads it. |
| +func (f *applyFixture) seedRoutes(routes ...registry.Route) { |
| + f.t.Helper() |
| + reg, err := registry.Open(filepath.Join(f.a.dataDir, "registry.db")) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + f.t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func flatRow(dataDir, host string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "tito", Channel: "stable", Hibernate: true, Status: "ready", |
| + DBPath: provision.DefaultDBPath(dataDir, host), Socket: provision.DefaultSocket(host)} |
| +} |
| + |
| +func nestedRow(dataDir, host string) registry.Route { |
| + rt := flatRow(dataDir, host) |
| + rt.DBPath, rt.Socket = provision.NestedDBPath(dataDir, host), provision.NestedSocket(host) |
| + return rt |
| +} |
| + |
| +// postLayout gives the fixture's candidate the mount unit the layout ships |
| +// with, which is what makes it a release that understands nested rows. |
| +func (f *applyFixture) postLayout() { |
| + staged := filepath.Join(f.a.stagingDir(), "carlos") |
| + f.answer["units:"+staged] += " data-instances.mount" |
| +} |
| + |
| +// A pre-layout candidate would remove the mount units and then serve a |
| +// nested row from an empty mountpoint directory, so the apply refuses it |
| +// before anything on the box changes, and names the row in the way. |
| +func TestApplyRefusesAPreLayoutCandidateOverANestedRow(t *testing.T) { |
| + f, req := applyHappy(t) |
| + f.seedRoutes(flatRow(f.a.dataDir, "flat.example.com"), nestedRow(f.a.dataDir, "acme.example.com")) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.result() |
| + if r == nil || r.Outcome != systemOutcomeRefused || !r.matches("stable", "v2", req.PromotedAt) { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + want := "refused: v2 predates the instance layout, and this box still has: acme.example.com. " + |
| + "revert the layout and wait for every row to report flat before installing it" |
| + if r.Reason != want { |
| + t.Fatalf("reason = %q\nwant %q", r.Reason, want) |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j != nil { |
| + t.Fatalf("a refused apply wrote a journal: %+v", j) |
| + } |
| + if bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(bin), "carlos v1") { |
| + t.Fatal("a refused apply swapped the binary") |
| + } |
| + if f.called("systemctl stop") || f.called("systemctl restart") { |
| + t.Fatalf("a refused apply touched systemd: %v", f.calls) |
| + } |
| +} |
| + |
| +func TestApplyInstallsAPreLayoutCandidateOverFlatRows(t *testing.T) { |
| + f, req := applyHappy(t) |
| + f.seedRoutes(flatRow(f.a.dataDir, "flat.example.com"), flatRow(f.a.dataDir, "acme.example.com")) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) { |
| + t.Fatalf("result = %+v", r) |
| + } |
| +} |
| + |
| +// A candidate that ships the mount units understands nested rows, so the |
| +// guard never even opens the registry for it. |
| +func TestApplyInstallsAPostLayoutCandidateOverNestedRows(t *testing.T) { |
| + f, req := applyHappy(t) |
| + f.postLayout() |
| + f.seedRoutes(nestedRow(f.a.dataDir, "acme.example.com")) |
| + opened := false |
| + f.a.openRegistry = func() (*registry.Registry, error) { |
| + opened = true |
| + return nil, errors.New("not reached") |
| + } |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if opened { |
| + t.Error("the guard opened the registry for a candidate that ships the layout") |
| + } |
| +} |
| + |
| +// A registry the guard cannot read cannot vouch that nothing is nested. |
| +func TestApplyRefusesAPreLayoutCandidateWhenTheRegistryWillNotOpen(t *testing.T) { |
| + f, _ := applyHappy(t) |
| + f.a.openRegistry = func() (*registry.Registry, error) { return nil, errors.New("registry.db: disk I/O error") } |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.result() |
| + if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "registry.db: disk I/O error") || |
| + !strings.Contains(r.Reason, "predates the instance layout") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j != nil { |
| + t.Fatalf("a refused apply wrote a journal: %+v", j) |
| + } |
| +} |
| + |
| +// Every row a pre-layout release would mishandle is named once: nested by |
| +// path shape whatever its flags, fenced by a journal, and a journal whose |
| +// row is gone. Flat rows are not named. |
| +func TestLayoutReversalIncompleteNamesEveryRowInTheWay(t *testing.T) { |
| + dd := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dd, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + unflagged := nestedRow(dd, "unflagged.example.com") |
| + unflagged.Hibernate = false |
| + for _, rt := range []registry.Route{ |
| + flatRow(dd, "flat.example.com"), |
| + nestedRow(dd, "nested.example.com"), |
| + unflagged, |
| + flatRow(dd, "moving.example.com"), |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + os.MkdirAll(filepath.Join(dd, "layout"), 0o700) |
| + for _, h := range []string{"moving.example.com", "gone.example.com"} { |
| + os.WriteFile(provision.LayoutJournalPath(dd, h), []byte("{}"), 0o600) |
| + } |
| + // A journal's temp file is not a journal. |
| + os.WriteFile(filepath.Join(dd, "layout", ".other.example.com.tmp-1"), []byte("{}"), 0o600) |
| + |
| + got := layoutReversalIncomplete(reg, dd) |
| + want := []string{"gone.example.com", "moving.example.com", "nested.example.com", "unflagged.example.com"} |
| + if !slices.Equal(got, want) { |
| + t.Fatalf("reasons = %q, want %q", got, want) |
| + } |
| + |
| + flatOnly := t.TempDir() |
| + reg2, err := registry.Open(filepath.Join(flatOnly, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg2.Close() |
| + if err := reg2.Upsert(flatRow(flatOnly, "flat.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := layoutReversalIncomplete(reg2, flatOnly); len(got) != 0 { |
| + t.Fatalf("a flat box reported %q", got) |
| + } |
| +} |