carlos / platform Public

Fresh boxes: keep the registry owned by the user the edge runs as

Discussion #8: all three region boxes brought up on 2026-08-31 were left
unstartable by the same cause, and two needed a second manual repair
because it is a race.

The registry is written by two users. `carlos agent` (carlos-edge.service)
runs as `carlos`; four units — carlos-adopt, -stage, -restart,
-config-sync — run `carlos adopt` as root, because a pass writes
/opt/carlos and cycles units via systemctl. SQLite creates the database
and its -wal/-shm owned by whoever opens it first. On an established box
that is the edge and root writes are fine; on a fresh box `carlos
bootstrap` arms adopt's .path and .timer before the edge has ever
started, so adopt can create the file as root and the edge then dies with

    agent: open registry /data/registry.db: attempt to write a readonly database (8)

after which systemd gives up in three seconds.

The report's suggested fix — `User=carlos` on carlos-adopt.service — is
not available: the pass genuinely needs root for /opt/carlos and
systemctl, which is why the unit says so out loud. So the ownership is
converged instead, at both ends of the race:

- bootstrap creates /data/registry.db itself, before it arms anything,
  inside the tree privilegedPlan already chowns to the service user. An
  empty file is a valid empty SQLite database; the first real opener
  migrates into it. An existing registry is left untouched — bootstrap
  re-runs over live boxes whose edge holds the file open.
- every root adopt pass chowns the database and its two sidecars back to
  the service user. Once by hand is not enough and the report says so:
  the armed units fire again and re-root the sidecars in between, and a
  carlos-owned database with a root-owned -wal is just as unwritable.

Chown failures are logged, never fatal: an adoption pass that can read
the registry has releases to stage, and refusing that over a chown would
turn a cosmetic problem into an outage.

Also the cheap fence the report asked for. "attempt to write a readonly
database (8)" is SQLite's words, and they sent the operator to disk space
and mount options before file ownership; both registry openers now add a
hint naming the file's actual owner, the user carlos-edge.service runs
as, and the chown that fixes it. Other SQLite errors still read as
themselves.

fileOwner is build-tagged: internal/host is linked into the CLI, which
people run on Macs and Windows, and syscall.Stat_t is not portable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVvK41AUKXndpcy5KkUZRS
Paul Campbell pushed by paul@keymail.dev 00e17e1b08adb219facf1f3a4a5cd5a4f531ad86 parent ae66fb6
6 files changed, +372 −0
  • internal/host/agent.go +11 −0
  • internal/host/bootstrap.go +29 −0
  • internal/host/fileowner_other.go +10 −0
  • internal/host/fileowner_unix.go +20 −0
  • internal/host/registryowner.go +123 −0
  • internal/host/registryowner_test.go +179 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 395fa43ab..1713beadf 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -198,6 +198,9 @@ func RunAgent(args []string) {
// one feeds the activator's due-time index and the adopt loop.
reg, err := registry.Open(regPath)
if err != nil {
+ if hint := registryOpenHint(regPath, err); hint != "" {
+ log.Fatalf("agent: open registry %s: %v\nagent: %s", regPath, err, hint)
+ }
log.Fatalf("agent: open registry %s: %v", regPath, err)
}
@@ -2813,8 +2816,16 @@ func RunAdopt(args []string) {
_ = fs.Parse(args)
reg, err := registry.Open(*regPath)
if err != nil {
+ if hint := registryOpenHint(*regPath, err); hint != "" {
+ log.Fatalf("adopt: open registry %s: %v\nadopt: %s", *regPath, err, hint)
+ }
log.Fatalf("adopt: open registry %s: %v", *regPath, err)
}
+ // This pass runs as root (carlos-adopt/-stage/-restart/-config-sync have
+ // no User=), and opening the registry is what creates its -wal/-shm. Hand
+ // them back to the user the edge runs as, on every pass — see
+ // registryowner.go for why once is not enough.
+ chownRegistryToService(*regPath)
ctx := context.Background()
store, err := openDeployStore(ctx)
if err != nil {
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 7611bde29..d59aed6f9 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -234,6 +234,35 @@ func bootstrap(root string, consumers []string) error {
}
}
+ // The registry file, created here and by nothing else on a fresh box.
+ //
+ // It has to exist BEFORE the units below are armed, because four of them
+ // (carlos-adopt, -stage, -restart, -config-sync) run `carlos adopt` as
+ // root and open this same file — and on a fresh box adopt's .path and
+ // .timer can fire before the edge has ever started. Whoever creates the
+ // file owns it, and an edge running as serviceUser cannot write a
+ // root-owned registry: that is discussion #8, three region boxes left
+ // unstartable on 2026-08-31. Creating it here puts it inside the
+ // `chown -R /data` that privilegedPlan already runs, so it is
+ // serviceUser's before anything can race for it.
+ //
+ // An empty file is a valid empty SQLite database — the first opener
+ // (edge or adopt) runs the migrations into it. Deliberately NOT opened
+ // as a registry here: on a re-run over a live box this must not touch
+ // the schema of a database the running edge holds open, and "the file
+ // exists, owned by the right user" is the whole job.
+ regPath := filepath.Join(dataDir, "registry.db")
+ if _, err := os.Stat(regPath); os.IsNotExist(err) {
+ f, err := os.OpenFile(regPath, os.O_CREATE|os.O_WRONLY, 0o640)
+ if err != nil {
+ return fmt.Errorf("create %s: %v", regPath, err)
+ }
+ if err := f.Close(); err != nil {
+ return fmt.Errorf("create %s: %v", regPath, err)
+ }
+ fmt.Printf(" created %s\n", regPath)
+ }
+
names, err := unitNames()
if err != nil {
return err
diff --git a/internal/host/fileowner_other.go b/internal/host/fileowner_other.go
new file mode 100644
index 000000000..9ab9347f4
--- /dev/null
+++ b/internal/host/fileowner_other.go
@@ -0,0 +1,10 @@
+//go:build !unix
+
+package host
+
+import "io/fs"
+
+// fileOwner has no answer off unix. Every caller treats !ok as "say nothing
+// about ownership" rather than as a failure, so the CLI still builds and runs
+// on Windows; the box side this serves is Linux-only anyway.
+func fileOwner(fs.FileInfo) (uid, gid int, ok bool) { return 0, 0, false }
diff --git a/internal/host/fileowner_unix.go b/internal/host/fileowner_unix.go
new file mode 100644
index 000000000..1a8067ba6
--- /dev/null
+++ b/internal/host/fileowner_unix.go
@@ -0,0 +1,20 @@
+//go:build unix
+
+package host
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+// fileOwner reports a file's owning uid and gid. Split by build tag because
+// internal/host is linked into the CLI, which people run on Macs and Windows,
+// and syscall.Stat_t does not exist everywhere — the gate cross-compiles all
+// five targets for exactly this reason.
+func fileOwner(fi fs.FileInfo) (uid, gid int, ok bool) {
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, 0, false
+ }
+ return int(st.Uid), int(st.Gid), true
+}
diff --git a/internal/host/registryowner.go b/internal/host/registryowner.go
new file mode 100644
index 000000000..e704afd48
--- /dev/null
+++ b/internal/host/registryowner.go
@@ -0,0 +1,123 @@
+package host
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/user"
+ "strconv"
+ "strings"
+)
+
+// The registry is written by TWO users. `carlos agent` (carlos-edge.service)
+// runs as serviceUser; every adoption-shaped pass — carlos-adopt,
+// carlos-stage, carlos-restart, carlos-config-sync — runs as root, because a
+// pass writes /opt/carlos and cycles units via systemctl. Both open
+// /data/registry.db, and SQLite creates the database and its -wal/-shm
+// sidecars owned by whoever got there first.
+//
+// On an established box the edge got there first, the files are carlos-owned,
+// and root writing to them is fine. On a FRESH box it is a race that root can
+// win: `carlos bootstrap` arms adopt's .path and .timer before the edge has
+// ever started, so adopt can create the registry as root and leave the edge
+// unable to start at all:
+//
+// agent: open registry /data/registry.db: attempt to write a readonly database (8)
+//
+// systemd gives up after five restarts in three seconds, so the box then sits
+// failed with no further attempts. All three region boxes brought up on
+// 2026-08-31 landed exactly there (discussion #8).
+//
+// Two fences, because one is not enough:
+//
+// 1. bootstrap creates the file itself, before the units that could race for
+// it are armed (see bootstrap.go) — so root never gets to create it.
+// 2. every root pass converges the ownership afterwards, below. Chowning by
+// hand once is NOT enough on a live box and the report says so: the armed
+// path/timer units fire again and re-create the sidecars as root in
+// between. Two of the three boxes needed the chown twice.
+
+// chownRegistryToService leaves the registry and its WAL sidecars owned by
+// serviceUser. It is a no-op unless this process is root: a pass running as
+// the service user already creates everything with the right owner.
+//
+// Failures are logged, never fatal. An adoption pass that can read the
+// registry has real work to do — releases to stage, units to cycle — and
+// refusing to do it because a chown failed would turn a cosmetic problem into
+// an outage. The next pass tries again.
+func chownRegistryToService(path string) {
+ // Geteuid reports -1 on platforms without the concept (the CLI
+ // cross-compiles to windows), which takes the same early exit.
+ if os.Geteuid() != 0 {
+ return
+ }
+ u, err := user.Lookup(serviceUser)
+ if err != nil {
+ log.Printf("adopt: registry ownership: no %s user: %v", serviceUser, err)
+ return
+ }
+ uid, err := strconv.Atoi(u.Uid)
+ if err != nil {
+ return
+ }
+ gid, err := strconv.Atoi(u.Gid)
+ if err != nil {
+ return
+ }
+ for _, p := range registryFiles(path) {
+ fi, err := os.Stat(p)
+ if err != nil {
+ // The -wal/-shm sidecars only exist while some connection is
+ // open. Their absence is the normal state, not a problem.
+ continue
+ }
+ if fuid, fgid, ok := fileOwner(fi); ok && fuid == uid && fgid == gid {
+ continue
+ }
+ if err := os.Chown(p, uid, gid); err != nil {
+ log.Printf("adopt: chown %s to %s: %v", p, serviceUser, err)
+ continue
+ }
+ // Worth a line every time it fires: on a healthy box it never does,
+ // so a run of these is the box telling you something armed root is
+ // creating the registry ahead of the edge.
+ log.Printf("adopt: %s was not owned by %s — fixed; the edge runs as %s and could not have written it", p, serviceUser, serviceUser)
+ }
+}
+
+// registryFiles is the database and the two sidecars SQLite keeps beside it
+// in WAL mode. Ownership has to cover all three: a carlos-owned database with
+// a root-owned -wal is just as unwritable as a root-owned database.
+func registryFiles(path string) []string {
+ return []string{path, path + "-wal", path + "-shm"}
+}
+
+// registryOpenHint turns SQLite's words into the box's words.
+//
+// "attempt to write a readonly database (8)" is what the edge printed on all
+// three boxes, and it sent the operator looking at disk space and mount
+// options before file ownership — which is the actual cause every time so far,
+// because the registry is the one file on the box two different users write.
+// The hint names the owner it found, the owner it needs, and the command that
+// fixes it. It returns "" for any other error, so a genuinely full disk still
+// reads as itself.
+func registryOpenHint(path string, err error) string {
+ if err == nil || !strings.Contains(strings.ToLower(err.Error()), "readonly database") {
+ return ""
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "the registry is not writable by this process (uid %d).", os.Geteuid())
+ if fi, statErr := os.Stat(path); statErr == nil {
+ if uid, gid, ok := fileOwner(fi); ok {
+ owner := strconv.Itoa(uid)
+ if u, lookupErr := user.LookupId(owner); lookupErr == nil {
+ owner = u.Username
+ }
+ fmt.Fprintf(&b, " %s is owned by %s (uid %d, gid %d)", path, owner, uid, gid)
+ }
+ }
+ fmt.Fprintf(&b, " — it and its -wal/-shm sidecars must be owned by %s, the user carlos-edge.service runs as."+
+ " A root `carlos adopt` pass that reached the file before the edge did is the usual cause."+
+ " Fix: chown %s:%s %s*", serviceUser, serviceUser, serviceUser, path)
+ return b.String()
+}
diff --git a/internal/host/registryowner_test.go b/internal/host/registryowner_test.go
new file mode 100644
index 000000000..48e7d22a3
--- /dev/null
+++ b/internal/host/registryowner_test.go
@@ -0,0 +1,179 @@
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestBootstrapCreatesRegistryBeforeArmingUnits is the fence for discussion
+// #8. Four of the units bootstrap arms run `carlos adopt` as ROOT and open
+// /data/registry.db; the edge that also opens it runs as the service user.
+// Whoever creates the file owns it, so on a fresh box the file must exist
+// before anything is armed — created inside the /data tree that
+// privilegedPlan then chowns to the service user.
+func TestBootstrapCreatesRegistryBeforeArmingUnits(t *testing.T) {
+ root := t.TempDir()
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatalf("bootstrap: %v", err)
+ }
+ reg := filepath.Join(root, "data", "registry.db")
+ fi, err := os.Stat(reg)
+ if err != nil {
+ t.Fatalf("bootstrap did not create the registry: %v", err)
+ }
+ if fi.IsDir() {
+ t.Fatalf("%s is a directory", reg)
+ }
+ // It is inside the tree privilegedPlan hands to the service user — the
+ // step that makes creating it here worth anything at all.
+ var chowned bool
+ for _, args := range privilegedPlan(nil) {
+ if args[0] == "chown" && args[len(args)-1] == "/data" {
+ chowned = true
+ }
+ }
+ if !chowned {
+ t.Error("privilegedPlan no longer chowns /data — the registry bootstrap creates would stay root-owned")
+ }
+}
+
+// TestBootstrapRegistryIsAnOpenableRegistry: the file bootstrap leaves behind
+// is an empty SQLite database, not a placeholder the first real opener has to
+// delete. Zero length is a valid empty database; migrations run into it.
+func TestBootstrapRegistryIsAnOpenableRegistry(t *testing.T) {
+ root := t.TempDir()
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatalf("bootstrap: %v", err)
+ }
+ reg, err := registry.Open(filepath.Join(root, "data", "registry.db"))
+ if err != nil {
+ t.Fatalf("open the registry bootstrap created: %v", err)
+ }
+ defer reg.Close()
+ if _, err := reg.List(); err != nil {
+ t.Fatalf("list routes: %v", err)
+ }
+}
+
+// TestBootstrapKeepsAnExistingRegistry: bootstrap re-runs over live boxes
+// (that is how a unit refresh happens), and the running edge holds this file
+// open. A re-run must not truncate it or touch its schema.
+func TestBootstrapKeepsAnExistingRegistry(t *testing.T) {
+ root := t.TempDir()
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatalf("first bootstrap: %v", err)
+ }
+ regPath := filepath.Join(root, "data", "registry.db")
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "static"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if err := reg.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatalf("second bootstrap: %v", err)
+ }
+
+ reg2, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer reg2.Close()
+ routes, err := reg2.List()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(routes) != 1 || routes[0].Host != "app.example.com" {
+ t.Fatalf("re-running bootstrap lost the registry contents: %+v", routes)
+ }
+}
+
+// TestChownRegistryToServiceIsANoopOffRoot: the helper runs on every adoption
+// pass, and the tests (and any developer box) are not root. It must do
+// nothing at all there rather than log or fail.
+func TestChownRegistryToServiceIsANoopOffRoot(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: this test pins the non-root path")
+ }
+ path := filepath.Join(t.TempDir(), "registry.db")
+ if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ chownRegistryToService(path) // must not panic, must not change anything
+ after, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ buid, bgid, ok := fileOwner(before)
+ auid, agid, ok2 := fileOwner(after)
+ if ok != ok2 || buid != auid || bgid != agid {
+ t.Errorf("ownership moved off root: %d:%d -> %d:%d", buid, bgid, auid, agid)
+ }
+}
+
+// TestRegistryFilesCoversTheWALSidecars: a carlos-owned database with a
+// root-owned -wal is exactly as unwritable as a root-owned database, and the
+// sidecars are what a root pass re-creates after a hand chown. Two of the
+// three boxes in #8 needed the chown twice for this reason.
+func TestRegistryFilesCoversTheWALSidecars(t *testing.T) {
+ got := registryFiles("/data/registry.db")
+ want := []string{"/data/registry.db", "/data/registry.db-wal", "/data/registry.db-shm"}
+ if len(got) != len(want) {
+ t.Fatalf("registryFiles = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("registryFiles[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+// TestRegistryOpenHintNamesOwnership is the cheap fence #8 asked for:
+// "attempt to write a readonly database (8)" is SQLite's words, and they sent
+// the operator looking at disk space and mount options. The hint has to name
+// ownership, the service user, and the command that fixes it.
+func TestRegistryOpenHintNamesOwnership(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ if err := os.WriteFile(path, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ hint := registryOpenHint(path, errors.New("attempt to write a readonly database (8)"))
+ if hint == "" {
+ t.Fatal("no hint for the readonly-database error")
+ }
+ for _, want := range []string{serviceUser, "chown", path, "owned by"} {
+ if !strings.Contains(hint, want) {
+ t.Errorf("hint does not mention %q:\n%s", want, hint)
+ }
+ }
+}
+
+// TestRegistryOpenHintSilentOnOtherErrors: a genuinely full disk or a missing
+// directory must still read as itself. The hint is only for the one error it
+// can actually explain.
+func TestRegistryOpenHintSilentOnOtherErrors(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ for _, err := range []error{
+ nil,
+ errors.New("database or disk is full (13)"),
+ errors.New("unable to open database file"),
+ } {
+ if hint := registryOpenHint(path, err); hint != "" {
+ t.Errorf("registryOpenHint(%v) = %q, want no hint", err, hint)
+ }
+ }
+}