// Package instancectl is the root instance-control channel of the // isolated-exec design (§5) and the names everything on both sides of it // agrees on: unit and slice names keyed by the bounded instance identifier, // the launch file the trampoline reads (§6.2), the advisory exit record // (§6.5) and the probe's verdict (§8.5). // // The names live in one package because three programs and two services // build them: the agent (plan 3) composes launch files and requests, the // control service builds unit names from validated hosts, and the // in-namespace programs read files keyed by the same identifier. A // disagreement between any two of them would be a unit that never starts // or a record nobody finds. package instancectl import ( "fmt" "path/filepath" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/provision" ) // Role is which of the four template units a request or a file is about. type Role string const ( RoleRun Role = "run" RoleSeed Role = "seed" RoleSidecar Role = "sidecar" // RoleCmd is one `carlos run` command (run spec §5.4). "run" is the // serving role already, so the command's role is cmd. RoleCmd Role = "cmd" ) // ParseRole accepts exactly the four literals. func ParseRole(s string) (Role, bool) { switch Role(s) { case RoleRun, RoleSeed, RoleSidecar, RoleCmd: return Role(s), true } return "", false } // Escape is systemd-escape(1)'s rule for a unit instance name: "/" becomes // "-", a leading "." becomes "\x2e", and every byte outside [a-zA-Z0-9:_.] // becomes "\x" plus two lowercase hex digits. For the identifiers this // platform produces (hosts are [a-z0-9.-], a hashed identifier adds a // leading underscore) that means "-" becomes "\x2d" and nothing else // changes; the general rule is kept so an unexpected byte can never yield // a name systemd refuses to load. func Escape(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { c := s[i] switch { case c == '/': b.WriteByte('-') case i == 0 && c == '.': b.WriteString(`\x2e`) case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.': b.WriteByte(c) default: fmt.Fprintf(&b, `\x%02x`, c) } } return b.String() } // The four template prefixes. A unit under any of them is the platform's // to start, stop and sweep; nothing else is (design §5.3). const ( UnitPrefixRun = "carlos-instance@" UnitPrefixSeed = "carlos-instance-seed@" UnitPrefixSidecar = "carlos-instance-sidecar@" UnitPrefixCmd = "carlos-instance-cmd@" ) // CmdExeApp and CmdExeLive are the cmd template's executable mount // sources under /, apart from the app and live the // other original templates bind (merge ruling M4). They are bound at the // same places in the namespace, /opt/carlos/app and /opt/carlos/live. const ( CmdExeApp = "cmd-app" CmdExeLive = "cmd-live" ) func prefixFor(role Role) string { switch role { case RoleSeed: return UnitPrefixSeed case RoleSidecar: return UnitPrefixSidecar case RoleCmd: return UnitPrefixCmd } return UnitPrefixRun } // UnitName is the template instance for a role and a host: // carlos-instance[-seed|-sidecar|-cmd]@.service. func UnitName(role Role, host string) string { return prefixFor(role) + Escape(provision.InstanceID(host)) + ".service" } // SliceName is the per-host slice every role of a host runs in (design // §4.3): carlos-instances-.slice. func SliceName(host string) string { return "carlos-instances-" + Escape(provision.InstanceID(host)) + ".slice" } // UnitPrefixGen is the generation template (zero-downtime deploys spec // §7.6): one instance per spawn of a host's run role. The four templates // above stay for every box and binary still running them. const UnitPrefixGen = "carlos-instance-gen@" // GenInstance is the generation template's unescaped instance string, // ":" (%I inside the unit). No instance ID holds ':'. func GenInstance(host string, n uint64) string { return provision.InstanceID(host) + ":" + strconv.FormatUint(n, 10) } // ParseGenInstance splits ":", the trampoline's and the exit // writer's half of §7.6. n is canonical decimal and never 0. iid is // non-empty and holds no ':', '/' or NUL, and is neither "." nor "..". func ParseGenInstance(s string) (iid string, n uint64, ok bool) { i := strings.LastIndexByte(s, ':') if i <= 0 { return "", 0, false } iid, num := s[:i], s[i+1:] n, err := strconv.ParseUint(num, 10, 64) if err != nil || n == 0 || strconv.FormatUint(n, 10) != num { return "", 0, false } if strings.ContainsAny(iid, ":/\x00") || iid == "." || iid == ".." { return "", 0, false } return iid, n, true } // GenUnitName is carlos-instance-gen@.service. func GenUnitName(host string, n uint64) string { return GenUnitNameOf(provision.InstanceID(host), n) } // GenUnitNameOf is GenUnitName keyed by the instance ID, for a name read // back from a generation's files (ParseGenKey), where no host is known. func GenUnitNameOf(iid string, n uint64) string { return UnitPrefixGen + Escape(iid+":"+strconv.FormatUint(n, 10)) + ".service" } // ParseGenUnitName recognises a generation unit and returns its ESCAPED // instance ID (systemd reports names escaped) and its generation. func ParseGenUnitName(unit string) (esc string, n uint64, ok bool) { body, found := strings.CutSuffix(unit, ".service") if !found { return "", 0, false } inst, found := strings.CutPrefix(body, UnitPrefixGen) if !found { return "", 0, false } return ParseGenInstance(inst) } // GenKeyOf and GenKey key a generation's files: its executable mount // directory and its log socket (spec §7.6). It is "@", not the // unit's ":", because ':' is BindPaths='s field separator. No host // and no instance ID holds '@', so a key never meets a legacy name. func GenKeyOf(iid string, n uint64) string { return iid + "@" + strconv.FormatUint(n, 10) } func GenKey(host string, n uint64) string { return GenKeyOf(provision.InstanceID(host), n) } // ParseGenKey is GenKeyOf's inverse, under ParseGenInstance's rules: n is // canonical decimal and never 0, and iid is non-empty and holds no '@', // ':', '/' or NUL. A host's own directory under the exe root has no '@', // so it is never a generation key. func ParseGenKey(key string) (iid string, n uint64, ok bool) { i := strings.LastIndexByte(key, '@') if i <= 0 || strings.ContainsRune(key[:i], '@') { return "", 0, false } return ParseGenInstance(key[:i] + ":" + key[i+1:]) } // GenLogSocket is the generation's run log socket: // /run/carlos/logs/.run.sock. func GenLogSocket(host string, n uint64) string { return filepath.Join(provision.SocketDir, "logs", GenKey(host, n)+".run.sock") } // Unescape is Escape's inverse for the names this platform produces: // "\xHH" becomes the byte and "-" becomes "/". It reports false on a // malformed escape. func Unescape(s string) (string, bool) { var b strings.Builder for i := 0; i < len(s); i++ { switch c := s[i]; { case c == '-': b.WriteByte('/') case c == '\\': if i+3 >= len(s) || s[i+1] != 'x' { return "", false } v, err := strconv.ParseUint(s[i+2:i+4], 16, 8) if err != nil { return "", false } b.WriteByte(byte(v)) i += 3 default: b.WriteByte(c) } } return b.String(), true } // ParseUnitName recognises the four templates' instances and returns the // role and the ESCAPED identifier (systemd reports names escaped). Anything // else, including a template with no instance, is not ours. func ParseUnitName(unit string) (Role, string, bool) { if !strings.HasSuffix(unit, ".service") { return "", "", false } if esc, _, ok := ParseGenUnitName(unit); ok { // A generation unit runs in its host's slice, as the run role. return RoleRun, esc, true } body := strings.TrimSuffix(unit, ".service") for _, p := range []struct { prefix string role Role }{{UnitPrefixSeed, RoleSeed}, {UnitPrefixSidecar, RoleSidecar}, {UnitPrefixCmd, RoleCmd}, {UnitPrefixRun, RoleRun}} { if strings.HasPrefix(body, p.prefix) { esc := strings.TrimPrefix(body, p.prefix) if esc == "" { return "", "", false } return p.role, esc, true } } return "", "", false } // LaunchSchema is the only launch-file schema this release understands. const LaunchSchema = 1 // Launch is what the agent writes into the instance directory before a // start and what the trampoline execs (design §6.2). Env is the complete // environment, one entry per key, exactly as the agent composed it; the // trampoline parses nothing. type Launch struct { Schema int `json:"schema"` Host string `json:"host"` Argv []string `json:"argv"` Env []string `json:"env"` } // LaunchFileName is "launch..json" inside the instance directory. func LaunchFileName(role Role) string { return "launch." + string(role) + ".json" } // GenLaunchFileName is generation n's launch file in the instance // directory: "launch.gen..json". func GenLaunchFileName(n uint64) string { return "launch.gen." + strconv.FormatUint(n, 10) + ".json" } // ExitRecord is the advisory record the unit's ExecStopPost leaves in the // instance's run directory (design §6.5). It decides the wording of the // member-facing event line and nothing else. type ExitRecord struct { Invocation string `json:"invocation"` Result string `json:"result"` Code string `json:"code"` Status string `json:"status"` At time.Time `json:"at"` } // ExitRecordName is "exit...json". func ExitRecordName(role Role, invocation string) string { return "exit." + string(role) + "." + invocation + ".json" } // GenExitRecordName is generation n's exit record: "exit.gen...json". // It is never under "exit.run.", which prepare sweeps for the run role. func GenExitRecordName(n uint64, invocation string) string { return GenExitPrefix(n) + invocation + ".json" } // GenExitPrefix is every exit record of generation n. func GenExitPrefix(n uint64) string { return "exit.gen." + strconv.FormatUint(n, 10) + "." } // ProbeDirName is the instance directory the readiness probe runs in. It // starts with an underscore, which no valid host does, so it can never // collide with a real instance's directory. const ProbeDirName = "_probe" // VerdictLine is one check of the readiness probe (design §8.5). Verdict is // "ok", "unverified" or "fail"; only "fail" blocks readiness. type VerdictLine struct { Check string `json:"check"` Verdict string `json:"verdict"` Detail string `json:"detail,omitempty"` } // ProbeReport is what the probe prints and what /ready answers with. type ProbeReport struct { Ready bool `json:"ready"` Lines []VerdictLine `json:"lines"` } // BuildHeader carries the control service's version.Build on every /ready // answer (#256). The agent compares it with its own: both are meant to be // /opt/carlos/carlos, and a service that reports another build, or none // (a release before this header), is one carlos system apply or the roll // script left running across a binary change. Nested wakes refuse until // it is restarted, with that as the reason. const BuildHeader = "X-Carlos-Build" // Responses of the control verbs (design §5.3). type StartResponse struct { InvocationID string `json:"invocation_id"` } type SeedResponse struct { InvocationID string `json:"invocation_id"` Result string `json:"result"` Status string `json:"status"` Code string `json:"code"` } // CmdStatus is GET /cmd/{host}'s answer (run spec §5.4). State is running, // done once the named invocation's main process has exited (active // (exited), failed, or inactive after a stop), or gone when the unit's // invocation is another one or none. Result, Status and Code are systemd's // Result, ExecMainStatus and ExecMainCode as `systemctl show` prints them. type CmdStatus struct { State string `json:"state"` InvocationID string `json:"invocation_id"` Result string `json:"result"` Status string `json:"status"` Code string `json:"code"` } const ( CmdRunning = "running" CmdDone = "done" CmdGone = "gone" ) type SweepResponse struct { Units []string `json:"units"` Failed map[string]string `json:"failed,omitempty"` }