carlos / platform Public

Merge branch 'fix-adopt-registry-ownership' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 8635d54f4dd07d4232b52515b635054e0a74e244 parent ac648d6parent 1ecd6c1
6 files changed, +687 −1
  • internal/host/agent.go +13 −1
  • internal/host/bootstrap.go +39 −0
  • internal/host/fileowner_other.go +10 −0
  • internal/host/fileowner_unix.go +20 −0
  • internal/host/registryowner.go +237 −0
  • internal/host/registryowner_test.go +368 −0

CI passed — run details

diff --git a/internal/host/agent.go b/internal/host/agent.go
index 395fa43ab..d2da8db09 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)
}
@@ -2811,8 +2814,17 @@ func RunAdopt(args []string) {
restartOnly := fs.Bool("restart-only", false, "cycle the routes of apps whose restart generation moved, and nothing else: the agent-requested pass (carlos-restart.service)")
fs.Usage = cliflag.Usage(fs, "")
_ = fs.Parse(args)
- reg, err := registry.Open(*regPath)
+ // openServiceRegistry, not registry.Open: this pass runs as root
+ // (carlos-adopt/-stage/-restart/-config-sync have no User=), opening the
+ // registry is what creates its -wal/-shm, and the helper hands them back
+ // to the user the edge runs as as part of the open. That matters most for
+ // -restart-only, which returns a dozen lines below — see registryowner.go
+ // for why a converge on every pass is what it takes.
+ reg, err := openServiceRegistry(*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)
}
ctx := context.Background()
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 7611bde29..a34b66e08 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -234,6 +234,45 @@ 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.
+ //
+ // One constraint this puts on the deploy side: a STAGING run takes this
+ // branch too. `carlos bootstrap -root <dir>` is how a live box refreshes
+ // its unit files (pinfra's update-platform-binary.sh stages into
+ // /tmp/carlos-unitstage), so a staging tree now contains a 0-byte
+ // data/registry.db. Harmless while that script copies only etc/ out of
+ // the tree — and destructive the day anything rsyncs the whole tree over
+ // /, because a 0-byte file would land on top of the live registry and
+ // empty it. The script lives in the private platform-infrastructure repo;
+ // whoever widens what it copies has to exclude data/.
+ 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..756b9e9d0
--- /dev/null
+++ b/internal/host/registryowner.go
@@ -0,0 +1,237 @@
+package host
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// 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.
+
+// openServiceRegistry opens the registry for a root-side pass and converges
+// its ownership in the same breath.
+//
+// One function rather than two statements, because the two must not drift.
+// Opening the database is what CREATES the -wal/-shm, so the converge has to
+// follow the open — and `carlos adopt -restart-only` returns from RunAdopt a
+// few lines after it. A converge written as a separate statement that ever
+// slid below that early return would silently stop self-healing the restart
+// path, which is the pass that fires most often on a live box (carlos-restart
+// .path is per-request). Callers get the registry only through here, so there
+// is no ordering left to get wrong.
+func openServiceRegistry(path string) (*registry.Registry, error) {
+ return openRegistryConverging(os.Geteuid(), serviceUser, path)
+}
+
+// openRegistryConverging is openServiceRegistry with the two facts it reads
+// from the process passed in; see chownRegistryFiles.
+func openRegistryConverging(euid int, svcUser, path string) (*registry.Registry, error) {
+ reg, err := registry.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ chownRegistryFiles(euid, svcUser, path)
+ return reg, nil
+}
+
+// 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.
+ chownRegistryFiles(os.Geteuid(), serviceUser, path)
+}
+
+// chownRegistryFiles is chownRegistryToService with the process facts — the
+// effective uid and the service user's name — made arguments, so a test can
+// drive the root arm, and both of its failure arms, from a test binary that
+// is not root on a box that has no carlos user.
+func chownRegistryFiles(euid int, svcUser, path string) {
+ if euid != 0 {
+ return
+ }
+ u, err := user.Lookup(svcUser)
+ if err != nil {
+ log.Printf("adopt: registry ownership: no %s user: %v", svcUser, 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 {
+ // Logged and stepped over, never returned: the files after this
+ // one may well be fixable, and none of it is worth the pass.
+ log.Printf("adopt: chown %s to %s: %v", p, svcUser, 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, svcUser, svcUser)
+ }
+}
+
+// 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. The hint exists so the next person does not
+// lose that time — which means it has to be RIGHT and not merely helpful: a
+// hint that names ownership where ownership is not the cause sends them
+// somewhere just as wrong, with more confidence behind it.
+//
+// So it says one of three things, and the process's own euid picks which:
+//
+// - As ROOT, ownership is never the answer. The root units drop no
+// capabilities, so root holds CAP_DAC_OVERRIDE and writes the file
+// whatever its owner and mode; a chown is the one action that cannot
+// help. What remains possible for root is a read-only remount, an
+// immutable attribute, a hot journal, or a MAC denial — and those are
+// what it names.
+// - Off root, holding a file this process does not own, ownership IS the
+// answer and it gives the chown.
+// - Off root with everything already owned correctly, ownership is not the
+// answer either, so it points at the mount and the unit sandbox rather
+// than repeating advice the operator has already taken.
+//
+// It stats all three of registryFiles rather than the database alone. A
+// carlos-owned database with a root-owned -wal reports the identical error,
+// and that is exactly the shape a hand chown of the database alone leaves
+// behind — the second failure in #8, and why two of the three boxes needed
+// the chown twice. Reporting the database alone printed "owned by carlos …
+// must be owned by carlos" there: a self-contradiction, on the very case that
+// motivated the hint.
+//
+// It returns "" for any other error, so a genuinely full disk still reads as
+// itself.
+func registryOpenHint(path string, err error) string {
+ return registryOpenHintFor(os.Geteuid(), path, err)
+}
+
+// registryOpenHintFor is registryOpenHint with the effective uid passed in,
+// so the root and non-root wordings are both reachable from one test binary.
+func registryOpenHintFor(euid int, 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).", euid)
+
+ // present counts the files that exist; mine, the ones this process owns.
+ // They are equal only when there is nothing left for a chown to fix.
+ var present, mine int
+ for _, p := range registryFiles(path) {
+ fi, statErr := os.Stat(p)
+ if statErr != nil {
+ // A missing -wal/-shm is the normal state between connections,
+ // not a finding to report.
+ continue
+ }
+ present++
+ uid, gid, ok := fileOwner(fi)
+ if !ok {
+ // Off unix there is no owner to read. Counted as present but
+ // never as mine, so the hint never claims ownership is fine on
+ // evidence it does not have.
+ fmt.Fprintf(&b, " %s exists (owner unreadable on this platform).", p)
+ continue
+ }
+ if uid == euid {
+ mine++
+ }
+ fmt.Fprintf(&b, " %s is owned by %s (uid %d, gid %d).", p, ownerName(uid), uid, gid)
+ }
+
+ dir := filepath.Dir(path)
+ switch {
+ case euid == 0:
+ fmt.Fprintf(&b, " Running as root, this is NOT an ownership problem: the root units drop"+
+ " no capabilities, so root holds CAP_DAC_OVERRIDE and writes the file whatever its"+
+ " owner and mode — changing the owner would change nothing. What can still produce"+
+ " it for root: the filesystem remounted read-only after an I/O error"+
+ " (`dmesg -T | tail`, `findmnt -no OPTIONS %s`), an immutable attribute on one of"+
+ " the files (`lsattr %s*`), a hot journal or -wal left by a crash that SQLite"+
+ " cannot replay, or a MAC denial (SELinux/AppArmor) on the write.", dir, path)
+ case present > 0 && present == mine:
+ fmt.Fprintf(&b, " Ownership is already correct — every file that exists is owned by this"+
+ " uid — so setting it again will not help. Look instead at the mount (a read-only"+
+ " remount after an I/O error, or a read-only %s: `findmnt -no OPTIONS %s`) and at"+
+ " this unit's sandbox (ProtectSystem= or ReadOnlyPaths=, or %s missing from"+
+ " ReadWritePaths=).", dir, dir, dir)
+ default:
+ 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()
+}
+
+// ownerName resolves a uid to a username, falling back to the number itself.
+// A box whose passwd lookup is broken still gets a usable hint.
+func ownerName(uid int) string {
+ if u, err := user.LookupId(strconv.Itoa(uid)); err == nil && u.Username != "" {
+ return u.Username
+ }
+ return strconv.Itoa(uid)
+}
diff --git a/internal/host/registryowner_test.go b/internal/host/registryowner_test.go
new file mode 100644
index 000000000..10a0d43d6
--- /dev/null
+++ b/internal/host/registryowner_test.go
@@ -0,0 +1,368 @@
+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)
+ }
+ logged := captureLog(t)
+ 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)
+ }
+ // Silent, too: a laptop or a CI runner must not be told about a chown it
+ // was never going to do.
+ if out := logged(); out != "" {
+ t.Errorf("off root the helper logged:\n%s", out)
+ }
+}
+
+// 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])
+ }
+ }
+}
+
+// foreignUID is a uid that is certainly not the one owning a file this test
+// process just created, and certainly not root. It is how the "somebody else
+// owns the registry" shape is reached from a test binary that cannot chown.
+func foreignUID() int { return os.Geteuid() + 1 }
+
+// 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. Where ownership IS
+// the cause — a non-root process holding a file it does not own — the hint
+// has to name the owner it found, the service user, and the fixing command.
+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 := registryOpenHintFor(foreignUID(), 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)
+ }
+ }
+}
+
+// TestRegistryOpenHintStatsTheSidecars: the hint used to stat the database
+// alone, which is why the reporter's SECOND failure — database owned by
+// carlos, -wal still owned by root — printed "owned by carlos … must be owned
+// by carlos". Every file that exists has to be reported, or the hint
+// contradicts itself on the exact case that motivated it.
+func TestRegistryOpenHintStatsTheSidecars(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "registry.db")
+ for _, p := range registryFiles(path) {
+ if err := os.WriteFile(p, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hint := registryOpenHintFor(foreignUID(), path, errors.New("attempt to write a readonly database (8)"))
+ for _, p := range registryFiles(path) {
+ if !strings.Contains(hint, p) {
+ t.Errorf("hint says nothing about %s:\n%s", p, hint)
+ }
+ }
+}
+
+// TestRegistryOpenHintDoesNotBlameOwnershipAsRoot is the negative fence. The
+// root passes drop no capabilities, so root holds CAP_DAC_OVERRIDE and writes
+// a file whatever its owner — a "readonly database" seen by root can NEVER be
+// an ownership problem, and telling root to chown is pointing at the one
+// action that cannot help. Note the file here is owned by somebody other than
+// root as far as the hint is concerned: even with a mismatch in front of it,
+// the root wording must hold.
+func TestRegistryOpenHintDoesNotBlameOwnershipAsRoot(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ if err := os.WriteFile(path, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ hint := registryOpenHintFor(0, path, errors.New("attempt to write a readonly database (8)"))
+ if hint == "" {
+ t.Fatal("no hint for the readonly-database error")
+ }
+ if strings.Contains(hint, "chown") {
+ t.Errorf("the root hint tells the operator to chown, which cannot help root:\n%s", hint)
+ }
+ // And it names causes that ARE possible for root.
+ for _, want := range []string{"CAP_DAC_OVERRIDE", "read-only", "immutable", "SELinux"} {
+ if !strings.Contains(hint, want) {
+ t.Errorf("the root hint does not mention %q:\n%s", want, hint)
+ }
+ }
+}
+
+// TestRegistryOpenHintSaysNothingIsWrongWithOwnership: off root, with every
+// file that exists already owned by this process, ownership is not the cause
+// either. Repeating the chown is exactly the advice the reporter had already
+// followed; the hint has to move on to the mount and the unit sandbox.
+func TestRegistryOpenHintSaysNothingIsWrongWithOwnership(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: the root wording is pinned by its own test")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "registry.db")
+ for _, p := range registryFiles(path) {
+ if err := os.WriteFile(p, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hint := registryOpenHintFor(os.Geteuid(), path, errors.New("attempt to write a readonly database (8)"))
+ if hint == "" {
+ t.Fatal("no hint for the readonly-database error")
+ }
+ if strings.Contains(hint, "chown") {
+ t.Errorf("ownership is already correct and the hint still asks for a chown:\n%s", hint)
+ }
+ for _, want := range []string{"already correct", "mount", "ReadWritePaths"} {
+ 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)
+ }
+ // Both wordings that DO fire have to stay off these errors too.
+ if hint := registryOpenHintFor(0, path, err); hint != "" {
+ t.Errorf("registryOpenHintFor(root, %v) = %q, want no hint", err, hint)
+ }
+ }
+}
+
+// TestOpenServiceRegistryConvergesOwnership pins the two halves as one
+// operation. `carlos adopt -restart-only` returns from RunAdopt within a few
+// lines of the open, so a converge that ever slid below that early return
+// would silently stop self-healing the pass that fires most often. Opening
+// through this helper is what makes the ordering unloseable — the test drives
+// the root arm with a service user that cannot resolve, so the one thing it
+// observes is that the open reached the converge at all.
+func TestOpenServiceRegistryConvergesOwnership(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ logged := captureLog(t)
+ const noSuchUser = "carlos-no-such-service-user-8f21"
+ reg, err := openRegistryConverging(0, noSuchUser, path)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer reg.Close()
+ if _, err := reg.List(); err != nil {
+ t.Fatalf("the helper returned an unusable registry: %v", err)
+ }
+ if !strings.Contains(logged(), noSuchUser) {
+ t.Errorf("opening the registry did not run the ownership converge; log was:\n%s", logged())
+ }
+}
+
+// TestRunAdoptOpensTheRegistryThroughTheHelper is the other half of the same
+// fence, and the reason it reads source rather than asserting an order: an
+// ordering assertion rots the moment the function is rearranged, while "the
+// root pass never calls registry.Open itself" stays true for as long as the
+// invariant does.
+func TestRunAdoptOpensTheRegistryThroughTheHelper(t *testing.T) {
+ src, err := os.ReadFile("agent.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ i := strings.Index(string(src), "\nfunc RunAdopt(")
+ if i < 0 {
+ t.Fatal("RunAdopt is no longer in agent.go — move this test with it")
+ }
+ rest := string(src)[i+1:]
+ j := strings.Index(rest, "\n}\n")
+ if j < 0 {
+ t.Fatal("could not find the end of RunAdopt")
+ }
+ body := rest[:j]
+ if !strings.Contains(body, "openServiceRegistry(") {
+ t.Error("RunAdopt no longer opens the registry through openServiceRegistry — the root pass has stopped converging ownership")
+ }
+ if strings.Contains(body, "registry.Open(") {
+ t.Error("RunAdopt opens the registry directly; use openServiceRegistry so the chown cannot be lost ahead of the -restart-only early return")
+ }
+}
+
+// TestChownRegistryFailureIsLoggedNotFatal: an adoption pass that can read the
+// registry has releases to stage and units to cycle. A chown it cannot do is
+// a cosmetic problem, and dying over it would turn that into an outage — so
+// every failure arm logs and carries on. A test that reaches its own end is
+// the assertion: log.Fatalf would take the process with it.
+func TestChownRegistryFailureIsLoggedNotFatal(t *testing.T) {
+ t.Run("service user does not resolve", func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ if err := os.WriteFile(path, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ logged := captureLog(t)
+ chownRegistryFiles(0, "carlos-no-such-service-user-8f21", path)
+ if !strings.Contains(logged(), "carlos-no-such-service-user-8f21") {
+ t.Errorf("the failed lookup was not logged:\n%s", logged())
+ }
+ })
+
+ t.Run("the chown itself is refused", func(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: chown would succeed, so there is no refusal to observe")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "registry.db")
+ // Database and -wal both present and both owned by this (non-root)
+ // user: chowning them to root is refused for each in turn.
+ for _, p := range []string{path, path + "-wal"} {
+ if err := os.WriteFile(p, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ logged := captureLog(t)
+ chownRegistryFiles(0, "root", path)
+ out := logged()
+ // Both files reported, which is the "continue, do not return" half:
+ // the loop stepped over the first failure and tried the second.
+ for _, p := range []string{path, path + "-wal"} {
+ if !strings.Contains(out, "chown "+p+" to root") {
+ t.Errorf("no logged failure for %s; log was:\n%s", p, out)
+ }
+ }
+ })
+}