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) } } } func TestOpenServiceRegistryRequiresServiceUser(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") const noSuchUser = "carlos-no-such-service-user-8f21" reg, err := openRegistryConverging(0, noSuchUser, path) if reg != nil { reg.Close() t.Fatal("root opened a registry without a service identity") } if err == nil { t.Fatal("missing service user was accepted") } if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("root created the registry before resolving credentials: %v", statErr) } } // 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. It follows the chain RunAdopt -> runAdopt -> // runAdoptWithEnsureSystemUnits, so a wrapper that stops delegating, or a // direct open in any of the three, fails here. func TestRunAdoptOpensTheRegistryThroughTheHelper(t *testing.T) { raw, err := os.ReadFile("agent.go") if err != nil { t.Fatal(err) } src := string(raw) body := func(name string) string { i := strings.Index(src, "\nfunc "+name+"(") if i < 0 { t.Fatalf("%s is no longer in agent.go, move this test with it", name) } rest := src[i+1:] end := len(rest) // A one-line wrapper has no closing "\n}\n" of its own, so the next // top-level func also ends the body. for _, m := range []string{"\n}\n", "\nfunc "} { if j := strings.Index(rest[1:], m); j >= 0 && j+1 < end { end = j + 1 } } return rest[:end] } chain := []struct{ name, calls string }{ {"RunAdopt", "runAdopt("}, {"runAdopt", "runAdoptWithEnsureSystemUnits("}, {"runAdoptWithEnsureSystemUnits", "withClaimRootRelease("}, {"runAdoptWithEnsureSystemUnits", "runAdoptUnderRootRelease("}, {"runAdoptUnderRootRelease", "openServiceRegistry("}, } for _, c := range chain { b := body(c.name) if !strings.Contains(b, c.calls) { t.Errorf("%s no longer calls %s the root pass has stopped converging ownership", c.name, c.calls) } if strings.Contains(b, "registry.Open(") { t.Errorf("%s opens the registry directly; use openServiceRegistry so the chown cannot be lost ahead of the -restart-only early return", c.name) } } } // 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) } } }) }