carlos / platform Public

feat: host verbs — bootstrap, agent, adopt; tito svc-pdf ASCII fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bf2f8b69a04d51b81afae4f121750d633a190b87 parent 2ed6993
12 files changed, +806 −7
  • infra/tito/svc-pdf.tf +1 −1
  • internal/edge/edge.go +92 −5
  • internal/host/agent.go +181 −0
  • internal/host/bootstrap.go +191 −0
  • internal/host/host_test.go +146 −0
  • internal/host/units/carlos-adopt.service +37 −0
  • internal/host/units/carlos-adopt.timer +14 −0
  • internal/host/units/carlos-edge.service +58 −0
  • internal/host/units/carlos-litestream.service +36 −0
  • internal/host/units/carlos-restore-verify.service +26 −0
  • internal/host/units/carlos-restore-verify.timer +12 −0
  • main.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