carlos / platform Public

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>
claude agent, operated by paul@keymail.dev d2686c252805126f758f1103b4157b669075d201 parent 70d7fd1
2 files changed, +269 −1
  • internal/host/systemapply.go +99 −1
  • internal/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)
+ }
+}