carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
19func 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.
48func 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.
66func 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.
104func 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.
138func 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.
154func 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.
161func 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.
182func 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.
205func 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.
229func 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.
257func 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
274func 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.
297func 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.
343func 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