carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package instancectl
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/carlosframework/platform/internal/cgroup"
14 "github.com/carlosframework/platform/internal/provision"
15)
16
17// Generation units (zero-downtime deploys spec §7.6, plan 2 D1). The
18// template carlos-instance-gen@<iid>:<n>.service cannot key its slice and
19// data mounts by itself: systemd has no specifier for part of an instance
20// string. So before each start this service writes a runtime drop-in with
21// everything the template leaves out, checks that systemd took it
22// (reloading first only when it has not), and only then starts the unit.
23
24// ErrGenUnproven is StartGen's answer when the start failed after systemd
25// may have started the unit and the stop that followed did not prove the
26// unit's cgroup empty. The generation may still be running: its files are
27// kept, and the caller must not release anything it owns for it.
28var ErrGenUnproven = errors.New("instance control: generation termination unproven")
29
30// statusGenUnproven is the status that carries ErrGenUnproven. No other
31// verb answers it, so the client can tell it from an ordinary 502.
32const statusGenUnproven = http.StatusInternalServerError
33
34// DropInName is the drop-in's file name inside <unit>.d.
35const DropInName = "50-carlos-generation.conf"
36
37// GenDropIn is the drop-in's text. The paths are the templates' literal
38// ones (/data/instances, /run/carlos/instances, /run/carlos-control/exe).
39// The memory lines are the generation's own tier (spec §7.3). The
40// template's OOMPolicy=kill gives the unit's cgroup memory.oom.group=1.
41func GenDropIn(host string, n uint64, tier cgroup.Limit) string {
42 iid := provision.InstanceID(host)
43 key := GenKey(host, n)
44 max := strconv.FormatInt(tier.MemoryMax, 10)
45 return "# Written by carlos instance-control for one generation; removed when it stops (zero-downtime plan 2).\n" +
46 "[Service]\n" +
47 "Slice=" + SliceName(host) + "\n" +
48 "BindPaths=/data/instances/" + iid + " /run/carlos/instances/" + iid + "\n" +
49 "ReadWritePaths=/data/instances/" + iid + " /run/carlos/instances/" + iid + "\n" +
50 "WorkingDirectory=/data/instances/" + iid + "\n" +
51 "BindReadOnlyPaths=/run/carlos-control/exe/" + key + "/app:/opt/carlos/app /run/carlos-control/exe/" + key + "/live:/opt/carlos/live\n" +
52 "StandardOutput=file:" + GenLogSocket(host, n) + "\n" +
53 "MemoryMax=" + max + "\n" +
54 "MemoryHigh=" + strconv.FormatInt(cgroup.HighOf(tier.MemoryMax), 10) + "\n" +
55 "MemorySwapMax=" + max + "\n"
56}
57
58// writeGenDropIn writes the drop-in atomically: a temp file systemd never
59// reads (only *.conf is read), renamed into place.
60func (s *Server) writeGenDropIn(host string, n uint64, tier cgroup.Limit) error {
61 dir := filepath.Join(s.DropInDir, GenUnitName(host, n)+".d")
62 if err := os.MkdirAll(dir, 0o755); err != nil {
63 return err
64 }
65 tmp := filepath.Join(dir, "."+DropInName+".tmp")
66 if err := os.WriteFile(tmp, []byte(GenDropIn(host, n, tier)), 0o644); err != nil {
67 return err
68 }
69 return os.Rename(tmp, filepath.Join(dir, DropInName))
70}
71
72// ensureGenExe lays out generation n's view of its build under ExeRoot,
73// keyed by GenKey, as ensureHostExe does for the original template. Its
74// target is the host's live link, as today. Plan 3 binds the attempt's
75// staged path instead (spec §7.5 step 3).
76func (s *Server) ensureGenExe(host string, n uint64) error {
77 return ensureExe(s.ExeRoot, GenKey(host, n), filepath.Join(LiveDir, host), host)
78}
79
80// removeGenFiles removes a generation unit's drop-in and executable layout,
81// by the unit's name as systemd reports it. Best-effort: a leftover is a
82// few bytes in /run, never a running unit, so most callers drop the error.
83// The sweep logs it, so a leftover it cannot remove says why (Task 15).
84func (s *Server) removeGenFiles(unit string) error {
85 esc, n, ok := ParseGenUnitName(unit)
86 if !ok {
87 return nil
88 }
89 var errs []error
90 if s.DropInDir != "" {
91 errs = append(errs, os.RemoveAll(filepath.Join(s.DropInDir, unit+".d")))
92 }
93 iid, ok := Unescape(esc)
94 if !ok || iid == "" || strings.ContainsAny(iid, "/\x00") || iid == "." || iid == ".." {
95 return errors.Join(errs...)
96 }
97 if s.ExeRoot != "" {
98 errs = append(errs, os.RemoveAll(filepath.Join(s.ExeRoot, GenKeyOf(iid, n))))
99 }
100 return errors.Join(errs...)
101}
102
103// slicePopulated reports whether anything runs in host's slice. Absent is
104// no. Unreadable is yes, so a raised limit is never lowered on a guess.
105func (s *Server) slicePopulated(host string) bool {
106 b, err := os.ReadFile(filepath.Join(s.CgroupRoot, parentSlices, SliceName(host), "cgroup.events"))
107 if errors.Is(err, os.ErrNotExist) {
108 return false
109 }
110 if err != nil {
111 return true
112 }
113 return !strings.Contains(string(b), "populated 0")
114}
115
116// genView is startGen's one look at systemd, a single show of the unit and
117// its host slice. applied reports whether systemd's view of unit is the
118// drop-in just written (GenDropIn). The unit's name fixes the host and
119// generation, and so every line of the drop-in but the tier's: the slice
120// says systemd read a drop-in at all, and the memory lines say it was this
121// tier's. NeedDaemonReload=no says no file changed since systemd loaded
122// the unit, though only by mtime, which is why the tier is checked as well
123// (Astra review: a reused, still-loaded generation and a clock that went
124// back). tierSet reports whether the slice already carries every value
125// setTier would give it, so a cold start can skip that call (wake latency,
126// pass 2c). Any failure to answer is a no to both.
127func (s *Server) genView(ctx context.Context, host, unit string, tier cgroup.Limit) (slice string, applied, tierSet bool) {
128 out, err := s.Systemctl(ctx, "show", "-p", "Slice,NeedDaemonReload,MemoryMax,MemoryHigh,MemorySwapMax,CPUWeight,CPUQuotaPerSecUSec,IOWeight", unit, SliceName(host))
129 if err != nil {
130 return "", false, false
131 }
132 blocks := strings.SplitN(strings.TrimLeft(out, "\n"), "\n\n", 2)
133 m := parseShow(blocks[0])
134 max := strconv.FormatInt(tier.MemoryMax, 10)
135 high := strconv.FormatInt(cgroup.HighOf(tier.MemoryMax), 10)
136 applied = m["Slice"] == SliceName(host) && m["NeedDaemonReload"] == "no" &&
137 m["MemoryMax"] == max && m["MemorySwapMax"] == max && m["MemoryHigh"] == high
138 if len(blocks) == 2 {
139 sl := parseShow(blocks[1])
140 sh := cgroup.SharesOf(tier)
141 tierSet = sl["MemoryMax"] == max && sl["MemorySwapMax"] == max && sl["MemoryHigh"] == high &&
142 sl["CPUWeight"] == strconv.Itoa(sh.CPUWeight) && sl["IOWeight"] == strconv.Itoa(sh.IOWeight) &&
143 sl["CPUQuotaPerSecUSec"] == cpuQuotaShown(sh.CPUQuotaPercent)
144 }
145 return m["Slice"], applied, tierSet
146}
147
148// cpuQuotaShown is CPUQuota=<pct>% as systemctl show prints its
149// CPUQuotaPerSecUSec (systemd 257): 500ms, 1s, 1.500000s. A format this
150// gets wrong only costs the set-property call it would have saved.
151func cpuQuotaShown(pct int) string {
152 usec := int64(pct) * 10000
153 switch {
154 case usec%1_000_000 == 0:
155 return strconv.FormatInt(usec/1_000_000, 10) + "s"
156 case usec < 1_000_000:
157 return strconv.FormatInt(usec/1000, 10) + "ms"
158 default:
159 return strconv.FormatFloat(float64(usec)/1e6, 'f', 6, 64) + "s"
160 }
161}
162
163// startGen is /start with a generation. The host slice is the parent (spec
164// §7.3): a cold start sets it to the tier. A start that finds the slice
165// populated leaves it alone, because a swap raised it with /parent before
166// the successor's start.
167func (s *Server) startGen(w http.ResponseWriter, r *http.Request, req request) {
168 ctx := r.Context()
169 unit := GenUnitName(req.host, req.gen)
170 if s.DropInDir == "" {
171 http.Error(w, "not starting "+unit+": generation units are not configured on this service", http.StatusNotImplemented)
172 return
173 }
174 s.markGenStarting(unit, true)
175 defer s.markGenStarting(unit, false)
176 // Timed for the start's log line: a wake waits on all of this.
177 began, reload := time.Now(), "skipped"
178 // A refusal for the disk writes nothing. An exe layout that fails
179 // half-way may have made its directory, so that failure cleans up too.
180 // A staged executable is validated before anything is laid out
181 // (zero-downtime plan 3, Task 9).
182 if s.refuseStagedExe(w, req, unit) {
183 return
184 }
185 checked := time.Now()
186 if s.refuseStart(w, req.host, unit, func() error {
187 err := s.ensureGenExeAt(req.host, req.gen, req.exe)
188 if err != nil {
189 s.removeGenFiles(unit)
190 }
191 return err
192 }) {
193 return
194 }
195 fail := func(status int, msg string) {
196 s.removeGenFiles(unit)
197 http.Error(w, msg, status)
198 }
199 laidOut := time.Now()
200 if err := s.writeGenDropIn(req.host, req.gen, req.tier); err != nil {
201 fail(http.StatusBadGateway, "drop-in: "+err.Error())
202 return
203 }
204 written := time.Now()
205 // The reload is only for a unit systemd already holds loaded from older
206 // files. A generation's unit is new and an inactive one is unloaded
207 // straight away, so the next reference reads the drop-in fresh: the
208 // common case needs no reload, which on a small box was a third of the
209 // whole wake. The check is the same either way (genDropInApplied).
210 slice, applied, tierSet := s.genView(ctx, req.host, unit, req.tier)
211 if !applied {
212 reloadStart := time.Now()
213 if out, err := s.Systemctl(ctx, "daemon-reload"); err != nil {
214 fail(http.StatusBadGateway, "daemon-reload: "+strings.TrimSpace(out+" "+err.Error()))
215 return
216 }
217 if slice, applied, tierSet = s.genView(ctx, req.host, unit, req.tier); !applied {
218 fail(http.StatusBadGateway, "not starting "+unit+": its drop-in did not apply (slice "+strconv.Quote(slice)+")")
219 return
220 }
221 reload = time.Since(reloadStart).Round(time.Millisecond).String()
222 }
223 if !s.slicePopulated(req.host) && !tierSet {
224 if err := s.setTier(ctx, req.host, req.tier); err != nil {
225 fail(http.StatusBadGateway, "set-property: "+err.Error())
226 return
227 }
228 }
229 // From the start on, systemd may have started the unit, whatever the
230 // answer says. A failure past this point kills it and proves it empty
231 // before its files go; one that cannot prove that keeps the files and
232 // says so (statusGenUnproven).
233 startAt := time.Now()
234 if out, err := s.Systemctl(ctx, "start", unit); err != nil {
235 s.abandonGen(ctx, w, req.host, unit, "start: "+strings.TrimSpace(out+" "+err.Error()))
236 return
237 }
238 inv, err := s.Systemctl(ctx, "show", "-p", "InvocationID", "--value", unit)
239 if err != nil {
240 s.abandonGen(ctx, w, req.host, unit, "show: "+err.Error())
241 return
242 }
243 ms := func(d time.Duration) time.Duration { return d.Round(time.Millisecond) }
244 s.logf("instance-control: %s: started %s memory.max=%d (prepared in %s: staged exe %s, layout %s, drop-in %s, check and tier %s; reload %s, start %s)", req.host, unit, req.tier.MemoryMax,
245 ms(startAt.Sub(began)), ms(checked.Sub(began)), ms(laidOut.Sub(checked)), ms(written.Sub(laidOut)), ms(startAt.Sub(written)), reload, ms(time.Since(startAt)))
246 writeJSON(w, http.StatusOK, StartResponse{InvocationID: strings.TrimSpace(inv)})
247}
248
249// abandonGen answers a generation start that failed after systemctl start
250// was issued. The unit may be running, so it is killed and waited on, on a
251// context the client's hang-up cannot cancel. Only a stop that proved the
252// unit's cgroup empty removes its files; otherwise they stay, and the
253// answer is statusGenUnproven so the client knows the generation may live.
254func (s *Server) abandonGen(ctx context.Context, w http.ResponseWriter, host, unit, msg string) {
255 dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), DeadlineControl+s.waitBudget())
256 defer cancel()
257 if err := s.killGen(dctx, host, unit); err != nil {
258 s.logf("instance-control: %s: abandon %s: termination unproven, files kept: %v", host, unit, err)
259 http.Error(w, msg+"; termination unproven: "+err.Error(), statusGenUnproven)
260 return
261 }
262 s.resetGenFailed(dctx, unit)
263 s.removeGenFiles(unit)
264 http.Error(w, msg, http.StatusBadGateway)
265}
266
267// resetGenFailed clears a stopped generation unit's failed state, so a
268// generation that died on its own does not stay in systemctl --failed
269// once the control service has stopped it. It runs only after a stop or
270// kill that proved the unit gone. Best-effort: the stop already succeeded,
271// so a unit that is no longer loaded is ignored and any other failure is
272// logged, never turned into a failed stop.
273func (s *Server) resetGenFailed(ctx context.Context, unit string) {
274 if out, err := s.Systemctl(ctx, "reset-failed", unit); err != nil && !strings.Contains(out+err.Error(), "not loaded") {
275 s.logf("instance-control: reset-failed %s: %v", unit, err)
276 }
277}
278
279// killGen kills a generation unit outright, stops it, and waits until its
280// cgroup is empty (spec §8.2 step 1). nil is the proof that nothing of it
281// runs. A kill that finds nothing to kill is not a failure: the stop and
282// the wait are the proof.
283func (s *Server) killGen(ctx context.Context, host, unit string) error {
284 if out, err := s.Systemctl(ctx, "kill", "--signal=SIGKILL", unit); err != nil && !strings.Contains(out+err.Error(), "not loaded") {
285 s.logf("instance-control: %s: kill %s: %v", host, unit, err)
286 }
287 if out, err := s.Systemctl(ctx, "stop", unit); err != nil {
288 return errors.New("stop: " + strings.TrimSpace(out+" "+err.Error()))
289 }
290 wctx, cancel := context.WithTimeout(ctx, s.waitBudget())
291 defer cancel()
292 if err := WaitCgroupEmpty(wctx, s.CgroupRoot, SliceName(host), unit, 50*time.Millisecond); err != nil {
293 return errors.New("stop: " + err.Error())
294 }
295 return nil
296}
297
298// stopGen is /stop with a generation. With kill=1 the generation is killed
299// outright (spec §8.2 step 1) and waited on until its cgroup is empty.
300// Either way the stop acts on that generation's unit only, and its files
301// go once it has stopped.
302func (s *Server) stopGen(w http.ResponseWriter, r *http.Request, req request) {
303 ctx := r.Context()
304 unit := GenUnitName(req.host, req.gen)
305 if req.kill {
306 if err := s.killGen(ctx, req.host, unit); err != nil {
307 http.Error(w, err.Error(), http.StatusBadGateway)
308 return
309 }
310 } else if out, err := s.Systemctl(ctx, "stop", unit); err != nil {
311 http.Error(w, "stop: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
312 return
313 }
314 s.resetGenFailed(ctx, unit)
315 s.removeGenFiles(unit)
316 w.WriteHeader(http.StatusNoContent)
317}
318
319func (s *Server) markGenStarting(unit string, on bool) {
320 s.genMu.Lock()
321 defer s.genMu.Unlock()
322 if !on {
323 delete(s.genStarting, unit)
324 return
325 }
326 if s.genStarting == nil {
327 s.genStarting = map[string]bool{}
328 }
329 s.genStarting[unit] = true
330}
331
332// sweepGenLeftovers is the sweep's recovery for generation units that are
333// gone but kept their files: their stop's answer was lost, or the agent
334// that would have finalised them restarted. Such a unit is inactive, so
335// the sweep had nothing to stop. Each drop-in directory under DropInDir is
336// checked: a unit the sweep could not stop is skipped, as is one a start
337// is preparing. The rest lose their files only when systemd reports them
338// inactive or failed AND their cgroup is empty. No daemon-reload follows:
339// stopGen removes files the same way, and a generation's name is never
340// started again.
341//
342// Each generation executable directory under ExeRoot (<iid>@<n>) is
343// checked the same way, so a generation that kept only its exe layout is
344// found too: its drop-in went and the exe's removal failed, which
345// removeGenFiles ignores (Task 15, Astra IMPL-2-R2 residual 4). A host's
346// own directory (<iid>, no '@') is never a generation key and is never
347// touched. On a box that never ran generations, ExeRoot holds only host
348// directories and this finds nothing.
349func (s *Server) sweepGenLeftovers(ctx context.Context, failed map[string]string) {
350 var units []string
351 seen := map[string]bool{}
352 add := func(unit string) {
353 if _, stuck := failed[unit]; stuck || seen[unit] {
354 return
355 }
356 seen[unit] = true
357 units = append(units, unit)
358 }
359 for _, e := range s.readSweepDir(s.DropInDir) {
360 unit, ok := strings.CutSuffix(e.Name(), ".d")
361 if !ok || !e.IsDir() {
362 continue
363 }
364 if _, _, ok := ParseGenUnitName(unit); !ok {
365 continue
366 }
367 add(unit)
368 }
369 for _, e := range s.readSweepDir(s.ExeRoot) {
370 if !e.IsDir() {
371 continue
372 }
373 if unit, ok := genUnitOfExeKey(e.Name()); ok {
374 add(unit)
375 }
376 }
377 for _, unit := range units {
378 s.sweepGenLeftover(ctx, unit)
379 }
380}
381
382// readSweepDir lists dir for sweepGenLeftovers. An unset or absent
383// directory has nothing to sweep; any other failure is logged.
384func (s *Server) readSweepDir(dir string) []os.DirEntry {
385 if dir == "" {
386 return nil
387 }
388 entries, err := os.ReadDir(dir)
389 if err != nil && !errors.Is(err, os.ErrNotExist) {
390 s.logf("instance-control: sweep: %s: %v", dir, err)
391 }
392 return entries
393}
394
395// genUnitOfExeKey names the generation unit whose executable directory is
396// key. It answers only when removeGenFiles, given that unit, removes
397// exactly key, so the sweep can never remove some other directory.
398func genUnitOfExeKey(key string) (string, bool) {
399 iid, n, ok := ParseGenKey(key)
400 if !ok {
401 return "", false
402 }
403 unit := GenUnitNameOf(iid, n)
404 esc, m, ok := ParseGenUnitName(unit)
405 if !ok || m != n {
406 return "", false
407 }
408 if back, ok := Unescape(esc); !ok || GenKeyOf(back, n) != key {
409 return "", false
410 }
411 return unit, true
412}
413
414func (s *Server) sweepGenLeftover(ctx context.Context, unit string) {
415 // Held across the check and the removal, so a start of the same unit
416 // cannot write fresh files in between.
417 s.genMu.Lock()
418 defer s.genMu.Unlock()
419 if s.genStarting[unit] {
420 return
421 }
422 if state := s.showValue(ctx, "ActiveState", unit); state != "inactive" && state != "failed" {
423 return
424 }
425 _, esc, _ := ParseUnitName(unit)
426 // One look at the cgroup, no wait: a populated one is not swept.
427 wctx, cancel := context.WithTimeout(ctx, time.Millisecond)
428 defer cancel()
429 if err := WaitCgroupEmpty(wctx, s.CgroupRoot, "carlos-instances-"+esc+".slice", unit, time.Millisecond); err != nil {
430 return
431 }
432 if err := s.removeGenFiles(unit); err != nil {
433 s.logf("instance-control: sweep: could not remove the files of %s: %v", unit, err)
434 return
435 }
436 s.logf("instance-control: sweep: removed the files of %s", unit)
437}
438
439// handleParent sets the host slice's memory: the generation parent's
440// backstop (spec §7.3). The swap raises it before the successor starts and
441// restores it once the retiring generation has stopped. CPU and IO shares
442// stay at one tier (plan 2 D6).
443func (s *Server) handleParent(w http.ResponseWriter, r *http.Request) {
444 q := r.URL.Query()
445 host := q.Get("host")
446 if !provision.ValidHostShape(host) {
447 http.Error(w, "host "+strconv.Quote(host)+" is not a host", http.StatusBadRequest)
448 return
449 }
450 n, err := strconv.ParseInt(q.Get("memory_max"), 10, 64)
451 if err != nil || !cgroup.ValidParentLimit(cgroup.Limit{MemoryMax: n}) {
452 http.Error(w, "memory_max "+strconv.Quote(q.Get("memory_max"))+" is not one tier or two", http.StatusBadRequest)
453 return
454 }
455 max := strconv.FormatInt(n, 10)
456 if out, err := s.Systemctl(r.Context(), "set-property", "--runtime", SliceName(host),
457 "MemoryMax="+max, "MemoryHigh="+strconv.FormatInt(cgroup.HighOf(n), 10), "MemorySwapMax="+max); err != nil {
458 http.Error(w, "set-property: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
459 return
460 }
461 w.WriteHeader(http.StatusNoContent)
462}
463