| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | // TestBootstrapCreatesRegistryBeforeArmingUnits is the fence for discussion |
| 14 | // #8. Four of the units bootstrap arms run `carlos adopt` as ROOT and open |
| 15 | // /data/registry.db; the edge that also opens it runs as the service user. |
| 16 | // Whoever creates the file owns it, so on a fresh box the file must exist |
| 17 | // before anything is armed — created inside the /data tree that |
| 18 | // privilegedPlan then chowns to the service user. |
| 19 | func TestBootstrapCreatesRegistryBeforeArmingUnits(t *testing.T) { |
| 20 | root := t.TempDir() |
| 21 | if err := bootstrap(root, nil); err != nil { |
| 22 | t.Fatalf("bootstrap: %v", err) |
| 23 | } |
| 24 | reg := filepath.Join(root, "data", "registry.db") |
| 25 | fi, err := os.Stat(reg) |
| 26 | if err != nil { |
| 27 | t.Fatalf("bootstrap did not create the registry: %v", err) |
| 28 | } |
| 29 | if fi.IsDir() { |
| 30 | t.Fatalf("%s is a directory", reg) |
| 31 | } |
| 32 | // It is inside the tree privilegedPlan hands to the service user — the |
| 33 | // step that makes creating it here worth anything at all. |
| 34 | var chowned bool |
| 35 | for _, args := range privilegedPlan(nil) { |
| 36 | if args[0] == "chown" && args[len(args)-1] == "/data" { |
| 37 | chowned = true |
| 38 | } |
| 39 | } |
| 40 | if !chowned { |
| 41 | t.Error("privilegedPlan no longer chowns /data — the registry bootstrap creates would stay root-owned") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // TestBootstrapRegistryIsAnOpenableRegistry: the file bootstrap leaves behind |
| 46 | // is an empty SQLite database, not a placeholder the first real opener has to |
| 47 | // delete. Zero length is a valid empty database; migrations run into it. |
| 48 | func TestBootstrapRegistryIsAnOpenableRegistry(t *testing.T) { |
| 49 | root := t.TempDir() |
| 50 | if err := bootstrap(root, nil); err != nil { |
| 51 | t.Fatalf("bootstrap: %v", err) |
| 52 | } |
| 53 | reg, err := registry.Open(filepath.Join(root, "data", "registry.db")) |
| 54 | if err != nil { |
| 55 | t.Fatalf("open the registry bootstrap created: %v", err) |
| 56 | } |
| 57 | defer reg.Close() |
| 58 | if _, err := reg.List(); err != nil { |
| 59 | t.Fatalf("list routes: %v", err) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // TestBootstrapKeepsAnExistingRegistry: bootstrap re-runs over live boxes |
| 64 | // (that is how a unit refresh happens), and the running edge holds this file |
| 65 | // open. A re-run must not truncate it or touch its schema. |
| 66 | func TestBootstrapKeepsAnExistingRegistry(t *testing.T) { |
| 67 | root := t.TempDir() |
| 68 | if err := bootstrap(root, nil); err != nil { |
| 69 | t.Fatalf("first bootstrap: %v", err) |
| 70 | } |
| 71 | regPath := filepath.Join(root, "data", "registry.db") |
| 72 | reg, err := registry.Open(regPath) |
| 73 | if err != nil { |
| 74 | t.Fatalf("open: %v", err) |
| 75 | } |
| 76 | if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "static"}); err != nil { |
| 77 | t.Fatalf("upsert: %v", err) |
| 78 | } |
| 79 | if err := reg.Close(); err != nil { |
| 80 | t.Fatalf("close: %v", err) |
| 81 | } |
| 82 | |
| 83 | if err := bootstrap(root, nil); err != nil { |
| 84 | t.Fatalf("second bootstrap: %v", err) |
| 85 | } |
| 86 | |
| 87 | reg2, err := registry.Open(regPath) |
| 88 | if err != nil { |
| 89 | t.Fatalf("reopen: %v", err) |
| 90 | } |
| 91 | defer reg2.Close() |
| 92 | routes, err := reg2.List() |
| 93 | if err != nil { |
| 94 | t.Fatalf("list: %v", err) |
| 95 | } |
| 96 | if len(routes) != 1 || routes[0].Host != "app.example.com" { |
| 97 | t.Fatalf("re-running bootstrap lost the registry contents: %+v", routes) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // TestChownRegistryToServiceIsANoopOffRoot: the helper runs on every adoption |
| 102 | // pass, and the tests (and any developer box) are not root. It must do |
| 103 | // nothing at all there rather than log or fail. |
| 104 | func TestChownRegistryToServiceIsANoopOffRoot(t *testing.T) { |
| 105 | if os.Geteuid() == 0 { |
| 106 | t.Skip("running as root: this test pins the non-root path") |
| 107 | } |
| 108 | path := filepath.Join(t.TempDir(), "registry.db") |
| 109 | if err := os.WriteFile(path, []byte("x"), 0o640); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | before, err := os.Stat(path) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | logged := captureLog(t) |
| 117 | chownRegistryToService(path) // must not panic, must not change anything |
| 118 | after, err := os.Stat(path) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | buid, bgid, ok := fileOwner(before) |
| 123 | auid, agid, ok2 := fileOwner(after) |
| 124 | if ok != ok2 || buid != auid || bgid != agid { |
| 125 | t.Errorf("ownership moved off root: %d:%d -> %d:%d", buid, bgid, auid, agid) |
| 126 | } |
| 127 | // Silent, too: a laptop or a CI runner must not be told about a chown it |
| 128 | // was never going to do. |
| 129 | if out := logged(); out != "" { |
| 130 | t.Errorf("off root the helper logged:\n%s", out) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // TestRegistryFilesCoversTheWALSidecars: a carlos-owned database with a |
| 135 | // root-owned -wal is exactly as unwritable as a root-owned database, and the |
| 136 | // sidecars are what a root pass re-creates after a hand chown. Two of the |
| 137 | // three boxes in #8 needed the chown twice for this reason. |
| 138 | func TestRegistryFilesCoversTheWALSidecars(t *testing.T) { |
| 139 | got := registryFiles("/data/registry.db") |
| 140 | want := []string{"/data/registry.db", "/data/registry.db-wal", "/data/registry.db-shm"} |
| 141 | if len(got) != len(want) { |
| 142 | t.Fatalf("registryFiles = %v, want %v", got, want) |
| 143 | } |
| 144 | for i := range want { |
| 145 | if got[i] != want[i] { |
| 146 | t.Errorf("registryFiles[%d] = %q, want %q", i, got[i], want[i]) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // foreignUID is a uid that is certainly not the one owning a file this test |
| 152 | // process just created, and certainly not root. It is how the "somebody else |
| 153 | // owns the registry" shape is reached from a test binary that cannot chown. |
| 154 | func foreignUID() int { return os.Geteuid() + 1 } |
| 155 | |
| 156 | // TestRegistryOpenHintNamesOwnership is the cheap fence #8 asked for: |
| 157 | // "attempt to write a readonly database (8)" is SQLite's words, and they sent |
| 158 | // the operator looking at disk space and mount options. Where ownership IS |
| 159 | // the cause — a non-root process holding a file it does not own — the hint |
| 160 | // has to name the owner it found, the service user, and the fixing command. |
| 161 | func TestRegistryOpenHintNamesOwnership(t *testing.T) { |
| 162 | path := filepath.Join(t.TempDir(), "registry.db") |
| 163 | if err := os.WriteFile(path, nil, 0o640); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | hint := registryOpenHintFor(foreignUID(), path, errors.New("attempt to write a readonly database (8)")) |
| 167 | if hint == "" { |
| 168 | t.Fatal("no hint for the readonly-database error") |
| 169 | } |
| 170 | for _, want := range []string{serviceUser, "chown", path, "owned by"} { |
| 171 | if !strings.Contains(hint, want) { |
| 172 | t.Errorf("hint does not mention %q:\n%s", want, hint) |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // TestRegistryOpenHintStatsTheSidecars: the hint used to stat the database |
| 178 | // alone, which is why the reporter's SECOND failure — database owned by |
| 179 | // carlos, -wal still owned by root — printed "owned by carlos … must be owned |
| 180 | // by carlos". Every file that exists has to be reported, or the hint |
| 181 | // contradicts itself on the exact case that motivated it. |
| 182 | func TestRegistryOpenHintStatsTheSidecars(t *testing.T) { |
| 183 | dir := t.TempDir() |
| 184 | path := filepath.Join(dir, "registry.db") |
| 185 | for _, p := range registryFiles(path) { |
| 186 | if err := os.WriteFile(p, nil, 0o640); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | } |
| 190 | hint := registryOpenHintFor(foreignUID(), path, errors.New("attempt to write a readonly database (8)")) |
| 191 | for _, p := range registryFiles(path) { |
| 192 | if !strings.Contains(hint, p) { |
| 193 | t.Errorf("hint says nothing about %s:\n%s", p, hint) |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | // TestRegistryOpenHintDoesNotBlameOwnershipAsRoot is the negative fence. The |
| 199 | // root passes drop no capabilities, so root holds CAP_DAC_OVERRIDE and writes |
| 200 | // a file whatever its owner — a "readonly database" seen by root can NEVER be |
| 201 | // an ownership problem, and telling root to chown is pointing at the one |
| 202 | // action that cannot help. Note the file here is owned by somebody other than |
| 203 | // root as far as the hint is concerned: even with a mismatch in front of it, |
| 204 | // the root wording must hold. |
| 205 | func TestRegistryOpenHintDoesNotBlameOwnershipAsRoot(t *testing.T) { |
| 206 | path := filepath.Join(t.TempDir(), "registry.db") |
| 207 | if err := os.WriteFile(path, nil, 0o640); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | hint := registryOpenHintFor(0, path, errors.New("attempt to write a readonly database (8)")) |
| 211 | if hint == "" { |
| 212 | t.Fatal("no hint for the readonly-database error") |
| 213 | } |
| 214 | if strings.Contains(hint, "chown") { |
| 215 | t.Errorf("the root hint tells the operator to chown, which cannot help root:\n%s", hint) |
| 216 | } |
| 217 | // And it names causes that ARE possible for root. |
| 218 | for _, want := range []string{"CAP_DAC_OVERRIDE", "read-only", "immutable", "SELinux"} { |
| 219 | if !strings.Contains(hint, want) { |
| 220 | t.Errorf("the root hint does not mention %q:\n%s", want, hint) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // TestRegistryOpenHintSaysNothingIsWrongWithOwnership: off root, with every |
| 226 | // file that exists already owned by this process, ownership is not the cause |
| 227 | // either. Repeating the chown is exactly the advice the reporter had already |
| 228 | // followed; the hint has to move on to the mount and the unit sandbox. |
| 229 | func TestRegistryOpenHintSaysNothingIsWrongWithOwnership(t *testing.T) { |
| 230 | if os.Geteuid() == 0 { |
| 231 | t.Skip("running as root: the root wording is pinned by its own test") |
| 232 | } |
| 233 | dir := t.TempDir() |
| 234 | path := filepath.Join(dir, "registry.db") |
| 235 | for _, p := range registryFiles(path) { |
| 236 | if err := os.WriteFile(p, nil, 0o640); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | } |
| 240 | hint := registryOpenHintFor(os.Geteuid(), path, errors.New("attempt to write a readonly database (8)")) |
| 241 | if hint == "" { |
| 242 | t.Fatal("no hint for the readonly-database error") |
| 243 | } |
| 244 | if strings.Contains(hint, "chown") { |
| 245 | t.Errorf("ownership is already correct and the hint still asks for a chown:\n%s", hint) |
| 246 | } |
| 247 | for _, want := range []string{"already correct", "mount", "ReadWritePaths"} { |
| 248 | if !strings.Contains(hint, want) { |
| 249 | t.Errorf("hint does not mention %q:\n%s", want, hint) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // TestRegistryOpenHintSilentOnOtherErrors: a genuinely full disk or a missing |
| 255 | // directory must still read as itself. The hint is only for the one error it |
| 256 | // can actually explain. |
| 257 | func TestRegistryOpenHintSilentOnOtherErrors(t *testing.T) { |
| 258 | path := filepath.Join(t.TempDir(), "registry.db") |
| 259 | for _, err := range []error{ |
| 260 | nil, |
| 261 | errors.New("database or disk is full (13)"), |
| 262 | errors.New("unable to open database file"), |
| 263 | } { |
| 264 | if hint := registryOpenHint(path, err); hint != "" { |
| 265 | t.Errorf("registryOpenHint(%v) = %q, want no hint", err, hint) |
| 266 | } |
| 267 | // Both wordings that DO fire have to stay off these errors too. |
| 268 | if hint := registryOpenHintFor(0, path, err); hint != "" { |
| 269 | t.Errorf("registryOpenHintFor(root, %v) = %q, want no hint", err, hint) |
| 270 | } |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func TestOpenServiceRegistryRequiresServiceUser(t *testing.T) { |
| 275 | path := filepath.Join(t.TempDir(), "registry.db") |
| 276 | const noSuchUser = "carlos-no-such-service-user-8f21" |
| 277 | reg, err := openRegistryConverging(0, noSuchUser, path) |
| 278 | if reg != nil { |
| 279 | reg.Close() |
| 280 | t.Fatal("root opened a registry without a service identity") |
| 281 | } |
| 282 | if err == nil { |
| 283 | t.Fatal("missing service user was accepted") |
| 284 | } |
| 285 | if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { |
| 286 | t.Fatalf("root created the registry before resolving credentials: %v", statErr) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // TestRunAdoptOpensTheRegistryThroughTheHelper is the other half of the same |
| 291 | // fence, and the reason it reads source rather than asserting an order: an |
| 292 | // ordering assertion rots the moment the function is rearranged, while "the |
| 293 | // root pass never calls registry.Open itself" stays true for as long as the |
| 294 | // invariant does. It follows the chain RunAdopt -> runAdopt -> |
| 295 | // runAdoptWithEnsureSystemUnits, so a wrapper that stops delegating, or a |
| 296 | // direct open in any of the three, fails here. |
| 297 | func TestRunAdoptOpensTheRegistryThroughTheHelper(t *testing.T) { |
| 298 | raw, err := os.ReadFile("agent.go") |
| 299 | if err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | src := string(raw) |
| 303 | body := func(name string) string { |
| 304 | i := strings.Index(src, "\nfunc "+name+"(") |
| 305 | if i < 0 { |
| 306 | t.Fatalf("%s is no longer in agent.go, move this test with it", name) |
| 307 | } |
| 308 | rest := src[i+1:] |
| 309 | end := len(rest) |
| 310 | // A one-line wrapper has no closing "\n}\n" of its own, so the next |
| 311 | // top-level func also ends the body. |
| 312 | for _, m := range []string{"\n}\n", "\nfunc "} { |
| 313 | if j := strings.Index(rest[1:], m); j >= 0 && j+1 < end { |
| 314 | end = j + 1 |
| 315 | } |
| 316 | } |
| 317 | return rest[:end] |
| 318 | } |
| 319 | |
| 320 | chain := []struct{ name, calls string }{ |
| 321 | {"RunAdopt", "runAdopt("}, |
| 322 | {"runAdopt", "runAdoptWithEnsureSystemUnits("}, |
| 323 | {"runAdoptWithEnsureSystemUnits", "withClaimRootRelease("}, |
| 324 | {"runAdoptWithEnsureSystemUnits", "runAdoptUnderRootRelease("}, |
| 325 | {"runAdoptUnderRootRelease", "openServiceRegistry("}, |
| 326 | } |
| 327 | for _, c := range chain { |
| 328 | b := body(c.name) |
| 329 | if !strings.Contains(b, c.calls) { |
| 330 | t.Errorf("%s no longer calls %s the root pass has stopped converging ownership", c.name, c.calls) |
| 331 | } |
| 332 | if strings.Contains(b, "registry.Open(") { |
| 333 | t.Errorf("%s opens the registry directly; use openServiceRegistry so the chown cannot be lost ahead of the -restart-only early return", c.name) |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // TestChownRegistryFailureIsLoggedNotFatal: an adoption pass that can read the |
| 339 | // registry has releases to stage and units to cycle. A chown it cannot do is |
| 340 | // a cosmetic problem, and dying over it would turn that into an outage — so |
| 341 | // every failure arm logs and carries on. A test that reaches its own end is |
| 342 | // the assertion: log.Fatalf would take the process with it. |
| 343 | func TestChownRegistryFailureIsLoggedNotFatal(t *testing.T) { |
| 344 | t.Run("service user does not resolve", func(t *testing.T) { |
| 345 | path := filepath.Join(t.TempDir(), "registry.db") |
| 346 | if err := os.WriteFile(path, nil, 0o640); err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | logged := captureLog(t) |
| 350 | chownRegistryFiles(0, "carlos-no-such-service-user-8f21", path) |
| 351 | if !strings.Contains(logged(), "carlos-no-such-service-user-8f21") { |
| 352 | t.Errorf("the failed lookup was not logged:\n%s", logged()) |
| 353 | } |
| 354 | }) |
| 355 | |
| 356 | t.Run("the chown itself is refused", func(t *testing.T) { |
| 357 | if os.Geteuid() == 0 { |
| 358 | t.Skip("running as root: chown would succeed, so there is no refusal to observe") |
| 359 | } |
| 360 | dir := t.TempDir() |
| 361 | path := filepath.Join(dir, "registry.db") |
| 362 | // Database and -wal both present and both owned by this (non-root) |
| 363 | // user: chowning them to root is refused for each in turn. |
| 364 | for _, p := range []string{path, path + "-wal"} { |
| 365 | if err := os.WriteFile(p, nil, 0o640); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | } |
| 369 | logged := captureLog(t) |
| 370 | chownRegistryFiles(0, "root", path) |
| 371 | out := logged() |
| 372 | // Both files reported, which is the "continue, do not return" half: |
| 373 | // the loop stepped over the first failure and tried the second. |
| 374 | for _, p := range []string{path, path + "-wal"} { |
| 375 | if !strings.Contains(out, "chown "+p+" to root") { |
| 376 | t.Errorf("no logged failure for %s; log was:\n%s", p, out) |
| 377 | } |
| 378 | } |
| 379 | }) |
| 380 | } |
| 381 | |