feat: host verbs — bootstrap, agent, adopt; tito svc-pdf ASCII fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+806
−7
infra/tito/svc-pdf.tf+1 −1internal/edge/edge.go+92 −5internal/host/agent.go+181 −0internal/host/bootstrap.go+191 −0internal/host/host_test.go+146 −0internal/host/units/carlos-adopt.service+37 −0internal/host/units/carlos-adopt.timer+14 −0internal/host/units/carlos-edge.service+58 −0internal/host/units/carlos-litestream.service+36 −0internal/host/units/carlos-restore-verify.service+26 −0internal/host/units/carlos-restore-verify.timer+12 −0main.go+12 −1
diff --git a/infra/tito/svc-pdf.tf b/infra/tito/svc-pdf.tf| index 72ba0ae77..7018001ed 100644 |
| --- a/infra/tito/svc-pdf.tf |
| +++ b/infra/tito/svc-pdf.tf |
| @@ -5,7 +5,7 @@ |
| resource "aws_security_group" "svc_pdf" { |
| name = "carlos-tito-svc-pdf" |
| - description = "Gotenberg — reachable from the fleet only" |
| + description = "Gotenberg, reachable from the fleet only" |
| ingress { |
| from_port = 443 |
| to_port = 443 |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index c5018c3de..707353997 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -17,6 +17,7 @@ package edge |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -34,6 +35,7 @@ import ( |
| "golang.org/x/crypto/acme/autocert" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // Activator is the hibernation seam (implemented by the activator package): |
| @@ -64,6 +66,15 @@ type Edge struct { |
| // because HostPolicy refuses to mint certs for unknown hosts (anything else |
| // would let strangers burn the ACME rate limit). The :80 path always works. |
| fallbackURL string |
| + // Static serving config (EnableStatic): the deployment store the site |
| + // tars are fetched from, the release pubkey channel pointers must verify |
| + // against, and the on-box cache the trees are extracted into. staticStore |
| + // nil = static serving not configured; static routes answer 404 (the |
| + // route can still exist and mint its cert before the config lands). |
| + staticStore release.Store |
| + staticPub ed25519.PublicKey |
| + staticCacheDir string |
| + staticRecheck time.Duration |
| } |
| // upstream is one served host: the handler for its backend, plus — when the |
| @@ -87,6 +98,38 @@ func New(reg *registry.Registry, act Activator) (*Edge, error) { |
| return e, nil |
| } |
| +// EnableStatic turns on the static serving class: routes with Kind=="static" |
| +// resolve their app's channel pointer against store (verified with pub) and |
| +// serve the extracted tree from cacheDir. Call Reload afterwards so existing |
| +// static routes pick up real handlers; without EnableStatic they 404. |
| +func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir string) { |
| + e.staticStore = store |
| + e.staticPub = pub |
| + e.staticCacheDir = cacheDir |
| + e.staticRecheck = staticRecheck |
| +} |
| + |
| +// SetStaticRecheck tunes how long a static route trusts a resolved channel |
| +// pointer before re-reading it (default 60s; 0 = every request). Call after |
| +// EnableStatic and before Reload; handlers built on the next reload use it. |
| +func (e *Edge) SetStaticRecheck(d time.Duration) { e.staticRecheck = d } |
| + |
| +// staticHandlerFor builds the handler for one static route: the real serving |
| +// class when EnableStatic has configured it, else the pre-config 404 (the |
| +// route exists — and its cert can be minted — before the bytes can flow). |
| +func (e *Edge) staticHandlerFor(rt registry.Route) http.Handler { |
| + if e.staticStore == nil || len(e.staticPub) == 0 || rt.App == "" { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Error(w, "static serving not configured", http.StatusNotFound) |
| + }) |
| + } |
| + h := newStaticHandler(rt, e.staticStore, e.staticPub, e.staticCacheDir) |
| + if ss, ok := h.(*staticSite); ok { |
| + ss.recheck = e.staticRecheck |
| + } |
| + return h |
| +} |
| + |
| // Reload rebuilds the host→backend map from the registry and swaps it in |
| // atomically. Safe to call while serving. |
| func (e *Edge) Reload() error { |
| @@ -113,7 +156,7 @@ func (e *Edge) Reload() error { |
| case t.Addr != "": |
| proxy = tcpProxy(t.Addr) |
| case t.Kind == "static": |
| - proxy = staticHandler(t) |
| + proxy = e.staticHandlerFor(t) |
| default: |
| continue // no backend to reach |
| } |
| @@ -309,7 +352,12 @@ func isRetryableDialErr(err error) bool { |
| // Run is the `carlos edge` verb: the shared front door for every instance, |
| // sibling service, and static site on the box. |
| -func Run(args []string) { |
| +func Run(args []string) { RunWithActivator(args, nil) } |
| + |
| +// RunWithActivator is Run with an injected hibernation activator — the seam |
| +// `carlos agent` (internal/host) uses to compose edge + activator in one |
| +// process. act may be nil: pure proxy, exactly Run's behavior. |
| +func RunWithActivator(args []string, act Activator) { |
| dataDir := envOr("CARLOS_DATA_DIR", "/data") |
| fs := flag.NewFlagSet("edge", flag.ExitOnError) |
| registryPath := fs.String("registry", filepath.Join(dataDir, "registry.db"), "registry SQLite path (default $CARLOS_DATA_DIR/registry.db)") |
| @@ -325,14 +373,20 @@ func Run(args []string) { |
| log.Fatalf("edge: open registry %s: %v", *registryPath, err) |
| } |
| - // No activator yet: `carlos edge` is the pure-proxy role; the hibernation |
| - // activator arrives with `carlos agent` (edge + activator in one process). |
| - e, err := New(reg, nil) |
| + // act is nil for `carlos edge` — the pure-proxy role; `carlos agent` |
| + // injects the hibernation activator (edge + activator in one process). |
| + e, err := New(reg, act) |
| if err != nil { |
| log.Fatalf("edge: load routes: %v", err) |
| } |
| e.fallbackURL = *fallbackURL |
| + // Static serving lights up when the environment carries a deployment |
| + // store and the release pubkey; otherwise static routes 404 until it does. |
| + if err := enableStaticFromEnv(e, dataDir); err != nil { |
| + log.Fatalf("edge: static serving: %v", err) |
| + } |
| + |
| go e.Watch(make(chan struct{})) // process-lifetime; never stopped |
| // Reload routes on SIGHUP too (an operator's explicit "now"). |
| @@ -441,6 +495,39 @@ func Run(args []string) { |
| _ = httpSrv.Shutdown(ctx) |
| } |
| +// enableStaticFromEnv configures static serving when the environment names a |
| +// deployment store (CARLOS_DEPLOYMENT_BUCKET for S3, else |
| +// CARLOS_DEPLOYMENT_DIR for a directory) and CARLOS_RELEASE_PUBKEY. It |
| +// deliberately duplicates the CLI's ten-line store-open helper — the edge |
| +// can't import package main. Missing config is not an error: static routes |
| +// simply keep answering 404, exactly the pre-A8 behavior. |
| +func enableStaticFromEnv(e *Edge, dataDir string) error { |
| + pubB64 := os.Getenv("CARLOS_RELEASE_PUBKEY") |
| + bucket := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") |
| + dir := os.Getenv("CARLOS_DEPLOYMENT_DIR") |
| + if pubB64 == "" || (bucket == "" && dir == "") { |
| + return nil |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return err |
| + } |
| + var store release.Store |
| + if bucket != "" { |
| + s, err := release.NewS3Store(context.Background(), bucket) |
| + if err != nil { |
| + return err |
| + } |
| + store = s |
| + } else { |
| + store = release.NewDirStore(dir) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(dataDir, "static")) |
| + // Rebuild the route table so static routes that loaded as 404 stubs in |
| + // New's startup reload get real handlers. |
| + return e.Reload() |
| +} |
| + |
| func envOr(key, fallback string) string { |
| if v := os.Getenv(key); v != "" { |
| return v |
diff --git a/internal/host/agent.go b/internal/host/agent.go| new file mode 100644 |
| index 000000000..a18848771 |
| --- /dev/null |
| +++ b/internal/host/agent.go |
| @@ -0,0 +1,181 @@ |
| +// The `carlos agent` verb — the single-box role: edge (TLS + host routing) + |
| +// hibernation activator + hourly release adoption composed in one process. |
| +// The composition is new here, but each part is a port: the edge from |
| +// Eleven's router.go, the activator from lchat activator.go, and the adopt |
| +// loop is release.Pass on the cadence of Eleven's updater timer. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "flag" |
| + "io" |
| + "log" |
| + "math/rand/v2" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// dataDir resolves the fleet data directory (CARLOS_DATA_DIR, default /data). |
| +func dataDir() string { |
| + if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| + return d |
| + } |
| + return "/data" |
| +} |
| + |
| +// RunAgent runs edge + activator + adoption in one process. Flags are exactly |
| +// edge.Run's (-registry, -dev, -http, -https, ...): the args pass through to |
| +// edge.RunWithActivator, which owns the serving loop and blocks until |
| +// SIGTERM/SIGINT. |
| +func RunAgent(args []string) { |
| + dd := dataDir() |
| + regPath := peekRegistryPath(args, dd) |
| + // A second handle on the same registry file the edge opens — the registry |
| + // is designed for concurrent handles (fresh-connection reads), and this |
| + // one feeds the activator's due-time index and the adopt loop. |
| + reg, err := registry.Open(regPath) |
| + if err != nil { |
| + log.Fatalf("agent: open registry %s: %v", regPath, err) |
| + } |
| + |
| + cfg, err := activator.ConfigFromEnv() |
| + if err != nil { |
| + log.Fatalf("agent: %v", err) |
| + } |
| + var act edge.Activator |
| + var svc *activator.Service |
| + if cfg != nil { |
| + svc = activator.New(*cfg, &activator.ExecBackend{ |
| + Cfg: *cfg, |
| + OptDir: release.OptDir(), |
| + DataDir: dd, |
| + }, reg) // the registry satisfies activator.DueStore directly |
| + svc.Start() |
| + act = svc |
| + log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| + } |
| + |
| + go adoptLoop(reg) |
| + |
| + // The same serving loop as `carlos edge`, with the activator injected. |
| + edge.RunWithActivator(args, act) |
| + |
| + // The edge has drained; take the children down too (they're this process's |
| + // children — orphaning them would strand their leases until TTL expiry). |
| + if svc != nil { |
| + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| + defer cancel() |
| + svc.Shutdown(ctx) |
| + } |
| +} |
| + |
| +// peekRegistryPath pre-parses the agent args for -registry (default |
| +// <dataDir>/registry.db) without disturbing them — edge.RunWithActivator does |
| +// the authoritative parse. The set mirrors edge.Run's flags so a full command |
| +// line parses cleanly; an unknown flag just ends the peek early (the edge's |
| +// own ExitOnError parse then reports it properly). |
| +func peekRegistryPath(args []string, dd string) string { |
| + fs := flag.NewFlagSet("agent", flag.ContinueOnError) |
| + fs.SetOutput(io.Discard) |
| + regPath := fs.String("registry", filepath.Join(dd, "registry.db"), "") |
| + fs.String("acme-cache", "", "") |
| + fs.String("https", "", "") |
| + fs.String("http", "", "") |
| + fs.String("fallback", "", "") |
| + fs.Bool("dev", false, "") |
| + _ = fs.Parse(args) |
| + return *regPath |
| +} |
| + |
| +// adoptLoop runs a release-adoption pass roughly hourly (plus jitter, so a |
| +// fleet never hits the store in the same second), forever. Missing deployment |
| +// config disables the loop with one log line — an agent with no release store |
| +// is a valid box (routes managed by hand, adopted by the timer unit, or not |
| +// versioned at all). |
| +func adoptLoop(reg *registry.Registry) { |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: adopt loop disabled: %v", err) |
| + return |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Printf("agent: adopt loop disabled: %v", err) |
| + return |
| + } |
| + optDir := release.OptDir() |
| + log.Printf("agent: adopt loop armed (hourly + jitter)") |
| + // First pass shortly after boot (Eleven's OnBootSec=5min, compressed — |
| + // the edge is already up by the time this fires), then hourly + jitter. |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = time.Hour + rand.N(10*time.Minute) |
| + if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil { |
| + log.Printf("agent: adopt pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| +// restartRoute cycles a route's process onto its freshly-repointed live |
| +// symlink. Only systemd-managed routes need anything done here: hibernating |
| +// hosts never reach this (release.Pass gives them the symlink-only path), and |
| +// an awake activator child is converged by the sweep's ConvergeDecision — so |
| +// for those returning nil is the signal. For unit-managed hosts, try-restart |
| +// cycles the unit iff it's running, best-effort: a missing or stopped unit is |
| +// not this pass's problem, and must not poison the build for other hosts. |
| +func restartRoute(host string) error { |
| + out, err := exec.Command("systemctl", "try-restart", "carlos-app@"+host+".service").CombinedOutput() |
| + if err != nil { |
| + log.Printf("adopt: try-restart carlos-app@%s: %v: %s (continuing)", host, err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| + |
| +// openDeployStore opens the release store the box adopts from: |
| +// CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (a directory — |
| +// tests and single-box no-AWS deployments). Mirrors cli.go's openStore, but |
| +// returns the error instead of dying: the agent degrades, the CLI exits. |
| +func openDeployStore(ctx context.Context) (release.Store, error) { |
| + if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| + return release.NewS3Store(ctx, b) |
| + } |
| + if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| + return release.NewDirStore(d), nil |
| + } |
| + return nil, errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") |
| +} |
| + |
| +// RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the |
| +// carlos-adopt.timer unit's ExecStart, and an operator's "converge now". |
| +func RunAdopt(args []string) { |
| + fs := flag.NewFlagSet("adopt", flag.ExitOnError) |
| + regPath := fs.String("registry", filepath.Join(dataDir(), "registry.db"), "registry database") |
| + _ = fs.Parse(args) |
| + reg, err := registry.Open(*regPath) |
| + if err != nil { |
| + log.Fatalf("adopt: open registry %s: %v", *regPath, err) |
| + } |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute); err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| +} |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| new file mode 100644 |
| index 000000000..d886ee8a8 |
| --- /dev/null |
| +++ b/internal/host/bootstrap.go |
| @@ -0,0 +1,191 @@ |
| +// Package host is the box side of the platform: `carlos bootstrap` provisions |
| +// a fresh machine (units, dirs, service user), `carlos agent` runs the |
| +// single-box role (edge + activator + release adoption in one process), and |
| +// `carlos adopt` is one immediate adoption pass for the timer unit. |
| +// |
| +// Bootstrap ported from Eleven's bootstrap.go (elevenmessenger/messenger: |
| +// bootstrap.go), radically slimmed: carlos has one long-running unit (the |
| +// edge/agent) plus timers — no sibling-service zoo, no collectors, no SSH |
| +// hardening verbs (those stay app-level concerns). |
| +package host |
| + |
| +import ( |
| + "embed" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// The systemd units ship inside the binary (source of truth: |
| +// internal/host/units), so `carlos bootstrap` can provision a fresh box with |
| +// no separate files to copy. Ported from Eleven's deploy/systemd (renamed, |
| +// same hardening) and titogo's deploy/systemd (litestream + restore-verify). |
| +// |
| +//go:embed units/*.service units/*.timer |
| +var unitsFS embed.FS |
| + |
| +// serviceUser is the unprivileged account the edge unit runs as; bootstrap |
| +// creates it and hands it the data + runtime dirs. The edge keeps |
| +// CAP_NET_BIND_SERVICE to still bind :80/:443. |
| +const serviceUser = "carlos" |
| + |
| +// tmpfilesConf keeps /run/carlos across reboots (/run is tmpfs). Owned by the |
| +// service user so the non-root units can bind their sockets there. |
| +const tmpfilesConf = "d /run/carlos 0750 " + serviceUser + " " + serviceUser + " -\n" |
| + |
| +// enabledUnits are what a live bootstrap turns on: the always-on front door |
| +// and the hourly adoption timer. The litestream + restore-verify units install |
| +// everywhere but stay inert until an operator stages /data/litestream.env and |
| +// generates /etc/litestream.yml (`carlos ops litestream`) — a box with no |
| +// replication bucket is unaffected. |
| +var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer"} |
| + |
| +// RunBootstrap is the `carlos bootstrap` verb. Idempotent — safe to re-run. |
| +func RunBootstrap(args []string) { |
| + fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) |
| + root := fs.String("root", "/", "install root; a staging dir writes files only (no user creation, no systemctl)") |
| + _ = fs.Parse(args) |
| + if err := bootstrap(*root); err != nil { |
| + log.Fatalf("bootstrap: %v", err) |
| + } |
| +} |
| + |
| +// bootstrap provisions (or re-converges) a box under root: units into |
| +// <root>/etc/systemd/system, the standard dirs, the tmpfiles.d entry, and — |
| +// only when root is "/" — the service user, ownership, and systemctl |
| +// daemon-reload + enable. A non-"/" root is the staging/test path: files |
| +// only, with the privileged steps printed instead of run. |
| +func bootstrap(root string) error { |
| + live := filepath.Clean(root) == "/" |
| + unitDir := filepath.Join(root, "etc", "systemd", "system") |
| + dataDir := filepath.Join(root, "data") |
| + runDir := filepath.Join(root, "run", "carlos") |
| + |
| + type dir struct { |
| + path string |
| + mode os.FileMode |
| + } |
| + dirs := []dir{ |
| + {dataDir, 0o750}, |
| + {filepath.Join(dataDir, "acme"), 0o750}, |
| + {runDir, 0o750}, |
| + // The version store + per-host live symlinks (release/versions.go). |
| + // World-readable/executable so sandboxed instance units can exec their |
| + // binary; only root (the adopt pass) writes here. |
| + {filepath.Join(root, "opt", "carlos", "versions"), 0o755}, |
| + {filepath.Join(root, "opt", "carlos", "live"), 0o755}, |
| + // Host-wide config (host.env, 0600 root-owned, staged by the operator). |
| + {filepath.Join(root, "etc", "carlos"), 0o755}, |
| + {unitDir, 0o755}, |
| + {filepath.Join(root, "etc", "tmpfiles.d"), 0o755}, |
| + } |
| + for _, d := range dirs { |
| + if err := os.MkdirAll(d.path, d.mode); err != nil { |
| + return fmt.Errorf("mkdir %s: %v", d.path, err) |
| + } |
| + // MkdirAll's mode is masked by umask, and a pre-existing dir keeps its |
| + // old mode; force it so re-runs converge (Eleven's staging lesson). |
| + if err := os.Chmod(d.path, d.mode); err != nil { |
| + return fmt.Errorf("chmod %s: %v", d.path, err) |
| + } |
| + } |
| + |
| + names, err := unitNames() |
| + if err != nil { |
| + return err |
| + } |
| + for _, name := range names { |
| + content, err := unitsFS.ReadFile("units/" + name) |
| + if err != nil { |
| + return fmt.Errorf("read embedded unit %s: %v", name, err) |
| + } |
| + path := filepath.Join(unitDir, name) |
| + if err := os.WriteFile(path, content, 0o644); err != nil { |
| + return fmt.Errorf("write %s: %v", path, err) |
| + } |
| + fmt.Printf(" wrote %s\n", path) |
| + } |
| + |
| + tmpfilesPath := filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf") |
| + if err := os.WriteFile(tmpfilesPath, []byte(tmpfilesConf), 0o644); err != nil { |
| + return fmt.Errorf("write %s: %v", tmpfilesPath, err) |
| + } |
| + fmt.Printf(" wrote %s\n", tmpfilesPath) |
| + |
| + if !live { |
| + fmt.Printf("bootstrap: staged under %s — on a real box (-root /) it would also:\n", root) |
| + fmt.Printf(" useradd --system --user-group --no-create-home --shell /usr/sbin/nologin %s\n", serviceUser) |
| + fmt.Printf(" chown -R %s:%s /data /run/carlos\n", serviceUser, serviceUser) |
| + fmt.Printf(" systemctl daemon-reload && systemctl enable %s\n", strings.Join(enabledUnits, " ")) |
| + return nil |
| + } |
| + |
| + if err := ensureServiceUser(); err != nil { |
| + return err |
| + } |
| + // The units run as serviceUser, so it must own where they read/write |
| + // (recursive, so it also fixes ownership of any data already present). |
| + for _, p := range []string{dataDir, runDir} { |
| + if err := chownToService(p); err != nil { |
| + return err |
| + } |
| + } |
| + if err := systemctl("daemon-reload"); err != nil { |
| + return err |
| + } |
| + if err := systemctl(append([]string{"enable"}, enabledUnits...)...); err != nil { |
| + return err |
| + } |
| + fmt.Printf("bootstrap: units installed, %s enabled\n", strings.Join(enabledUnits, " + ")) |
| + fmt.Println(" next: copy the carlos binary to /opt/carlos/carlos, stage /etc/carlos/host.env, then: systemctl start carlos-edge") |
| + return nil |
| +} |
| + |
| +// unitNames lists every embedded unit, sorted (embed.FS ReadDir order). |
| +func unitNames() ([]string, error) { |
| + entries, err := unitsFS.ReadDir("units") |
| + if err != nil { |
| + return nil, fmt.Errorf("read embedded units: %v", err) |
| + } |
| + names := make([]string, 0, len(entries)) |
| + for _, e := range entries { |
| + names = append(names, e.Name()) |
| + } |
| + return names, nil |
| +} |
| + |
| +// ensureServiceUser creates the locked, home-less system user the units run |
| +// as, idempotently. |
| +func ensureServiceUser() error { |
| + if _, err := user.Lookup(serviceUser); err == nil { |
| + return nil |
| + } |
| + out, err := exec.Command("useradd", "--system", "--user-group", |
| + "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("create user %s: %v: %s", serviceUser, err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| + |
| +// chownToService recursively gives a path to the service user. |
| +func chownToService(path string) error { |
| + out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("chown %s: %v: %s", path, err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| + |
| +func systemctl(args ...string) error { |
| + out, err := exec.Command("systemctl", args...).CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| new file mode 100644 |
| index 000000000..f2a27ee37 |
| --- /dev/null |
| +++ b/internal/host/host_test.go |
| @@ -0,0 +1,146 @@ |
| +package host |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// expectedUnits is the contract for what bootstrap installs — a new unit file |
| +// must be added here (and a removed one taken out) or TestUnitsEmbedComplete |
| +// fails, so the embed directive and the fleet's unit set can't drift apart. |
| +var expectedUnits = []string{ |
| + "carlos-adopt.service", |
| + "carlos-adopt.timer", |
| + "carlos-edge.service", |
| + "carlos-litestream.service", |
| + "carlos-restore-verify.service", |
| + "carlos-restore-verify.timer", |
| +} |
| + |
| +// requireUnixSockets skips a test when the sandbox forbids unix-socket |
| +// creation (probe: net.Listen("unix") in a temp dir). None of the current |
| +// tests need one; the helper is here for the host tests that will (agent |
| +// serving loops), matching the pattern used across the repo. |
| +func requireUnixSockets(t *testing.T) { |
| + t.Helper() |
| + l, err := net.Listen("unix", filepath.Join(t.TempDir(), "probe.sock")) |
| + if err != nil { |
| + if strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("sandbox forbids unix sockets: %v", err) |
| + } |
| + t.Fatalf("unix socket probe: %v", err) |
| + } |
| + l.Close() |
| +} |
| + |
| +func TestUnitsEmbedComplete(t *testing.T) { |
| + names, err := unitNames() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := map[string]bool{} |
| + for _, n := range names { |
| + got[n] = true |
| + } |
| + for _, want := range expectedUnits { |
| + if !got[want] { |
| + t.Errorf("unit %s missing from the embed FS", want) |
| + } |
| + } |
| + if len(names) != len(expectedUnits) { |
| + t.Errorf("embed FS has %d units %v, expected list has %d — keep them in step", len(names), names, len(expectedUnits)) |
| + } |
| +} |
| + |
| +func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| + root := t.TempDir() |
| + if err := bootstrap(root); err != nil { |
| + t.Fatalf("bootstrap: %v", err) |
| + } |
| + |
| + unitDir := filepath.Join(root, "etc", "systemd", "system") |
| + for _, name := range expectedUnits { |
| + b, err := os.ReadFile(filepath.Join(unitDir, name)) |
| + if err != nil { |
| + t.Errorf("unit not installed: %v", err) |
| + continue |
| + } |
| + content := string(b) |
| + // Only the edge binds privileged ports; every other unit must not |
| + // carry the capability. |
| + if name == "carlos-edge.service" { |
| + if !strings.Contains(content, "AmbientCapabilities=CAP_NET_BIND_SERVICE") { |
| + t.Errorf("%s: missing AmbientCapabilities=CAP_NET_BIND_SERVICE", name) |
| + } |
| + } else if strings.Contains(content, "CAP_NET_BIND_SERVICE") { |
| + t.Errorf("%s: must not carry CAP_NET_BIND_SERVICE", name) |
| + } |
| + } |
| + |
| + // The edge unit is the agent role. |
| + edge, err := os.ReadFile(filepath.Join(unitDir, "carlos-edge.service")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, want := range []string{ |
| + "ExecStart=/opt/carlos/carlos agent", |
| + "EnvironmentFile=-/etc/carlos/host.env", |
| + "ProtectSystem=strict", |
| + "ReadWritePaths=/data /run/carlos", |
| + "NoNewPrivileges=yes", |
| + "MemoryDenyWriteExecute=yes", |
| + "SystemCallFilter=@system-service", |
| + "User=carlos", |
| + "Restart=always", |
| + } { |
| + if !strings.Contains(string(edge), want) { |
| + t.Errorf("carlos-edge.service: missing %q", want) |
| + } |
| + } |
| + |
| + // The standard dirs. |
| + for _, d := range []string{ |
| + filepath.Join(root, "data"), |
| + filepath.Join(root, "opt", "carlos", "versions"), |
| + filepath.Join(root, "opt", "carlos", "live"), |
| + filepath.Join(root, "etc", "carlos"), |
| + filepath.Join(root, "run", "carlos"), |
| + } { |
| + fi, err := os.Stat(d) |
| + if err != nil { |
| + t.Errorf("dir not created: %v", err) |
| + continue |
| + } |
| + if !fi.IsDir() { |
| + t.Errorf("%s: not a directory", d) |
| + } |
| + } |
| + |
| + // The tmpfiles.d entry that keeps /run/carlos across reboots. |
| + tf, err := os.ReadFile(filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf")) |
| + if err != nil { |
| + t.Fatalf("tmpfiles conf: %v", err) |
| + } |
| + if !strings.Contains(string(tf), "/run/carlos") { |
| + t.Errorf("tmpfiles conf %q does not mention /run/carlos", tf) |
| + } |
| +} |
| + |
| +func TestBootstrapIdempotent(t *testing.T) { |
| + root := t.TempDir() |
| + if err := bootstrap(root); err != nil { |
| + t.Fatalf("first bootstrap: %v", err) |
| + } |
| + if err := bootstrap(root); err != nil { |
| + t.Fatalf("second bootstrap: %v", err) |
| + } |
| + // Still all present after the re-run. |
| + for _, name := range expectedUnits { |
| + if _, err := os.Stat(filepath.Join(root, "etc", "systemd", "system", name)); err != nil { |
| + t.Errorf("after re-run: %v", err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/units/carlos-adopt.service b/internal/host/units/carlos-adopt.service| new file mode 100644 |
| index 000000000..d4a78ca55 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-adopt.service |
| @@ -0,0 +1,37 @@ |
| +# One release-adoption pass (release.Pass): resolve channel pointers, verify |
| +# signatures, stage new versions under /opt/carlos/versions, repoint live |
| +# symlinks, cycle systemd-managed routes. Fired hourly by carlos-adopt.timer. |
| +# The agent also runs an in-process adopt loop, but that loop lives inside the |
| +# sandboxed edge unit (ProtectSystem=strict, no /opt write) — this root oneshot |
| +# is what actually stages on a hardened box. Shaped after Eleven's |
| +# deploy/systemd/lchat-update.service. |
| +[Unit] |
| +Description=carlos adopt (one release-adoption pass) |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +Type=oneshot |
| +# CARLOS_DEPLOYMENT_BUCKET/_DIR + CARLOS_RELEASE_PUBKEY live here. |
| +EnvironmentFile=-/etc/carlos/host.env |
| +ExecStart=/opt/carlos/carlos adopt |
| + |
| +# --- hardening --- |
| +# Runs as ROOT deliberately (like Eleven's updater): a pass writes the version |
| +# store (/opt/carlos) and restarts instance units via systemctl. NoNewPrivileges |
| +# and the syscall filter are omitted for the systemctl child; the rest applies. |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +TasksMax=64 |
diff --git a/internal/host/units/carlos-adopt.timer b/internal/host/units/carlos-adopt.timer| new file mode 100644 |
| index 000000000..0e1e458cd |
| --- /dev/null |
| +++ b/internal/host/units/carlos-adopt.timer |
| @@ -0,0 +1,14 @@ |
| +# Hourly release adoption — see carlos-adopt.service. Ported from Eleven's |
| +# deploy/systemd/lchat-update.timer. |
| +[Unit] |
| +Description=hourly carlos release-adoption pass |
| + |
| +[Timer] |
| +# A short boot delay so the edge is up before the first pass; the random |
| +# spread keeps a fleet of boxes from hitting the store in the same second. |
| +OnBootSec=5min |
| +OnUnitActiveSec=1h |
| +RandomizedDelaySec=10min |
| + |
| +[Install] |
| +WantedBy=timers.target |
diff --git a/internal/host/units/carlos-edge.service b/internal/host/units/carlos-edge.service| new file mode 100644 |
| index 000000000..aa41e536a |
| --- /dev/null |
| +++ b/internal/host/units/carlos-edge.service |
| @@ -0,0 +1,58 @@ |
| +# The single-box role: `carlos agent` = edge (TLS terminator + host router) + |
| +# hibernation activator + hourly release adoption in one process. Ported from |
| +# Eleven's deploy/systemd/lchat-router.service, renamed, same hardening. |
| +# Hibernating instances run as THIS unit's children (the activator); systemd- |
| +# managed instances get their own units. |
| +[Unit] |
| +Description=carlos edge (TLS terminator, host router, activator, release adoption) |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +# Optional host-wide config (0600, root-owned): CARLOS_HIBERNATE_*, |
| +# CARLOS_DEPLOYMENT_BUCKET/_DIR, CARLOS_RELEASE_PUBKEY, CARLOS_RESERVED_SLUGS... |
| +EnvironmentFile=-/etc/carlos/host.env |
| +ExecStart=/opt/carlos/carlos agent |
| +# `carlos add` writes are picked up by the registry fingerprint watcher within |
| +# seconds; SIGHUP is the operator's explicit "reload now". |
| +ExecReload=/bin/kill -HUP $MAINPID |
| +Restart=always |
| +RestartSec=1 |
| + |
| +# --- hardening (ported from lchat-router.service; see Eleven's |
| +# docs/instance-router.md § Hardening) --- |
| +User=carlos |
| +Group=carlos |
| +# The edge is the ONLY unit that binds privileged ports (:80 + :443); this cap |
| +# lets it do so as a non-root user. Instances bind unix sockets only. |
| +AmbientCapabilities=CAP_NET_BIND_SERVICE |
| +CapabilityBoundingSet=CAP_NET_BIND_SERVICE |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +ProtectHome=yes |
| +ReadWritePaths=/data /run/carlos |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +LockPersonality=yes |
| +# Pure Go — the binary never maps writable+executable memory. |
| +MemoryDenyWriteExecute=yes |
| +SystemCallFilter=@system-service |
| +SystemCallErrorNumber=EPERM |
| +SystemCallArchitectures=native |
| +# No MemoryMax: hibernating instances are this unit's children, so a cap here |
| +# would cap the whole set of awake instances at once. |
| +TasksMax=1024 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-litestream.service b/internal/host/units/carlos-litestream.service| new file mode 100644 |
| index 000000000..dbc923f8e |
| --- /dev/null |
| +++ b/internal/host/units/carlos-litestream.service |
| @@ -0,0 +1,36 @@ |
| +# Continuous off-box replication of the fleet's SQLite files under /data to S3 |
| +# via litestream (WAL shipping; the fleet is single-writer-per-DB by design, |
| +# which is exactly litestream's model). /etc/litestream.yml is GENERATED by |
| +# `carlos ops litestream` — never hand-edit it; re-run the verb after adding or |
| +# removing an instance. Hibernating routes are excluded from the config (their |
| +# DBs are replicated by the activator's per-host lease — two replicators on one |
| +# DB was Eleven's lesson). Credentials live in /data/litestream.env (0600). |
| +# Ported from titogo's deploy/systemd/titogo-litestream.service. |
| +[Unit] |
| +Description=carlos litestream (replicate /data SQLite files to S3) |
| +Documentation=https://litestream.io |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +ExecStart=/usr/local/bin/litestream replicate -config /etc/litestream.yml |
| +EnvironmentFile=/data/litestream.env |
| +User=carlos |
| +Group=carlos |
| +CapabilityBoundingSet= |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +# litestream checkpoints the databases it replicates, so it needs /data rw. |
| +ReadWritePaths=/data |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +CPUWeight=50 |
| +Restart=always |
| +RestartSec=2 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-restore-verify.service b/internal/host/units/carlos-restore-verify.service| new file mode 100644 |
| index 000000000..4a893062a |
| --- /dev/null |
| +++ b/internal/host/units/carlos-restore-verify.service |
| @@ -0,0 +1,26 @@ |
| +# Proves the S3 replicas actually restore: pulls every database named in |
| +# /etc/litestream.yml back from S3 into a private tmp, opens it, and runs an |
| +# integrity check. An un-restored backup is not a backup. Runs daily via |
| +# carlos-restore-verify.timer; a failure leaves this unit in state 'failed', |
| +# where fleet sweeps see it loudly. Ported from titogo's |
| +# deploy/systemd/titogo-restore-verify.service. |
| +[Unit] |
| +Description=carlos restore-verify (restore every S3 replica, integrity-check it) |
| +After=network-online.target carlos-litestream.service |
| +Wants=network-online.target |
| + |
| +[Service] |
| +Type=oneshot |
| +ExecStart=/opt/carlos/carlos ops restore-verify -config /etc/litestream.yml |
| +EnvironmentFile=/data/litestream.env |
| +User=carlos |
| +Group=carlos |
| +CapabilityBoundingSet= |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
diff --git a/internal/host/units/carlos-restore-verify.timer b/internal/host/units/carlos-restore-verify.timer| new file mode 100644 |
| index 000000000..cd8d830e5 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-restore-verify.timer |
| @@ -0,0 +1,12 @@ |
| +# Daily restore drill for the S3 replicas — see carlos-restore-verify.service. |
| +# Ported from titogo's deploy/systemd/titogo-restore-verify.timer. |
| +[Unit] |
| +Description=carlos restore-verify daily timer |
| + |
| +[Timer] |
| +OnCalendar=daily |
| +RandomizedDelaySec=30min |
| +Persistent=true |
| + |
| +[Install] |
| +WantedBy=timers.target |
diff --git a/main.go b/main.go| index 5d18852c9..aa180c470 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -8,8 +8,10 @@ import ( |
| "os" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/host" |
| "github.com/carlosframework/platform/internal/ops" |
| "github.com/carlosframework/platform/internal/version" |
| + "github.com/carlosframework/platform/internal/vet" |
| ) |
| func main() { |
| @@ -41,7 +43,15 @@ func main() { |
| runKeygen(args) |
| case "ops": |
| ops.Run(args) |
| - case "agent", "canary", "bootstrap", "vet": |
| + case "agent": |
| + host.RunAgent(args) |
| + case "bootstrap": |
| + host.RunBootstrap(args) |
| + case "adopt": |
| + host.RunAdopt(args) |
| + case "vet": |
| + vet.Run(args) |
| + case "canary": |
| log.Fatalf("not implemented: %s", cmd) |
| default: |
| usage() |
| @@ -58,6 +68,7 @@ commands: |
| agent run edge + activator + release adoption on this host |
| add | remove | instances manage routes |
| ship | promote | channels publish and promote releases |
| + adopt run one release-adoption pass (the hourly timer's verb) |
| canary ship this worktree to an ephemeral canary channel |
| bootstrap prepare a host (units, dirs, user) |
| ops litestream | restore-verify |