// 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 (
"github.com/carlosframework/platform/internal/cliflag"
"bytes"
"embed"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"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 units/*.socket units/*.path units/*.slice units/*.mount
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"
// socketsGroup is the blessed cross-user consumers group for exec-backed
// instance sockets (issue #103): a host app's own process (titogo's router
// was the finding) enrolled here may dial every instance socket on the box.
// A DEDICATED group, never serviceUser's own: group `carlos` is also the
// group on /data (0750), so enrolling a consumer there would grant
// group-read of every instance database and env file — every
// CARLOS_ADMIN_TOKEN on the box — when the grant's whole meaning is "may
// dial sockets".
const socketsGroup = "carlos-sockets"
// tmpfilesConf keeps /run/carlos across reboots (/run is tmpfs). Owned by the
// service user so the non-root units can bind their sockets there; group is
// the consumers group with SETGID so every socket an exec child binds inside
// inherits it — that inheritance is what lets the activator's post-health
// 0660 normalization (backend_exec.go) grant exactly the enrolled consumers
// and nobody else, with no app aware the group exists. The apps subdir holds
// the carlos-app@ tenant sockets: root systemd binds them (SocketUser=carlos,
// 0600), tenants never need path access (the fd is passed in) — it stays
// carlos:carlos, so consumer enrollment deliberately does not reach unit
// tenants. other=0 on both dirs keeps every unenrolled uid out.
const tmpfilesConf = "d /data/.parked 2770 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
"d /data/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
// The isolated-exec trees (design §3.1, §3.2). Each protected view is
// a nosymfollow bind of the dotted raw directory beside it (the mount
// units); tmpfiles creates both every boot because /run is tmpfs and
// because the mounts are ordered after it. The socket trees keep the
// consumers group and setgid, exactly like /run/carlos above, so an
// enrolled router can still dial a migrated instance (issue #103).
"d /data/.instances 0700 " + serviceUser + " " + serviceUser + " -\n" +
// /data/instances is a nosymfollow bind mount OF /data/.instances, so
// once the mount is active the two paths name ONE inode: a mode that
// differed between the two lines would flip back and forth on every
// tmpfiles run, since each write lands on the same inode the other
// line just set. Both are 0700 so re-runs converge instead of racing.
"d /data/instances 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" +
// The relay's one writable state dir (carlos-relay.service's
// ReadWritePaths). It is in bootstrap's dirs list too, but that list
// only ever runs against a FRESH box or a staging root — a self-update
// apply creates no directories, it re-runs tmpfiles. Without this line
// a box bootstrapped before the relay existed gets the unit, the
// binary and the master file and still fails to start with "Failed to
// set up mount namespacing: /data/relay: No such file or directory"
// (discussion #118, hit on the Ireland box 2026-09-07).
"d /data/relay 0750 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
// The readiness probe's own instance directory (design §8.5): a name
// no host can have, created every boot so the probe unit's bind
// sources exist before it starts.
"d /data/.instances/_probe 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/.instances/_probe 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/.logs 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/logs 0700 " + serviceUser + " " + serviceUser + " -\n" +
// The root instance-control channel's directory (design §5.2): root's,
// so no carlos process can create, rename or replace anything in it;
// group carlos with search so the agent can connect.
"d /run/carlos-control 0750 root " + serviceUser + " -\n" +
// The readiness probe's fixture (design §8.5, codex R11-1): a file
// root:carlos 0640 that an isolated instance must NOT be able to read.
// Written here, not by the control service, whose ProtectSystem=strict
// makes /etc read-only to it.
"f /etc/carlos/probe-secret 0640 root " + serviceUser + " - probe\n" +
// And one in the host's /dev/shm, which an isolated instance must NOT
// see: its own /dev/shm is a private tmpfs (#126). Without a file
// here, an empty listing inside the probe would prove nothing.
"f /dev/shm/carlos-probe 0644 root root - probe\n"
// journaldConfName is the journald drop-in every box gets (discussion #111).
//
// The name matches the one placed by hand on the flagship during the
// 2026-09-06 relief, and must keep matching it: drop-ins are read in name
// order and the LAST assignment wins, so a differently-named file would sit
// beside the hand-placed one and whichever sorted later would decide the cap.
// Same name means this file supersedes it on every box that has it, and the
// hand-placed copy can be deleted or left alone with the same result.
const journaldConfName = "50-cap.conf"
// journaldConf caps the journal. Uncapped, journald's default is 10% of the
// filesystem — 2 GB of a 20 GB root, of which the flagship had spent 1.2 GB
// by the time it filled. A cap is not a fix for a chatty box, it is the
// difference between a chatty box losing old log lines and a chatty box
// losing the ability to accept a push.
//
// Both halves are set explicitly. SystemMaxUse governs /var/log/journal, the
// persistent journal, which is the one that filled; RuntimeMaxUse governs
// /run/log/journal, which is tmpfs — so its default 10% is 10% of RAM, and on
// a small box a journal storm there is memory pressure rather than disk. One
// drop-in that leaves the volatile half at the default would have to be
// reasoned about again the first time that matters.
const journaldConf = "# Written by carlos bootstrap (discussion #111). Do not edit by hand:\n" +
"# the next bootstrap or self-update apply rewrites this file.\n" +
"[Journal]\n" +
"SystemMaxUse=256M\n" +
"RuntimeMaxUse=64M\n"
// enabledUnits are what a live bootstrap turns on: the always-on front door,
// the hourly adoption timer, and the path units that let the sandboxed
// agent ask a root pass for the halves it cannot do itself: carlos-adopt.path
// (config fast convergence, issue #44), carlos-stage.path (release staging
// — the agent sees a promotion in seconds but cannot write /opt),
// carlos-restart.path (a member's `carlos restart`, issue #113 — the agent
// bounces its own exec children but cannot cycle a unit) and
// carlos-system-update.path (the platform self-update relay, spec §4 — the
// agent can fetch and verify a candidate but cannot write /opt or /etc).
// carlos-system-recover.service is enabled (not armed by a path — it also
// runs Before=carlos-edge.service on every boot, and on the update oneshot's
// own OnFailure=) so recovery from an interrupted self-update never depends
// on a marker file surviving a crash. 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. carlos-relay.service (relay spec §3) is
// enabled AND started fleet-wide, unlike the edge: its
// ConditionPathExists=/etc/carlos/relay-masters makes the start a no-op on
// every box the operator has not armed with a region master.
//
// relayUnitName is spelled once because two places must agree on it: this
// list and the systemctl plan's start set.
const relayUnitName = "carlos-relay.service"
var enabledUnits = []string{"carlos-edge.service", relayUnitName, "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path",
"carlos-system-update.path", "carlos-system-recover.service",
// Region HA joining (spec 2 §5 step 5.3): root's half, on the agent's
// request. Idle on every box that is not joining.
"carlos-system-join.path",
// The isolated-exec mounts (design §3.2): the edge Requires= them.
"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount",
// The root instance-control channel (design §5.2); socket-activated, so
// only the socket is enabled and started.
"carlos-instance-control.socket"}
// inertUnits are installed everywhere and deliberately never armed by
// bootstrap or by a self-update apply: restore-verify until an operator
// stages litestream, hibernate-verify by long-standing choice.
var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verify.timer"}
// bootstrapPlan is what `bootstrap -root
-plan-json` prints: the
// arming list of THIS binary, so a self-update apply arms what the new
// binary's bootstrap would arm and never a list an older binary remembers.
//
// Armed is the other half of that promise, and it was missing until
// discussion #118. Enabled alone says which units to `enable`; WHICH of them
// also get a `start` was decided by the applier's own systemctlPlan — code
// from the OLD binary, which cannot know about a unit the new build just
// introduced. That is how the relay landed on the Ireland box enabled but
// never started on 2026-09-07: the applying build predated
// carlos-relay.service, so its systemctlPlan had no reason to start it.
// Carrying the list the TARGET build would start closes that gap for every
// future unit, not just the relay.
//
// Additive on purpose: a plan.json written by a build older than this field
// (a rollback stages carlos.prev's plan) carries no "armed" key, and arm()
// then decides locally exactly as it always did. The key is NOT omitempty:
// nil ("this build is too old to say") and empty ("start nothing") are
// different answers, and omitempty would collapse them into the same absent
// key, making "start nothing" impossible for a build to express.
type bootstrapPlan struct {
Enabled []string `json:"enabled"`
Inert []string `json:"inert"`
Armed []string `json:"armed"`
}
func currentBootstrapPlan() bootstrapPlan {
return bootstrapPlan{
Enabled: append([]string(nil), enabledUnits...),
Inert: append([]string(nil), inertUnits...),
Armed: armedUnits(enabledUnits),
}
}
// armedUnits is the "and start these too" rule in one place: the unit types
// whose enable-without-start is a silent no-op until the next boot, plus
// carlos-relay.service, whose ConditionPathExists makes starting it safe on a
// box with no region master. See systemctlPlan for the long version.
func armedUnits(units []string) []string {
var armed []string
for _, u := range units {
if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") ||
strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName {
armed = append(armed, u)
}
}
return armed
}
// systemctlPlan is the exact systemctl sequence a live bootstrap runs — and
// the same sequence a staging run (-root ) PRINTS instead of running, so
// the two can never drift into describing different behaviour.
//
// Timers and path units get an explicit `start` after the `enable`, and that
// line is the point of this function: `systemctl enable` writes a .wants
// symlink and stops there, which arms the unit at the NEXT boot and not a
// moment sooner. carlos-adopt.timer sat enabled-but-inactive on the flagship
// for weeks for exactly this reason — symlink present, nothing rebooted, no
// release ever adopted itself, every deploy silently needing a hand-run
// adoption pass. A path unit fails the same way and just as quietly: an
// unstarted carlos-adopt.path watches nothing, so the agent's config-sync
// marker lands in a file no one is reading. A re-run is harmless: starting an
// already-running timer or path unit is a no-op, which keeps bootstrap
// idempotent.
//
// Services are deliberately NOT started, with one exception. carlos-edge
// needs its binary at /opt/carlos/carlos and /etc/carlos/host.env staged
// first — neither of which bootstrap can do for the operator, which is why
// it ends by telling them to. Starting timers is safe in a way starting
// services is not: a timer that fires before the binary lands just fails one
// adoption pass and tries again on the next tick.
//
// carlos-relay.service is the exception (relay spec §3). It carries
// ConditionPathExists=/etc/carlos/relay-masters, so on a box the operator
// has not armed with a region master the start is a no-op systemd records as
// a failed condition, not a failure — which is what makes starting it safe
// fleet-wide. On a box that IS armed, starting it here is what saves a
// reboot.
//
// Socket and mount units are armed the same way (isolated-exec design
// §8.6, codex R1-14): an enabled-but-unstarted socket is a control channel
// nobody is listening on, and an enabled-but-unmounted bind is an instance
// tree with no protection, until the next reboot. Starting either is
// idempotent.
func systemctlPlan(units []string) [][]string {
return systemctlPlanArming(units, armedUnits(units))
}
// systemctlPlanArming is systemctlPlan with the start list supplied rather
// than derived — the shape a self-update apply needs, where the units come
// from the target build's plan.json and so does the decision about which of
// them to start (discussion #118).
func systemctlPlanArming(units, armed []string) [][]string {
plan := [][]string{
{"daemon-reload"},
append([]string{"enable"}, units...),
}
if len(armed) > 0 {
plan = append(plan, append([]string{"start"}, armed...))
}
return plan
}
// journaldReload is the one systemctl call that makes a freshly written
// journald drop-in take effect. Kept out of systemctlPlan on purpose: that
// plan is "enable these units and arm the triggers among them", and it is
// also what the self-update relay's arm step and ensureSystemUnits run over
// narrow unit lists — neither of which should be bouncing the box's logging
// daemon. Both places that install the drop-in call this explicitly instead.
var journaldReload = []string{"restart", "systemd-journald"}
// RunBootstrap is the `carlos bootstrap` verb. Idempotent — safe to re-run
// (which is also how a box enrolls a new socket consumer after the fact).
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)")
offcloud := fs.Bool("offcloud", false, "off-cloud box: require a staged 0600 credentials file instead of an EC2 instance profile")
planJSON := fs.Bool("plan-json", false, "with -root: print the arming plan as JSON on stdout instead of the human summary")
var consumers repeatableFlag
fs.Var(&consumers, "socket-consumer", "host-app user to enroll in the "+socketsGroup+" group (repeatable); members may dial every instance socket on the box")
fs.Usage = cliflag.Usage(fs, "")
_ = fs.Parse(args)
if *planJSON && filepath.Clean(*root) == "/" {
log.Fatal("bootstrap: -plan-json is for a staging root")
}
if *offcloud {
if err := bootstrapOffcloud(*root, consumers); err != nil {
log.Fatalf("bootstrap: %v", err)
}
return
}
if err := bootstrapWith(*root, consumers, *planJSON); err != nil {
log.Fatalf("bootstrap: %v", err)
}
}
// repeatableFlag collects every occurrence of a string flag.
type repeatableFlag []string
func (f *repeatableFlag) String() string { return strings.Join(*f, ",") }
func (f *repeatableFlag) Set(v string) error {
*f = append(*f, v)
return nil
}
// bootstrapOffcloud is bootstrap for a box with no EC2 instance profile
// (issue #53 point 4): identical structural work, plus the credential
// preconditions — a staged 0600 credentials file scoped (at IAM) to the
// deployment bucket + this host's own region bucket and nothing else, so
// wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no
// IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2).
//
// Credential shape and mode checks precede bootstrap mutations. A rejected
// credential precondition leaves the box untouched.
func bootstrapOffcloud(root string, consumers []string) error {
cred := filepath.Join(root, "etc", "carlos", "credentials")
fi, err := checkOffcloudCredentials(cred)
if err != nil {
return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err)
}
if fi.Mode().Perm() != 0o600 && fi.Mode().Perm() != 0o640 {
return fmt.Errorf("offcloud: %s must be mode 0600 or 0640, is %o", cred, fi.Mode().Perm())
}
if err := bootstrap(root, consumers); err != nil {
return err
}
fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n")
return nil
}
// bootstrap provisions (or re-converges) a box under root: units into
// /etc/systemd/system, the standard dirs, the tmpfiles.d entry, and —
// only when root is "/" — the service user, ownership, and the systemctl
// sequence (daemon-reload, enable, and arming the timers; see systemctlPlan).
// A non-"/" root is the staging/test path: files only, with the privileged
// steps printed instead of run.
func bootstrap(root string, consumers []string) error {
return bootstrapWith(root, consumers, false)
}
// bootstrapWith is bootstrap's body, plus the -plan-json staging path: when
// planJSON is set (staging root only — RunBootstrap refuses it with -root /),
// every "wrote"/"created" line below is redirected to stderr so the JSON
// plan currentBootstrapPlan() prints is alone on stdout.
func bootstrapWith(root string, consumers []string, planJSON bool) error {
live := filepath.Clean(root) == "/"
hostEnv := filepath.Join(root, "etc", "carlos", "host.env")
if live {
hostEnv = claimBootstrapHostEnv
}
claimSettings, settingsErr := readClaimUnitSettings(hostEnv)
if settingsErr != nil {
fmt.Fprintf(os.Stderr, "bootstrap: instance-control unit settings: %v\n", settingsErr)
claimSettings = claimUnitSettings{}
}
beforeControl, err := os.ReadFile(filepath.Join(root, "etc", "systemd", "system", claimControlUnit))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
// A host.env this build cannot parse says nothing about the claim
// (6b-N2). A control unit already rendered for an enabled box stays as
// it is, unrestarted, as adopt leaves it; a flag-off one is rendered as
// main renders it.
keepControl := settingsErr != nil && bytes.Contains(beforeControl, []byte(claimControlEnvironment))
var afterControl []byte
out := io.Writer(os.Stdout)
if planJSON {
out = os.Stderr
}
if live {
// Validate consumer users BEFORE any mutation (the offcloud
// precondition's discipline): a typo in -socket-consumer must leave
// the box untouched, not abort bootstrap halfway with units written
// but timers never armed.
for _, u := range consumers {
if _, err := user.Lookup(u); err != nil {
return fmt.Errorf("--socket-consumer %s: no such user (create the host app's user first): %w", u, err)
}
}
}
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},
// The relay's one writable state dir (carlos-relay.service's
// ReadWritePaths). Created on every box, armed or not, so the unit
// never fails for want of a directory the moment a master lands.
{filepath.Join(dataDir, "relay"), 0o750},
// Setgid: sockets bound inside inherit the dir's group — the
// consumers group, once a live run (or the tmpfiles line) sets it.
// See socketsGroup/tmpfilesConf.
{runDir, 0o750 | os.ModeSetgid},
// The carlos-app@ tenant sockets (see tmpfilesConf for the perms
// rationale).
{filepath.Join(runDir, "apps"), 0o750},
// The isolated-exec trees (design §3.1). The dotted names are the
// raw directories the mount units bind onto the undotted views;
// see tmpfilesConf for the modes and why both exist.
{filepath.Join(dataDir, ".instances"), 0o700},
// One inode once the bind is active (see tmpfilesConf): both this
// entry and the tmpfiles line for /data/instances must agree, or
// the mode flips on every tmpfiles run.
{filepath.Join(dataDir, "instances"), 0o700},
{filepath.Join(dataDir, "layout"), 0o700},
{filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid},
{filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid},
// The readiness probe's own instance directory (design §8.5): a
// name no host can have, created here so a staging root has it
// too (see tmpfilesConf for the modes and why both exist).
{filepath.Join(dataDir, ".instances", "_probe"), 0o700},
{filepath.Join(runDir, ".instances", "_probe"), 0o750 | os.ModeSetgid},
{filepath.Join(runDir, ".logs"), 0o700},
{filepath.Join(runDir, "logs"), 0o700},
// Root-owned on a live box (privilegedPlan chowns /run/carlos
// recursively to the service user; this one sits BESIDE it, not
// under it, precisely so that chown never reaches it).
{filepath.Join(root, "run", "carlos-control"), 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},
// Per-app env files for carlos-app@ tenants (systemd reads them as
// root before dropping to the unit's DynamicUser, so 0600 root works).
{filepath.Join(root, "etc", "carlos", "apps"), 0o755},
{unitDir, 0o755},
// The journal cap's drop-in dir. systemd creates it on demand for a
// hand-placed file, but nothing creates it on a fresh box, and the
// write below would fail ENOENT if this line were missing.
{filepath.Join(root, "etc", "systemd", "journald.conf.d"), 0o755},
{filepath.Join(root, "etc", "tmpfiles.d"), 0o755},
{filepath.Join(root, "etc", "sysusers.d"), 0o755},
}
for _, d := range dirs {
if err := bootstrapMakeDir(d.path, d.mode); err != nil {
return fmt.Errorf("mkdir/chmod %s: %v", d.path, err)
}
}
// The registry file, created here and by nothing else on a fresh box.
//
// It has to exist BEFORE the units below are armed, because four of them
// (carlos-adopt, -stage, -restart, -config-sync) run `carlos adopt` as
// root and open this same file — and on a fresh box adopt's .path and
// .timer can fire before the edge has ever started. Whoever creates the
// file owns it, and an edge running as serviceUser cannot write a
// root-owned registry: that is discussion #8, three region boxes left
// unstartable on 2026-08-31. Creating it here puts it inside the
// descriptor-based ownership walk that privilegedPlan runs, so it is
// serviceUser's before anything can race for it.
//
// An empty file is a valid empty SQLite database — the first opener
// (edge or adopt) runs the migrations into it. Deliberately NOT opened
// as a registry here: on a re-run over a live box this must not touch
// the schema of a database the running edge holds open, and "the file
// exists, owned by the right user" is the whole job.
//
// One constraint this puts on the deploy side: a STAGING run takes this
// branch too. `carlos bootstrap -root ` is how a live box refreshes
// its unit files (pinfra's update-platform-binary.sh stages into
// /tmp/carlos-unitstage), so a staging tree now contains a 0-byte
// data/registry.db. Harmless while that script copies only etc/ out of
// the tree — and destructive the day anything rsyncs the whole tree over
// /, because a 0-byte file would land on top of the live registry and
// empty it. The script lives in the private platform-infrastructure repo;
// whoever widens what it copies has to exclude data/.
regPath := filepath.Join(dataDir, "registry.db")
created, err := bootstrapCreateRegistry(regPath)
if err != nil {
return fmt.Errorf("create %s: %v", regPath, err)
}
if created {
fmt.Fprintf(out, " created %s\n", regPath)
}
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 name == claimControlUnit {
if keepControl {
afterControl = beforeControl
fmt.Fprintf(out, " kept %s (host.env cannot be parsed)\n", path)
continue
}
afterControl = claimUnitContent(name, content, claimSettings)
}
if err := os.WriteFile(path, claimUnitContent(name, content, claimSettings), 0o644); err != nil {
return fmt.Errorf("write %s: %v", path, err)
}
fmt.Fprintf(out, " wrote %s\n", path)
}
// On a live root the privileged plan runs BEFORE the tmpfiles conf is
// written: the conf's /run/carlos line names socketsGroup, and
// systemd-tmpfiles DROPS a line whose group doesn't resolve — so a
// bootstrap interrupted between "conf written" and "groupadd ran" would
// leave a box that looks healthy until its next reboot, when /run/carlos
// simply isn't created and carlos-edge (ReadWritePaths=/run/carlos, no
// "-" prefix) fails its namespace setup forever. Group first, then the
// conf that names it.
if live {
for _, args := range privilegedPlan(consumers) {
if err := runPrivileged(args); err != nil {
return err
}
}
}
// The sysusers.d snippet closes the same hole for confs that arrive
// WITHOUT a live bootstrap run: a staging root is how a live box
// refreshes its unit files (pinfra's update-platform-binary.sh), and a
// refresh that installs the staged etc/ tree ships the tmpfiles line to
// a box that never ran groupadd. systemd-sysusers.service is ordered
// Before=systemd-tmpfiles-setup.service, so the group exists by the time
// the tmpfiles line is parsed, however the conf got there.
sysusersPath := filepath.Join(root, "etc", "sysusers.d", "carlos.conf")
if err := os.WriteFile(sysusersPath, []byte("g "+socketsGroup+" -\n"), 0o644); err != nil {
return fmt.Errorf("write %s: %v", sysusersPath, err)
}
fmt.Fprintf(out, " wrote %s\n", sysusersPath)
// The journal cap (discussion #111). Written on every root, live or
// staging, because a staging root is how a live box refreshes its etc/
// tree — both the self-update apply (systemapply.go's installUnits) and
// pinfra's update-platform-binary.sh stage with `bootstrap -root `
// and copy named files out of it. The apply path names this one; the
// pinfra script copies only units, sysusers and tmpfiles today, so a box
// updated by hand gets the cap on its next self-update or bootstrap
// rather than on that script's next run.
journaldPath := filepath.Join(root, "etc", "systemd", "journald.conf.d", journaldConfName)
if err := os.WriteFile(journaldPath, []byte(journaldConf), 0o644); err != nil {
return fmt.Errorf("write %s: %v", journaldPath, err)
}
fmt.Fprintf(out, " wrote %s\n", journaldPath)
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.Fprintf(out, " wrote %s\n", tmpfilesPath)
if !live {
if planJSON {
return json.NewEncoder(os.Stdout).Encode(currentBootstrapPlan())
}
fmt.Printf("bootstrap: staged under %s — on a real box (--root /) it would also:\n", root)
// Printed, never run: a staging root is how a LIVE box refreshes its
// unit files (pinfra's update-platform-binary.sh stages into
// /tmp/carlos-unitstage), so arming anything from here would reach out
// of the temp root and touch the running system from a command whose
// entire contract is "write files, change nothing".
for _, args := range privilegedPlan(consumers) {
fmt.Printf(" %s\n", strings.Join(args, " "))
}
for _, args := range systemctlPlan(enabledUnits) {
fmt.Printf(" systemctl %s\n", strings.Join(args, " "))
}
fmt.Printf(" systemctl %s\n", strings.Join(journaldReload, " "))
return nil
}
// Repair before arming any root pass that may load these credentials.
if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
return err
}
for _, args := range systemctlPlan(enabledUnits) {
if err := systemctl(args...); err != nil {
return err
}
}
if err := restartClaimControl(filepath.Join(unitDir, claimControlUnit), beforeControl, afterControl, func(args ...string) (string, error) { return "", systemctl(args...) }); err != nil {
return err
}
// A journald drop-in is inert until journald re-reads its configuration,
// and journald re-reads it only on restart — so a bootstrap that writes
// the cap and stops leaves a box whose journal is capped from its next
// reboot, which on a long-lived box is never. Non-fatal: the cap is a
// safety margin, and refusing to finish bootstrap over it would be a
// worse outcome than an uncapped journal on one box.
if err := systemctl(journaldReload...); err != nil {
fmt.Printf("bootstrap: could not restart systemd-journald (%v) — the journal cap in %s takes effect at the next restart\n", err, journaldConfName)
}
fmt.Printf("bootstrap: units installed, %s enabled (timers and path units armed, not just symlinked)\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
}
// privilegedPlan is the exact non-systemctl privileged sequence a live
// bootstrap dispatches — and the same sequence a staging run PRINTS instead of
// running (systemctlPlan's discipline, extended to the user/group half when
// issue #103 added the consumers group): the two can never drift into
// describing different behaviour.
//
// The sequence: service user, consumers group, ownership. Every chown
// converges TOWARD the target state, never through an intermediate that
// undoes a previous run's work: /run/carlos is recursively
// serviceUser:socketsGroup. Unsafe entries, including live sockets, are
// logged and skipped; their creating units establish socket ownership.
// The apps/ directory (unit-tenant sockets, no path consumers) is then
// put back to serviceUser:serviceUser. The chmod re-asserts the setgid bit
// the tmpfiles line promises; consumers enroll last, one usermod each
// (`-aG` is append — an existing member is a no-op, so re-runs converge).
// It deliberately does NOT touch group `carlos` memberships: an interim
// grant made there (the tito workaround this replaces) is the operator's to
// revoke, per the deployment notes in the #103 spec.
func privilegedPlan(consumers []string) [][]string {
plan := [][]string{
{"useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser},
{"groupadd", "--system", socketsGroup},
{"chown", "-R", serviceUser + ":" + serviceUser, "/data"},
{"chown", "-R", serviceUser + ":" + socketsGroup, "/run/carlos"},
{"chown", "-R", serviceUser + ":" + serviceUser, "/run/carlos/apps"},
{"chmod", "2750", "/run/carlos"},
}
for _, u := range consumers {
plan = append(plan, []string{"usermod", "-aG", socketsGroup, u})
}
return plan
}
// runPrivileged executes one privilegedPlan step, adding the existence
// guards that keep the raw commands idempotent: useradd and groupadd exit
// non-zero when their subject already exists, and "already provisioned" is
// bootstrap's normal re-run state, not an error. Everything else in the plan
// is idempotent. Ownership and mode steps use descriptor helpers; the
// command text is retained for staging output and is never sent to chown.
func runPrivileged(args []string) error {
if len(args) == 4 && args[0] == "chown" && args[1] == "-R" {
owner := strings.SplitN(args[2], ":", 2)
if len(owner) != 2 {
return fmt.Errorf("invalid ownership %q", args[2])
}
uid, gid, err := bootstrapOwner(owner[0], owner[1])
if err != nil {
return err
}
return bootstrapChownTree(args[3], uid, gid)
}
if len(args) == 3 && args[0] == "chmod" && args[1] == "2750" {
return bootstrapMakeDir(args[2], 0o750|os.ModeSetgid)
}
// The subject is derived from the command line itself (both commands
// take it as their final argument), never from a package constant — a
// guard keyed to a constant would silently skip any SECOND useradd or
// groupadd a later plan revision adds.
switch subject := args[len(args)-1]; args[0] {
case "useradd":
if _, err := user.Lookup(subject); err == nil {
return nil
}
case "groupadd":
if _, err := user.LookupGroup(subject); err == nil {
return nil
}
}
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func bootstrapOwner(owner, group string) (int, int, error) {
u, err := user.Lookup(owner)
if err != nil {
return 0, 0, err
}
g, err := user.LookupGroup(group)
if err != nil {
return 0, 0, err
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return 0, 0, err
}
gid, err := strconv.Atoi(g.Gid)
return uid, gid, err
}
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
}