appconfig: the marker-owned drop-in and pending marker as pure file operations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+301
−0
internal/appconfig/dropin.go+172 −0internal/appconfig/dropin_test.go+129 −0
diff --git a/internal/appconfig/dropin.go b/internal/appconfig/dropin.go| new file mode 100644 |
| index 000000000..f8af494fb |
| --- /dev/null |
| +++ b/internal/appconfig/dropin.go |
| @@ -0,0 +1,172 @@ |
| +// The drop-in a stamped route's unit reads its config through (spec §3.2): |
| +// <root>/<unit>.d/zz-carlos-env.conf, marker-owned, carrying a reset and |
| +// ONE EnvironmentFile line; and beside it carlos-env.pending, the record |
| +// that the unit owes a daemon-reload and (while it is still owned) a |
| +// restart. Everything here is a pure file operation with no systemd in it, |
| +// so the pass's protocol (unitsync.go) is testable with a temp dir. |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +const ( |
| + // DropInFile sorts after override.conf and every conventionally named |
| + // drop-in; with the reset line that ordering is belt and braces. |
| + DropInFile = "zz-carlos-env.conf" |
| + // PendingFile is not a .conf on purpose: systemd ignores it. |
| + PendingFile = "carlos-env.pending" |
| + // dropInMarker is the first line of every drop-in this package writes, |
| + // and the ONLY evidence it accepts before replacing or removing one. |
| + dropInMarker = "# Managed by carlos: config delivery for the route naming this unit." |
| +) |
| + |
| +func dropInDir(root, unit string) string { return filepath.Join(root, unit+".d") } |
| + |
| +// renderDropIn: the reset line hands the unit's EnvironmentFile list to |
| +// carlos whole (spec §3.2, codex R2-8); the second line is the rendered |
| +// bundle, with the '-' prefix so an absent file is not a start failure. |
| +func renderDropIn(envDir, host string) []byte { |
| + return []byte(dropInMarker + "\n" + |
| + "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" + |
| + "[Service]\n" + |
| + "EnvironmentFile=\n" + |
| + "EnvironmentFile=-" + filepath.Join(envDir, host+".env") + "\n") |
| +} |
| + |
| +// ensureDropInDir creates <unit>.d if absent and refuses to use it if it |
| +// is not a plain directory: a symlinked drop-in directory would let a root |
| +// write land somewhere else (spec §3.2 rail; codex R1-2, R2-1). |
| +func ensureDropInDir(root, unit string) error { |
| + dir := dropInDir(root, unit) |
| + st, err := os.Lstat(dir) |
| + switch { |
| + case err == nil: |
| + if st.Mode()&os.ModeSymlink != 0 { |
| + return fmt.Errorf("%s is a symlink; refusing to write through it", dir) |
| + } |
| + if !st.IsDir() { |
| + return fmt.Errorf("%s exists and is not a directory", dir) |
| + } |
| + return nil |
| + case os.IsNotExist(err): |
| + return os.Mkdir(dir, 0o755) |
| + default: |
| + return err |
| + } |
| +} |
| + |
| +// dirIsPlain is ensureDropInDir's check without the mkdir, for readers. |
| +func dirIsPlain(dir string) bool { |
| + st, err := os.Lstat(dir) |
| + return err == nil && st.IsDir() && st.Mode()&os.ModeSymlink == 0 |
| +} |
| + |
| +func readDropIn(root, unit string) (cur []byte, exists, marked bool, err error) { |
| + cur, err = os.ReadFile(filepath.Join(dropInDir(root, unit), DropInFile)) |
| + if err != nil { |
| + if os.IsNotExist(err) { |
| + return nil, false, false, nil |
| + } |
| + return nil, false, false, err |
| + } |
| + first, _, _ := bytes.Cut(cur, []byte("\n")) |
| + return cur, true, string(first) == dropInMarker, nil |
| +} |
| + |
| +func writeDropIn(root, unit string, rendered []byte) error { |
| + path := filepath.Join(dropInDir(root, unit), DropInFile) |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o644); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func removeDropIn(root, unit string) error { |
| + err := os.Remove(filepath.Join(dropInDir(root, unit), DropInFile)) |
| + if err != nil && !os.IsNotExist(err) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func pendingPath(root, unit string) string { return filepath.Join(dropInDir(root, unit), PendingFile) } |
| + |
| +func writePending(root, unit string) error { |
| + return os.WriteFile(pendingPath(root, unit), []byte("reload and restart owed\n"), 0o644) |
| +} |
| + |
| +func removePending(root, unit string) error { |
| + err := os.Remove(pendingPath(root, unit)) |
| + if err != nil && !os.IsNotExist(err) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func hasPending(root, unit string) bool { |
| + _, err := os.Lstat(pendingPath(root, unit)) |
| + return err == nil |
| +} |
| + |
| +// unitDirs lists <unit> for every plain <unit>.d directory under root; |
| +// symlinked or non-directory entries are returned in skipped and never |
| +// entered (codex R2-1). |
| +func unitDirs(root string) (units, skipped []string) { |
| + ents, err := os.ReadDir(root) |
| + if err != nil { |
| + return nil, nil |
| + } |
| + for _, e := range ents { |
| + name := e.Name() |
| + if !strings.HasSuffix(name, ".service.d") { |
| + continue |
| + } |
| + unit := strings.TrimSuffix(name, ".d") |
| + if !dirIsPlain(filepath.Join(root, name)) { |
| + skipped = append(skipped, unit) |
| + continue |
| + } |
| + units = append(units, unit) |
| + } |
| + sort.Strings(units) |
| + sort.Strings(skipped) |
| + return units, skipped |
| +} |
| + |
| +// listMarkedDropIns: units whose plain <unit>.d holds a marker-bearing |
| +// drop-in. An unmarked file at the path is not carlos's and is not listed. |
| +func listMarkedDropIns(root string) (units, skipped []string) { |
| + dirs, skipped := unitDirs(root) |
| + for _, u := range dirs { |
| + if _, exists, marked, err := readDropIn(root, u); err == nil && exists && marked { |
| + units = append(units, u) |
| + } |
| + } |
| + return units, skipped |
| +} |
| + |
| +// listPending: units whose plain <unit>.d holds a pending marker. |
| +func listPending(root string) []string { |
| + dirs, _ := unitDirs(root) |
| + var out []string |
| + for _, u := range dirs { |
| + if hasPending(root, u) { |
| + out = append(out, u) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +var errNotOurs = errors.New("not carlos's file") |
diff --git a/internal/appconfig/dropin_test.go b/internal/appconfig/dropin_test.go| new file mode 100644 |
| index 000000000..297c529f4 |
| --- /dev/null |
| +++ b/internal/appconfig/dropin_test.go |
| @@ -0,0 +1,129 @@ |
| +package appconfig |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRenderDropInHasMarkerResetAndFile(t *testing.T) { |
| + got := string(renderDropIn("/etc/carlos/apps", "relay.example.com")) |
| + want := dropInMarker + "\n" + |
| + "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" + |
| + "[Service]\n" + |
| + "EnvironmentFile=\n" + |
| + "EnvironmentFile=-/etc/carlos/apps/relay.example.com.env\n" |
| + if got != want { |
| + t.Fatalf("renderDropIn =\n%s\nwant\n%s", got, want) |
| + } |
| +} |
| + |
| +func TestWriteReadAndMarkDropIn(t *testing.T) { |
| + root := t.TempDir() |
| + if err := ensureDropInDir(root, "relay.service"); err != nil { |
| + t.Fatalf("ensureDropInDir: %v", err) |
| + } |
| + if _, exists, _, err := readDropIn(root, "relay.service"); err != nil || exists { |
| + t.Fatalf("readDropIn before write: exists=%v err=%v", exists, err) |
| + } |
| + body := renderDropIn("/etc/carlos/apps", "relay.example.com") |
| + if err := writeDropIn(root, "relay.service", body); err != nil { |
| + t.Fatalf("writeDropIn: %v", err) |
| + } |
| + cur, exists, marked, err := readDropIn(root, "relay.service") |
| + if err != nil || !exists || !marked || string(cur) != string(body) { |
| + t.Fatalf("readDropIn after write: exists=%v marked=%v err=%v", exists, marked, err) |
| + } |
| + st, _ := os.Stat(filepath.Join(root, "relay.service.d", DropInFile)) |
| + if st.Mode().Perm() != 0o644 { |
| + t.Fatalf("drop-in mode = %o, want 0644", st.Mode().Perm()) |
| + } |
| + if hasPending(root, "relay.service") { |
| + t.Fatal("pending marker present before writePending") |
| + } |
| + if err := writePending(root, "relay.service"); err != nil { |
| + t.Fatalf("writePending: %v", err) |
| + } |
| + if !hasPending(root, "relay.service") { |
| + t.Fatal("pending marker absent after writePending") |
| + } |
| + if got := listPending(root); len(got) != 1 || got[0] != "relay.service" { |
| + t.Fatalf("listPending = %v", got) |
| + } |
| + if err := removePending(root, "relay.service"); err != nil || hasPending(root, "relay.service") { |
| + t.Fatalf("removePending: err=%v still pending=%v", err, hasPending(root, "relay.service")) |
| + } |
| + if err := removePending(root, "relay.service"); err != nil { |
| + t.Fatalf("removePending twice must be a no-op, got %v", err) |
| + } |
| +} |
| + |
| +func TestReadDropInSeesAnUnmarkedFile(t *testing.T) { |
| + root := t.TempDir() |
| + dir := filepath.Join(root, "relay.service.d") |
| + os.MkdirAll(dir, 0o755) |
| + os.WriteFile(filepath.Join(dir, DropInFile), []byte("[Service]\nEnvironmentFile=/x\n"), 0o644) |
| + _, exists, marked, err := readDropIn(root, "relay.service") |
| + if err != nil || !exists || marked { |
| + t.Fatalf("exists=%v marked=%v err=%v, want an existing unmarked file", exists, marked, err) |
| + } |
| + units, _ := listMarkedDropIns(root) |
| + if len(units) != 0 { |
| + t.Fatalf("listMarkedDropIns = %v, want none for an unmarked file", units) |
| + } |
| +} |
| + |
| +func TestEnsureDropInDirRefusesSymlinkAndFile(t *testing.T) { |
| + root := t.TempDir() |
| + elsewhere := t.TempDir() |
| + os.Symlink(elsewhere, filepath.Join(root, "linked.service.d")) |
| + if err := ensureDropInDir(root, "linked.service"); err == nil || !strings.Contains(err.Error(), "symlink") { |
| + t.Fatalf("ensureDropInDir(symlink) = %v, want a symlink refusal", err) |
| + } |
| + os.WriteFile(filepath.Join(root, "file.service.d"), []byte("x"), 0o644) |
| + if err := ensureDropInDir(root, "file.service"); err == nil { |
| + t.Fatal("ensureDropInDir(regular file) accepted") |
| + } |
| + if err := ensureDropInDir(root, "fresh.service"); err != nil { |
| + t.Fatalf("ensureDropInDir(fresh) = %v", err) |
| + } |
| + st, _ := os.Lstat(filepath.Join(root, "fresh.service.d")) |
| + if !st.IsDir() || st.Mode().Perm() != 0o755 { |
| + t.Fatalf("fresh dir mode = %v", st.Mode()) |
| + } |
| +} |
| + |
| +func TestListMarkedDropInsSkipsSymlinkedDirs(t *testing.T) { |
| + root := t.TempDir() |
| + elsewhere := t.TempDir() |
| + os.WriteFile(filepath.Join(elsewhere, DropInFile), renderDropIn("/e", "h"), 0o644) |
| + os.Symlink(elsewhere, filepath.Join(root, "retired.service.d")) |
| + os.MkdirAll(filepath.Join(root, "real.service.d"), 0o755) |
| + os.WriteFile(filepath.Join(root, "real.service.d", DropInFile), renderDropIn("/e", "h"), 0o644) |
| + units, skipped := listMarkedDropIns(root) |
| + if len(units) != 1 || units[0] != "real.service" { |
| + t.Fatalf("units = %v, want [real.service]", units) |
| + } |
| + if len(skipped) != 1 || skipped[0] != "retired.service" { |
| + t.Fatalf("skipped = %v, want [retired.service]", skipped) |
| + } |
| + if _, err := os.Stat(filepath.Join(elsewhere, DropInFile)); err != nil { |
| + t.Fatal("the file behind the symlink must be untouched") |
| + } |
| +} |
| + |
| +func TestRemoveDropIn(t *testing.T) { |
| + root := t.TempDir() |
| + ensureDropInDir(root, "relay.service") |
| + writeDropIn(root, "relay.service", renderDropIn("/e", "h")) |
| + if err := removeDropIn(root, "relay.service"); err != nil { |
| + t.Fatalf("removeDropIn: %v", err) |
| + } |
| + if _, exists, _, _ := readDropIn(root, "relay.service"); exists { |
| + t.Fatal("drop-in still present") |
| + } |
| + if _, err := os.Stat(filepath.Join(root, "relay.service.d")); err != nil { |
| + t.Fatal("the directory must never be removed") |
| + } |
| +} |