carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
13package instancectl
14
15import (
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.
26type Role string
27
28const (
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.
38func 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.
53func 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).
73const (
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.
84const (
85 CmdExeApp = "cmd-app"
86 CmdExeLive = "cmd-live"
87)
88
89func 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.
103func 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.
109func 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.
116const 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 ':'.
120func 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 "..".
127func 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.
144func 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.
150func 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.
156func 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.
172func GenKeyOf(iid string, n uint64) string { return iid + "@" + strconv.FormatUint(n, 10) }
173
174func 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.
180func 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.
190func 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.
197func 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.
223func 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.
248const 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.
254type 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.
262func 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".
266func 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.
271type 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".
280func 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.
286func GenExitRecordName(n uint64, invocation string) string {
287 return GenExitPrefix(n) + invocation + ".json"
288}
289
290// GenExitPrefix is every exit record of generation n.
291func 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.
296const 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.
300type 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.
307type 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.
318const BuildHeader = "X-Carlos-Build"
319
320// Responses of the control verbs (design §5.3).
321type StartResponse struct {
322 InvocationID string `json:"invocation_id"`
323}
324
325type 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.
337type 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
345const (
346 CmdRunning = "running"
347 CmdDone = "done"
348 CmdGone = "gone"
349)
350
351type SweepResponse struct {
352 Units []string `json:"units"`
353 Failed map[string]string `json:"failed,omitempty"`
354}
355