| 1 | // Package instancectl is the root instance-control channel of the |
| 2 | // isolated-exec design (§5) and the names everything on both sides of it |
| 3 | // agrees on: unit and slice names keyed by the bounded instance identifier, |
| 4 | // the launch file the trampoline reads (§6.2), the advisory exit record |
| 5 | // (§6.5) and the probe's verdict (§8.5). |
| 6 | // |
| 7 | // The names live in one package because three programs and two services |
| 8 | // build them: the agent (plan 3) composes launch files and requests, the |
| 9 | // control service builds unit names from validated hosts, and the |
| 10 | // in-namespace programs read files keyed by the same identifier. A |
| 11 | // disagreement between any two of them would be a unit that never starts |
| 12 | // or a record nobody finds. |
| 13 | package instancectl |
| 14 | |
| 15 | import ( |
| 16 | "fmt" |
| 17 | "path/filepath" |
| 18 | "strconv" |
| 19 | "strings" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/carlosframework/platform/internal/provision" |
| 23 | ) |
| 24 | |
| 25 | // Role is which of the four template units a request or a file is about. |
| 26 | type Role string |
| 27 | |
| 28 | const ( |
| 29 | RoleRun Role = "run" |
| 30 | RoleSeed Role = "seed" |
| 31 | RoleSidecar Role = "sidecar" |
| 32 | // RoleCmd is one `carlos run` command (run spec §5.4). "run" is the |
| 33 | // serving role already, so the command's role is cmd. |
| 34 | RoleCmd Role = "cmd" |
| 35 | ) |
| 36 | |
| 37 | // ParseRole accepts exactly the four literals. |
| 38 | func ParseRole(s string) (Role, bool) { |
| 39 | switch Role(s) { |
| 40 | case RoleRun, RoleSeed, RoleSidecar, RoleCmd: |
| 41 | return Role(s), true |
| 42 | } |
| 43 | return "", false |
| 44 | } |
| 45 | |
| 46 | // Escape is systemd-escape(1)'s rule for a unit instance name: "/" becomes |
| 47 | // "-", a leading "." becomes "\x2e", and every byte outside [a-zA-Z0-9:_.] |
| 48 | // becomes "\x" plus two lowercase hex digits. For the identifiers this |
| 49 | // platform produces (hosts are [a-z0-9.-], a hashed identifier adds a |
| 50 | // leading underscore) that means "-" becomes "\x2d" and nothing else |
| 51 | // changes; the general rule is kept so an unexpected byte can never yield |
| 52 | // a name systemd refuses to load. |
| 53 | func Escape(s string) string { |
| 54 | var b strings.Builder |
| 55 | for i := 0; i < len(s); i++ { |
| 56 | c := s[i] |
| 57 | switch { |
| 58 | case c == '/': |
| 59 | b.WriteByte('-') |
| 60 | case i == 0 && c == '.': |
| 61 | b.WriteString(`\x2e`) |
| 62 | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.': |
| 63 | b.WriteByte(c) |
| 64 | default: |
| 65 | fmt.Fprintf(&b, `\x%02x`, c) |
| 66 | } |
| 67 | } |
| 68 | return b.String() |
| 69 | } |
| 70 | |
| 71 | // The four template prefixes. A unit under any of them is the platform's |
| 72 | // to start, stop and sweep; nothing else is (design §5.3). |
| 73 | const ( |
| 74 | UnitPrefixRun = "carlos-instance@" |
| 75 | UnitPrefixSeed = "carlos-instance-seed@" |
| 76 | UnitPrefixSidecar = "carlos-instance-sidecar@" |
| 77 | UnitPrefixCmd = "carlos-instance-cmd@" |
| 78 | ) |
| 79 | |
| 80 | // CmdExeApp and CmdExeLive are the cmd template's executable mount |
| 81 | // sources under <ExeRoot>/<instance id>, apart from the app and live the |
| 82 | // other original templates bind (merge ruling M4). They are bound at the |
| 83 | // same places in the namespace, /opt/carlos/app and /opt/carlos/live. |
| 84 | const ( |
| 85 | CmdExeApp = "cmd-app" |
| 86 | CmdExeLive = "cmd-live" |
| 87 | ) |
| 88 | |
| 89 | func prefixFor(role Role) string { |
| 90 | switch role { |
| 91 | case RoleSeed: |
| 92 | return UnitPrefixSeed |
| 93 | case RoleSidecar: |
| 94 | return UnitPrefixSidecar |
| 95 | case RoleCmd: |
| 96 | return UnitPrefixCmd |
| 97 | } |
| 98 | return UnitPrefixRun |
| 99 | } |
| 100 | |
| 101 | // UnitName is the template instance for a role and a host: |
| 102 | // carlos-instance[-seed|-sidecar|-cmd]@<Escape(InstanceID(host))>.service. |
| 103 | func UnitName(role Role, host string) string { |
| 104 | return prefixFor(role) + Escape(provision.InstanceID(host)) + ".service" |
| 105 | } |
| 106 | |
| 107 | // SliceName is the per-host slice every role of a host runs in (design |
| 108 | // §4.3): carlos-instances-<Escape(InstanceID(host))>.slice. |
| 109 | func SliceName(host string) string { |
| 110 | return "carlos-instances-" + Escape(provision.InstanceID(host)) + ".slice" |
| 111 | } |
| 112 | |
| 113 | // UnitPrefixGen is the generation template (zero-downtime deploys spec |
| 114 | // §7.6): one instance per spawn of a host's run role. The four templates |
| 115 | // above stay for every box and binary still running them. |
| 116 | const UnitPrefixGen = "carlos-instance-gen@" |
| 117 | |
| 118 | // GenInstance is the generation template's unescaped instance string, |
| 119 | // "<iid>:<n>" (%I inside the unit). No instance ID holds ':'. |
| 120 | func GenInstance(host string, n uint64) string { |
| 121 | return provision.InstanceID(host) + ":" + strconv.FormatUint(n, 10) |
| 122 | } |
| 123 | |
| 124 | // ParseGenInstance splits "<iid>:<n>", the trampoline's and the exit |
| 125 | // writer's half of §7.6. n is canonical decimal and never 0. iid is |
| 126 | // non-empty and holds no ':', '/' or NUL, and is neither "." nor "..". |
| 127 | func ParseGenInstance(s string) (iid string, n uint64, ok bool) { |
| 128 | i := strings.LastIndexByte(s, ':') |
| 129 | if i <= 0 { |
| 130 | return "", 0, false |
| 131 | } |
| 132 | iid, num := s[:i], s[i+1:] |
| 133 | n, err := strconv.ParseUint(num, 10, 64) |
| 134 | if err != nil || n == 0 || strconv.FormatUint(n, 10) != num { |
| 135 | return "", 0, false |
| 136 | } |
| 137 | if strings.ContainsAny(iid, ":/\x00") || iid == "." || iid == ".." { |
| 138 | return "", 0, false |
| 139 | } |
| 140 | return iid, n, true |
| 141 | } |
| 142 | |
| 143 | // GenUnitName is carlos-instance-gen@<Escape(GenInstance(host, n))>.service. |
| 144 | func GenUnitName(host string, n uint64) string { |
| 145 | return GenUnitNameOf(provision.InstanceID(host), n) |
| 146 | } |
| 147 | |
| 148 | // GenUnitNameOf is GenUnitName keyed by the instance ID, for a name read |
| 149 | // back from a generation's files (ParseGenKey), where no host is known. |
| 150 | func GenUnitNameOf(iid string, n uint64) string { |
| 151 | return UnitPrefixGen + Escape(iid+":"+strconv.FormatUint(n, 10)) + ".service" |
| 152 | } |
| 153 | |
| 154 | // ParseGenUnitName recognises a generation unit and returns its ESCAPED |
| 155 | // instance ID (systemd reports names escaped) and its generation. |
| 156 | func ParseGenUnitName(unit string) (esc string, n uint64, ok bool) { |
| 157 | body, found := strings.CutSuffix(unit, ".service") |
| 158 | if !found { |
| 159 | return "", 0, false |
| 160 | } |
| 161 | inst, found := strings.CutPrefix(body, UnitPrefixGen) |
| 162 | if !found { |
| 163 | return "", 0, false |
| 164 | } |
| 165 | return ParseGenInstance(inst) |
| 166 | } |
| 167 | |
| 168 | // GenKeyOf and GenKey key a generation's files: its executable mount |
| 169 | // directory and its log socket (spec §7.6). It is "<iid>@<n>", not the |
| 170 | // unit's "<iid>:<n>", because ':' is BindPaths='s field separator. No host |
| 171 | // and no instance ID holds '@', so a key never meets a legacy name. |
| 172 | func GenKeyOf(iid string, n uint64) string { return iid + "@" + strconv.FormatUint(n, 10) } |
| 173 | |
| 174 | func GenKey(host string, n uint64) string { return GenKeyOf(provision.InstanceID(host), n) } |
| 175 | |
| 176 | // ParseGenKey is GenKeyOf's inverse, under ParseGenInstance's rules: n is |
| 177 | // canonical decimal and never 0, and iid is non-empty and holds no '@', |
| 178 | // ':', '/' or NUL. A host's own directory under the exe root has no '@', |
| 179 | // so it is never a generation key. |
| 180 | func ParseGenKey(key string) (iid string, n uint64, ok bool) { |
| 181 | i := strings.LastIndexByte(key, '@') |
| 182 | if i <= 0 || strings.ContainsRune(key[:i], '@') { |
| 183 | return "", 0, false |
| 184 | } |
| 185 | return ParseGenInstance(key[:i] + ":" + key[i+1:]) |
| 186 | } |
| 187 | |
| 188 | // GenLogSocket is the generation's run log socket: |
| 189 | // /run/carlos/logs/<GenKey>.run.sock. |
| 190 | func GenLogSocket(host string, n uint64) string { |
| 191 | return filepath.Join(provision.SocketDir, "logs", GenKey(host, n)+".run.sock") |
| 192 | } |
| 193 | |
| 194 | // Unescape is Escape's inverse for the names this platform produces: |
| 195 | // "\xHH" becomes the byte and "-" becomes "/". It reports false on a |
| 196 | // malformed escape. |
| 197 | func Unescape(s string) (string, bool) { |
| 198 | var b strings.Builder |
| 199 | for i := 0; i < len(s); i++ { |
| 200 | switch c := s[i]; { |
| 201 | case c == '-': |
| 202 | b.WriteByte('/') |
| 203 | case c == '\\': |
| 204 | if i+3 >= len(s) || s[i+1] != 'x' { |
| 205 | return "", false |
| 206 | } |
| 207 | v, err := strconv.ParseUint(s[i+2:i+4], 16, 8) |
| 208 | if err != nil { |
| 209 | return "", false |
| 210 | } |
| 211 | b.WriteByte(byte(v)) |
| 212 | i += 3 |
| 213 | default: |
| 214 | b.WriteByte(c) |
| 215 | } |
| 216 | } |
| 217 | return b.String(), true |
| 218 | } |
| 219 | |
| 220 | // ParseUnitName recognises the four templates' instances and returns the |
| 221 | // role and the ESCAPED identifier (systemd reports names escaped). Anything |
| 222 | // else, including a template with no instance, is not ours. |
| 223 | func ParseUnitName(unit string) (Role, string, bool) { |
| 224 | if !strings.HasSuffix(unit, ".service") { |
| 225 | return "", "", false |
| 226 | } |
| 227 | if esc, _, ok := ParseGenUnitName(unit); ok { |
| 228 | // A generation unit runs in its host's slice, as the run role. |
| 229 | return RoleRun, esc, true |
| 230 | } |
| 231 | body := strings.TrimSuffix(unit, ".service") |
| 232 | for _, p := range []struct { |
| 233 | prefix string |
| 234 | role Role |
| 235 | }{{UnitPrefixSeed, RoleSeed}, {UnitPrefixSidecar, RoleSidecar}, {UnitPrefixCmd, RoleCmd}, {UnitPrefixRun, RoleRun}} { |
| 236 | if strings.HasPrefix(body, p.prefix) { |
| 237 | esc := strings.TrimPrefix(body, p.prefix) |
| 238 | if esc == "" { |
| 239 | return "", "", false |
| 240 | } |
| 241 | return p.role, esc, true |
| 242 | } |
| 243 | } |
| 244 | return "", "", false |
| 245 | } |
| 246 | |
| 247 | // LaunchSchema is the only launch-file schema this release understands. |
| 248 | const LaunchSchema = 1 |
| 249 | |
| 250 | // Launch is what the agent writes into the instance directory before a |
| 251 | // start and what the trampoline execs (design §6.2). Env is the complete |
| 252 | // environment, one entry per key, exactly as the agent composed it; the |
| 253 | // trampoline parses nothing. |
| 254 | type Launch struct { |
| 255 | Schema int `json:"schema"` |
| 256 | Host string `json:"host"` |
| 257 | Argv []string `json:"argv"` |
| 258 | Env []string `json:"env"` |
| 259 | } |
| 260 | |
| 261 | // LaunchFileName is "launch.<role>.json" inside the instance directory. |
| 262 | func LaunchFileName(role Role) string { return "launch." + string(role) + ".json" } |
| 263 | |
| 264 | // GenLaunchFileName is generation n's launch file in the instance |
| 265 | // directory: "launch.gen.<n>.json". |
| 266 | func GenLaunchFileName(n uint64) string { return "launch.gen." + strconv.FormatUint(n, 10) + ".json" } |
| 267 | |
| 268 | // ExitRecord is the advisory record the unit's ExecStopPost leaves in the |
| 269 | // instance's run directory (design §6.5). It decides the wording of the |
| 270 | // member-facing event line and nothing else. |
| 271 | type ExitRecord struct { |
| 272 | Invocation string `json:"invocation"` |
| 273 | Result string `json:"result"` |
| 274 | Code string `json:"code"` |
| 275 | Status string `json:"status"` |
| 276 | At time.Time `json:"at"` |
| 277 | } |
| 278 | |
| 279 | // ExitRecordName is "exit.<role>.<invocation>.json". |
| 280 | func ExitRecordName(role Role, invocation string) string { |
| 281 | return "exit." + string(role) + "." + invocation + ".json" |
| 282 | } |
| 283 | |
| 284 | // GenExitRecordName is generation n's exit record: "exit.gen.<n>.<inv>.json". |
| 285 | // It is never under "exit.run.", which prepare sweeps for the run role. |
| 286 | func GenExitRecordName(n uint64, invocation string) string { |
| 287 | return GenExitPrefix(n) + invocation + ".json" |
| 288 | } |
| 289 | |
| 290 | // GenExitPrefix is every exit record of generation n. |
| 291 | func GenExitPrefix(n uint64) string { return "exit.gen." + strconv.FormatUint(n, 10) + "." } |
| 292 | |
| 293 | // ProbeDirName is the instance directory the readiness probe runs in. It |
| 294 | // starts with an underscore, which no valid host does, so it can never |
| 295 | // collide with a real instance's directory. |
| 296 | const ProbeDirName = "_probe" |
| 297 | |
| 298 | // VerdictLine is one check of the readiness probe (design §8.5). Verdict is |
| 299 | // "ok", "unverified" or "fail"; only "fail" blocks readiness. |
| 300 | type VerdictLine struct { |
| 301 | Check string `json:"check"` |
| 302 | Verdict string `json:"verdict"` |
| 303 | Detail string `json:"detail,omitempty"` |
| 304 | } |
| 305 | |
| 306 | // ProbeReport is what the probe prints and what /ready answers with. |
| 307 | type ProbeReport struct { |
| 308 | Ready bool `json:"ready"` |
| 309 | Lines []VerdictLine `json:"lines"` |
| 310 | } |
| 311 | |
| 312 | // BuildHeader carries the control service's version.Build on every /ready |
| 313 | // answer (#256). The agent compares it with its own: both are meant to be |
| 314 | // /opt/carlos/carlos, and a service that reports another build, or none |
| 315 | // (a release before this header), is one carlos system apply or the roll |
| 316 | // script left running across a binary change. Nested wakes refuse until |
| 317 | // it is restarted, with that as the reason. |
| 318 | const BuildHeader = "X-Carlos-Build" |
| 319 | |
| 320 | // Responses of the control verbs (design §5.3). |
| 321 | type StartResponse struct { |
| 322 | InvocationID string `json:"invocation_id"` |
| 323 | } |
| 324 | |
| 325 | type SeedResponse struct { |
| 326 | InvocationID string `json:"invocation_id"` |
| 327 | Result string `json:"result"` |
| 328 | Status string `json:"status"` |
| 329 | Code string `json:"code"` |
| 330 | } |
| 331 | |
| 332 | // CmdStatus is GET /cmd/{host}'s answer (run spec §5.4). State is running, |
| 333 | // done once the named invocation's main process has exited (active |
| 334 | // (exited), failed, or inactive after a stop), or gone when the unit's |
| 335 | // invocation is another one or none. Result, Status and Code are systemd's |
| 336 | // Result, ExecMainStatus and ExecMainCode as `systemctl show` prints them. |
| 337 | type CmdStatus struct { |
| 338 | State string `json:"state"` |
| 339 | InvocationID string `json:"invocation_id"` |
| 340 | Result string `json:"result"` |
| 341 | Status string `json:"status"` |
| 342 | Code string `json:"code"` |
| 343 | } |
| 344 | |
| 345 | const ( |
| 346 | CmdRunning = "running" |
| 347 | CmdDone = "done" |
| 348 | CmdGone = "gone" |
| 349 | ) |
| 350 | |
| 351 | type SweepResponse struct { |
| 352 | Units []string `json:"units"` |
| 353 | Failed map[string]string `json:"failed,omitempty"` |
| 354 | } |
| 355 | |