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 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "log"
11 "net"
12 "net/http"
13 "net/netip"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "time"
22
23 "github.com/carlosframework/platform/internal/cgroup"
24 "github.com/carlosframework/platform/internal/cliflag"
25 "github.com/carlosframework/platform/internal/controlsock"
26 "github.com/carlosframework/platform/internal/provision"
27 "github.com/carlosframework/platform/internal/version"
28)
29
30// Server is the root instance-control service (design §5). It does four
31// things and nothing else: starts, stops and sweeps the four templates'
32// instances, sets a host slice's memory tier before a start, runs the
33// readiness probe, and answers /ready. Every systemctl argument it passes
34// is built from a validated host by UnitName/SliceName; nothing from a
35// request reaches systemctl as a string.
36type Server struct {
37 ClaimStop http.Handler
38 // ClaimHeldRelease is the moves held-release route's handler (moves
39 // Task 7b), registered only when set: the host sets it with both
40 // CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 on, so with moves off the
41 // route does not exist and main's 404 stands (whole-branch fixes,
42 // item 6).
43 ClaimHeldRelease http.Handler
44 // Systemctl runs one systemctl invocation and returns its combined
45 // output; production is exec.Command, tests record.
46 Systemctl func(ctx context.Context, args ...string) (string, error)
47 // PeerOK is asked once per accepted connection, before a byte is read.
48 // Production requires SO_PEERCRED uid carlos and pid == MainPID of
49 // carlos-edge.service (design §5.4). A refused connection is closed
50 // with no response.
51 PeerOK func(conn net.Conn) error
52 // CgroupRoot is /sys/fs/cgroup; tests point it at a fixture tree.
53 CgroupRoot string
54 // ProbeOut is where the probe unit's StandardOutput=truncate: lands.
55 ProbeOut string
56 // FixtureOK verifies /etc/carlos/probe-secret is root:carlos 0640
57 // and /dev/shm/carlos-probe is a root-owned file before a probe
58 // verdict is accepted (design §8.5, codex R11-1, #126 R15).
59 FixtureOK func() error
60 // ProbeEvery is the re-run cadence (five minutes in production).
61 ProbeEvery time.Duration
62 Logf func(string, ...any)
63
64 // ExeRoot is where ensureExe lays out each instance's view of its own
65 // build (#214 item 2); empty skips it, for tests of other verbs.
66 ExeRoot string
67 // OptDir is the version store's root (/opt/carlos), and HostApp reads a
68 // host's account and app from the registry. Together they are what a
69 // nested generation's staged executable is validated against
70 // (validateStagedExe). Either one unset refuses every staged start.
71 OptDir string
72 HostApp func(host string) (acct, app string, err error)
73 // DropInDir is where generation units' runtime drop-ins go
74 // (/run/systemd/system). Empty refuses every generation start: the
75 // template alone runs nothing (zero-downtime plan 2 D1).
76 DropInDir string
77 // ResolvConf is the box's resolv.conf and ResolvedConf resolved's
78 // upstream list; whichever an instance sees (resolverFile) has its
79 // nameservers re-opened through the egress fence before every probe
80 // (#214 item 3).
81 ResolvConf string
82 ResolvedConf string
83 // LocalAddrs lists what routes to this box itself; a nameserver
84 // inside it is never opened (#126). Production reads procfs
85 // (localPrefixes); nil closes every resolver.
86 LocalAddrs func() ([]netip.Prefix, error)
87 // DataRoot is the instance data tree, and Statfs reports its free and
88 // total bytes; a nil Statfs skips the disk floors (#214 item 1).
89 DataRoot string
90 Statfs func(path string) (avail, total uint64, err error)
91 DiskEvery time.Duration
92
93 // cmdPollEvery, cmdWaitBound and cmdStartBound pace the cmd routes:
94 // how often GET /cmd/{host} re-reads the unit (250 ms), how long it
95 // holds a request (30 s, run spec §5.4), and how long POST /cmd waits
96 // for the queued start to show a new invocation (10 s). Zero is the
97 // default; tests shorten them.
98 cmdPollEvery, cmdWaitBound, cmdStartBound time.Duration
99 // cmdLocks holds one *sync.Mutex per host, taken by handleCmd from its
100 // ActiveState read to its answer, so two starts for one host cannot both
101 // pass the check.
102 cmdLocks sync.Map
103
104 // Run executes one command as root and returns its combined output;
105 // production is exec.CommandContext, tests record. Only
106 // inspectMountpoints uses it, and every argument it passes is a
107 // constant: nothing from a request reaches it.
108 Run func(ctx context.Context, args ...string) (string, error)
109
110 // Build is the build this process runs (version.Build), answered on
111 // every /ready as the BuildHeader so the agent can see it talks to
112 // its own release (#256). Empty sends no header, which a client of
113 // this release reads as an older service.
114 Build string
115 // ExeCurrent reports whether this process still runs the installed
116 // platform binary; nil skips the check. Production compares
117 // /proc/self/exe with /opt/carlos/carlos by identity
118 // (installedBinaryCurrent). Asked on every /ready, not once at probe
119 // time, because the binary can change at any moment after the probe
120 // ran: that is exactly the stale-service case of #256.
121 ExeCurrent func() error
122
123 // cgroupWaitBudget bounds a wait for an empty cgroup; tests shorten it.
124 cgroupWaitBudget time.Duration
125
126 // genMu guards genStarting, the generation units a start is preparing
127 // now. The sweep never removes the files of one of them.
128 genMu sync.Mutex
129 genStarting map[string]bool
130
131 // Legacy run start attempts per host (legacystart.go, #292): the
132 // starts in flight, the stops in progress, and each host's barrier,
133 // the accept order of the latest stop's request. connSeq numbers
134 // connections in accept order.
135 legacyMu sync.Mutex
136 legacyStarts map[string]map[*legacyAttempt]struct{}
137 legacyStopping map[string]int
138 legacyBarrier map[string]uint64
139 connSeq atomic.Uint64
140 // legacyJoin overrides legacyJoinBudget; tests shorten it.
141 legacyJoin time.Duration
142
143 mu sync.Mutex
144 report ProbeReport
145 probed bool
146 // startLines is inspectMountpoints' verdict, taken once at start and
147 // folded into every report RunProbe stores. A failing line keeps the
148 // box not ready whatever the probe says, until an operator clears the
149 // files and restarts the service.
150 startLines []VerdictLine
151}
152
153const (
154 probeUnit = "carlos-instance-probe.service"
155 edgeUnit = "carlos-edge.service"
156 parentSlices = "carlos.slice/carlos-instances.slice"
157)
158
159// Run is `carlos instance-control`.
160func Run(args []string) { runWithClaim(args, nil, nil, nil) }
161
162func RunWithRegistry(args []string, hostApp func(string) (string, string, error)) {
163 runWithClaim(args, nil, nil, hostApp)
164}
165
166func runWithClaim(args []string, claim, heldRelease http.Handler, hostApp func(string) (string, string, error)) {
167 fs := flag.NewFlagSet("instance-control", flag.ExitOnError)
168 fs.Usage = cliflag.Usage(fs, "usage: carlos instance-control (socket-activated by carlos-instance-control.socket)")
169 _ = fs.Parse(args)
170 if os.Geteuid() != 0 {
171 log.Fatal("instance-control: this is the root service behind carlos-instance-control.socket; it is not for operators")
172 }
173 s := &Server{
174 ClaimStop: claim,
175 ClaimHeldRelease: heldRelease,
176 Systemctl: func(ctx context.Context, args ...string) (string, error) {
177 out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
178 return string(out), err
179 },
180 Run: func(ctx context.Context, args ...string) (string, error) {
181 out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
182 return string(out), err
183 },
184 PeerOK: productionPeerOK(),
185 CgroupRoot: "/sys/fs/cgroup",
186 ProbeOut: filepath.Join(controlsock.Dir, "probe.out"),
187 FixtureOK: probeFixturesOK,
188 ProbeEvery: 5 * time.Minute,
189 Logf: log.Printf,
190 ExeRoot: filepath.Join(controlsock.Dir, "exe"),
191 OptDir: "/opt/carlos",
192 HostApp: hostApp,
193 DropInDir: "/run/systemd/system",
194 ResolvConf: "/etc/resolv.conf",
195 ResolvedConf: ResolvedConf,
196 LocalAddrs: func() ([]netip.Prefix, error) { return localPrefixes(fibTriePath, ifInet6Path) },
197 DataRoot: "/data/instances",
198 Statfs: statfsBytes,
199 DiskEvery: 30 * time.Second,
200 Build: version.Build,
201 ExeCurrent: installedBinaryCurrent,
202 }
203 ln, activated, err := activatedListener()
204 if err != nil {
205 log.Fatalf("instance-control: %v", err)
206 }
207 if !activated {
208 // Not socket-activated (a hand run): bind the path ourselves with
209 // the same ownership the socket unit gives it.
210 ln, err = bindControlSocket(controlsock.Path)
211 if err != nil {
212 log.Fatalf("instance-control: %v", err)
213 }
214 }
215 ctx, cancel := context.WithCancel(context.Background())
216 defer cancel()
217 s.InspectMountpoints(ctx)
218 go s.probeLoop(ctx)
219 go s.diskLoop(ctx)
220 if err := s.Serve(ctx, ln); err != nil {
221 log.Fatalf("instance-control: %v", err)
222 }
223}
224
225// Serve accepts connections, asks PeerOK about each, and hands the accepted
226// ones to the HTTP server one at a time through a listener that only ever
227// yields vetted connections.
228func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
229 vetted := &vettedListener{Listener: ln, ok: s.peerOK, logf: s.logf}
230 srv := &http.Server{Handler: s.Handler(), ReadHeaderTimeout: 5 * time.Second, ConnContext: s.connContext}
231 // One request per connection (#292): a connection's accept order is the
232 // request's (connContext), which the legacy start fence relies on, so a
233 // client may not reuse one. The agent's client never does.
234 srv.SetKeepAlivesEnabled(false)
235 go func() {
236 <-ctx.Done()
237 srv.Close()
238 }()
239 err := srv.Serve(vetted)
240 if errors.Is(err, http.ErrServerClosed) || ctx.Err() != nil {
241 return nil
242 }
243 return err
244}
245
246// vettedListener runs the peer check inside Accept so a refused peer is
247// closed before net/http ever reads from it.
248type vettedListener struct {
249 net.Listener
250 ok func(net.Conn) error
251 logf func(string, ...any)
252
253 // lastLog and suppressed rate-limit the refusal log to at most one
254 // line per second: a refusing peer that reconnects in a tight loop
255 // (the common shape while carlos-edge.service has no MainPID yet)
256 // would otherwise flood the log at the same rate. Accept is only ever
257 // called from the one goroutine net/http.Server.Serve runs its accept
258 // loop on, so these need no lock of their own.
259 lastLog time.Time
260 suppressed int
261}
262
263func (v *vettedListener) Accept() (net.Conn, error) {
264 for {
265 c, err := v.Listener.Accept()
266 if err != nil {
267 return nil, err
268 }
269 if err := v.ok(c); err != nil {
270 v.logRefusal(err)
271 c.Close()
272 continue
273 }
274 return c, nil
275 }
276}
277
278func (v *vettedListener) logRefusal(err error) {
279 now := time.Now()
280 if !v.lastLog.IsZero() && now.Sub(v.lastLog) < time.Second {
281 v.suppressed++
282 return
283 }
284 if v.suppressed > 0 {
285 v.logf("instance-control: refused connection: %v (%d more refusals suppressed)", err, v.suppressed)
286 } else {
287 v.logf("instance-control: refused connection: %v", err)
288 }
289 v.lastLog = now
290 v.suppressed = 0
291}
292
293func (s *Server) logf(format string, args ...any) {
294 if s.Logf != nil {
295 s.Logf(format, args...)
296 }
297}
298
299// peerOK calls through s.PeerOK per connection (never captured into a
300// closure at Serve start) so a caller that sets PeerOK after Serve has
301// begun — a test, or a production reconfiguration — takes effect on the
302// very next Accept instead of being silently ignored (codex review,
303// fix round 2 item 1).
304func (s *Server) peerOK(c net.Conn) error { return s.PeerOK(c) }
305
306func (s *Server) Handler() http.Handler {
307 mux := http.NewServeMux()
308 if s.ClaimStop != nil {
309 mux.Handle("POST /claim/stop", s.ClaimStop)
310 mux.Handle("POST /claim/retire", s.ClaimStop)
311 mux.Handle("POST /claim/handover", s.ClaimStop)
312 mux.Handle("POST /claim/recovery-rebind", s.ClaimStop)
313 mux.Handle("POST /claim/journal", s.ClaimStop)
314 mux.Handle("POST /claim/execution", s.ClaimStop)
315 mux.Handle("POST /claim/writers-stopped", s.ClaimStop)
316 mux.Handle("POST /claim/launch", s.ClaimStop)
317 }
318 if s.ClaimHeldRelease != nil {
319 // moves Task 7b: the held release under the claim, only with both
320 // flags on (item 6); off, the route is absent as on main.
321 mux.Handle("POST /claim/held-release", s.ClaimHeldRelease)
322 }
323 mux.HandleFunc("POST /start", s.claimAwareLegacy(s.handleStart))
324 mux.HandleFunc("POST /seed", s.claimAwareLegacy(s.handleSeed))
325 mux.HandleFunc("POST /stop", s.claimAwareLegacy(s.handleStop))
326 mux.HandleFunc("POST /reset", s.claimAwareLegacy(s.handleReset))
327 mux.HandleFunc("POST /sweep", s.claimAwareLegacy(s.handleSweep))
328 mux.HandleFunc("POST /parent", s.claimAwareLegacy(s.handleParent))
329 mux.HandleFunc("POST /cmd", s.claimAwareLegacy(s.handleCmd))
330 mux.HandleFunc("GET /cmd/{host}", s.claimAwareLegacy(s.handleCmdStatus))
331 mux.HandleFunc("GET /ready", s.handleReady)
332 return mux
333}
334
335// request is the validated form of a verb's query: nothing else reaches a
336// unit name.
337type request struct {
338 host string
339 role Role
340 tier cgroup.Limit
341 gen uint64 // a generation of the run role (zero-downtime spec §7.6); 0 is none
342 kill bool
343 exe StagedExe // a staged build to mount (zero-downtime plan 3); Path "" is none
344}
345
346// verbShape is what a verb's query may carry.
347type verbShape struct {
348 roles []Role
349 tier bool
350 gen bool
351 kill bool
352 exe bool // a start, seed or cmd verb: the staged build its unit binds
353 // seed is the /seed verb, which names no role: its unit is the seed
354 // unit, which may bind a staged build (settings spec §4.2).
355 seed bool
356 // cmd is /cmd, whose staged build is the serving generation's, as the
357 // legacy sidecar's is (run spec §5.4 under zero-downtime spec §7.5).
358 cmd bool
359}
360
361func parseRequest(r *http.Request, v verbShape) (request, error) {
362 q := r.URL.Query()
363 host := q.Get("host")
364 if !provision.ValidHostShape(host) {
365 return request{}, fmt.Errorf("host %q is not a host", host)
366 }
367 req := request{host: host}
368 if len(v.roles) > 0 {
369 role, ok := ParseRole(q.Get("role"))
370 if !ok {
371 return request{}, fmt.Errorf("role %q is not run, seed, sidecar or cmd", q.Get("role"))
372 }
373 allowed := false
374 for _, r := range v.roles {
375 allowed = allowed || r == role
376 }
377 if !allowed {
378 return request{}, fmt.Errorf("role %s is not valid for this verb", role)
379 }
380 req.role = role
381 }
382 if v.tier {
383 n, err := strconv.ParseInt(q.Get("memory_max"), 10, 64)
384 if err != nil {
385 return request{}, fmt.Errorf("memory_max %q is not a number", q.Get("memory_max"))
386 }
387 switch n {
388 case cgroup.TierDefault.MemoryMax, cgroup.TierChildren.MemoryMax:
389 req.tier = cgroup.Limit{MemoryMax: n}
390 default:
391 return request{}, fmt.Errorf("memory_max %d is not a platform tier", n)
392 }
393 }
394 if q.Has("gen") {
395 raw := q.Get("gen")
396 if !v.gen {
397 return request{}, errors.New("this verb takes no generation")
398 }
399 n, err := strconv.ParseUint(raw, 10, 64)
400 if err != nil || n == 0 || strconv.FormatUint(n, 10) != raw {
401 return request{}, fmt.Errorf("gen %q is not a generation", raw)
402 }
403 if req.role != RoleRun {
404 return request{}, fmt.Errorf("role %s has no generations", req.role)
405 }
406 req.gen = n
407 }
408 if q.Has("kill") {
409 if !v.kill || req.gen == 0 || q.Get("kill") != "1" {
410 return request{}, errors.New("kill=1 belongs to a generation stop only")
411 }
412 req.kill = true
413 }
414 if q.Has("exe") || q.Has("version") || q.Has("digest") {
415 if !v.exe {
416 return request{}, errors.New("this verb takes no staged executable")
417 }
418 if x := q.Get("exe"); x != "" {
419 // A generation start; the legacy sidecar (zero-downtime spec
420 // §7.5: it runs its instance's serving artifact); a run's
421 // command, which runs that same artifact; or the original
422 // template's run unit and the seed unit, which run the build a
423 // cold wake recorded (settings spec §4.2). Every one is
424 // validated before anything is laid out (refuseStagedExe).
425 if req.gen == 0 && !v.seed && !v.cmd && req.role != RoleRun && req.role != RoleSidecar {
426 return request{}, errors.New("exe is only for a run, seed or sidecar unit, a run's command, or a generation start")
427 }
428 req.exe = StagedExe{Path: x, Version: q.Get("version"), Digest: q.Get("digest")}
429 } else {
430 return request{}, errors.New("version and digest need an exe")
431 }
432 }
433 return req, nil
434}
435
436// setTier writes the whole tier onto the host slice in one call: the
437// memory caps, and the CPU and IO shares that follow from the same tier
438// (#214 item 1). Seed, instance and sidecar share all of it.
439func (s *Server) setTier(ctx context.Context, host string, tier cgroup.Limit) error {
440 return sliceTier(ctx, s.Systemctl, host, tier)
441}
442
443// sliceTier is setTier's one systemctl call, shared with the claim launch.
444func sliceTier(ctx context.Context, systemctl func(context.Context, ...string) (string, error), host string, tier cgroup.Limit) error {
445 max := strconv.FormatInt(tier.MemoryMax, 10)
446 high := strconv.FormatInt(cgroup.HighOf(tier.MemoryMax), 10)
447 sh := cgroup.SharesOf(tier)
448 _, err := systemctl(ctx, "set-property", "--runtime", SliceName(host),
449 "MemoryMax="+max, "MemoryHigh="+high, "MemorySwapMax="+max,
450 "CPUWeight="+strconv.Itoa(sh.CPUWeight), "CPUQuota="+strconv.Itoa(sh.CPUQuotaPercent)+"%",
451 "IOWeight="+strconv.Itoa(sh.IOWeight))
452 return err
453}
454
455func writeJSON(w http.ResponseWriter, status int, v any) {
456 w.Header().Set("Content-Type", "application/json")
457 w.WriteHeader(status)
458 _ = json.NewEncoder(w).Encode(v)
459}
460
461func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
462 req, err := parseRequest(r, verbShape{roles: []Role{RoleRun, RoleSidecar}, tier: true, gen: true, exe: true})
463 if err != nil {
464 http.Error(w, err.Error(), http.StatusBadRequest)
465 return
466 }
467 if req.gen != 0 {
468 s.startGen(w, r, req)
469 return
470 }
471 ctx := r.Context()
472 if req.role == RoleRun {
473 // An attempt a /stop of this host can cancel and join, and that a
474 // stop which has already run refuses (legacystart.go, #292).
475 attempt, finish, err := s.beginLegacyStart(ctx, req.host, requestSeq(ctx))
476 if err != nil {
477 http.Error(w, "start: "+err.Error(), http.StatusConflict)
478 return
479 }
480 defer finish()
481 ctx = attempt
482 }
483 unit := UnitName(req.role, req.host)
484 // A sidecar naming its generation's staged build, or a run unit naming
485 // the build its cold wake recorded, is validated before anything is
486 // laid out, under the generation rule (validateStagedExe).
487 if s.refuseStagedExe(w, req, unit) {
488 return
489 }
490 if req.role == RoleRun {
491 if err := s.clearLegacyClaimLaunch(ctx, req.host); err != nil {
492 http.Error(w, "start: "+err.Error(), http.StatusBadGateway)
493 return
494 }
495 }
496 if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExeAt(req.host, req.exe) }) {
497 return
498 }
499 // A sidecar start names its instance's staged build with generations
500 // on or off (settings ruling R-EXEC-SIDECAR). With them on, the host
501 // slice is the generation parent (spec §7.3): a swap may have raised it
502 // to two tiers, and lowering it here would put both generations under
503 // one tier's limit. Its restore is the swap owner's, so a populated
504 // slice is left alone, exactly as startGen does; a cold slice still gets
505 // the tier. With them off, a populated slice was tiered by the
506 // instance's own run start of that same recorded build, so leaving it
507 // alone keeps the right tier too. A start without exe (a live-link
508 // sidecar) sets the tier as before. The exemption is the sidecar's
509 // alone: a run unit naming its recorded build starts a cold instance,
510 // and sets its tier as a run start always has.
511 if req.role == RoleSidecar && req.exe.Path != "" && s.slicePopulated(req.host) {
512 s.logf("instance-control: %s: slice populated, leaving its limit to the generation owner", req.host)
513 } else if err := s.setTier(ctx, req.host, req.tier); err != nil {
514 http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway)
515 return
516 }
517 // Never submitted once cancelled (#292): a /stop of this host cancels
518 // the attempt before it joins it, and the client may have gone.
519 if err := ctx.Err(); err != nil {
520 http.Error(w, "start: cancelled before it was submitted: "+err.Error(), http.StatusConflict)
521 return
522 }
523 if out, err := s.Systemctl(ctx, "start", unit); err != nil {
524 http.Error(w, "start: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
525 return
526 }
527 inv, err := s.Systemctl(ctx, "show", "-p", "InvocationID", "--value", unit)
528 if err != nil {
529 http.Error(w, "show: "+err.Error(), http.StatusBadGateway)
530 return
531 }
532 s.logf("instance-control: %s: started %s memory.max=%d", req.host, unit, req.tier.MemoryMax)
533 writeJSON(w, http.StatusOK, StartResponse{InvocationID: strings.TrimSpace(inv)})
534}
535
536// parseShow reads systemctl show's KEY=value lines.
537func parseShow(out string) map[string]string {
538 m := map[string]string{}
539 sc := bufio.NewScanner(strings.NewReader(out))
540 for sc.Scan() {
541 if k, v, ok := strings.Cut(sc.Text(), "="); ok {
542 m[k] = strings.TrimSpace(v)
543 }
544 }
545 return m
546}
547
548// handleSeed runs the seed oneshot once and returns its retained result.
549// The unit's InvocationID is read before and after the first `start`: if
550// start left the SAME invocation in place, an earlier request's stop
551// never ran (its client disconnected before this fix existed, or the
552// agent restarted mid-seed), so this handler stops the stale run and
553// starts again rather than reading and returning someone else's result.
554//
555// The request context ctx is used only up to and including that first
556// `start`. From the moment it returns, the unit is this server's
557// responsibility to clean up, whether or not the client that asked for
558// it is still connected: `start` is the blocking call, and production's
559// Systemctl is exec.CommandContext, which kills the systemctl process
560// outright when its context is cancelled. A client that hung up while
561// `start` was running would otherwise cancel every systemctl call after
562// it too: the very next `show` would fail, the handler would 502, and
563// the cleanup `stop` after it would never run (fix round 1, item 4). So
564// every call from here to the end of the handler runs on ONE context
565// (dctx) detached from the request instead. The order is show (the
566// fields RemainAfterExit retains), then stop, then wait for the cgroup
567// to empty: a populated cgroup after the wait is still reported as a 502
568// naming the unit.
569//
570// A seed may name its executable (settings spec §4.2): the staged build
571// the wake recorded. Root validates it as it does a generation's (403 on
572// a refusal, before anything is laid out or started) and binds the host's
573// app mount to it. Without one the mount is the live link, as before.
574func (s *Server) handleSeed(w http.ResponseWriter, r *http.Request) {
575 req, err := parseRequest(r, verbShape{tier: true, exe: true, seed: true})
576 if err != nil {
577 http.Error(w, err.Error(), http.StatusBadRequest)
578 return
579 }
580 ctx := r.Context()
581 unit := UnitName(RoleSeed, req.host)
582 if s.refuseStagedExe(w, req, unit) {
583 return
584 }
585 if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExeAt(req.host, req.exe) }) {
586 return
587 }
588 if err := s.setTier(ctx, req.host, req.tier); err != nil {
589 http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway)
590 return
591 }
592
593 // A oneshot's start job completes when ExecStart exits; RemainAfterExit
594 // keeps the unit loaded with its result until the stop below.
595 invBefore := s.showValue(ctx, "InvocationID", unit)
596 startOut, startErr := s.Systemctl(ctx, "start", unit)
597
598 // See the handler doc comment: everything past this point belongs to
599 // the server, not the client, so it runs on a context of the server's
600 // own rather than one the client's disconnect can cancel.
601 dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
602 defer cancel()
603
604 invAfter := s.showValue(dctx, "InvocationID", unit)
605 if invBefore != "" && invBefore == invAfter {
606 s.stopUnit(dctx, unit)
607 startOut, startErr = s.Systemctl(dctx, "start", unit)
608 if retry := s.showValue(dctx, "InvocationID", unit); retry == "" || retry == invAfter {
609 // Still this server's unit to clean up even though the request
610 // is about to fail: leaving it running would strand the NEXT
611 // /seed on this same stale-invocation guard forever.
612 s.stopUnit(dctx, unit)
613 http.Error(w, "seed: "+unit+" would not leave its stale invocation "+invAfter, http.StatusBadGateway)
614 return
615 }
616 }
617
618 out, err := s.Systemctl(dctx, "show", "-p", "InvocationID,Result,ExecMainStatus,ExecMainCode", unit)
619 if err != nil {
620 http.Error(w, "show: "+err.Error(), http.StatusBadGateway)
621 return
622 }
623 m := parseShow(out)
624 s.stopUnit(dctx, unit)
625 wctx, waitCancel := context.WithTimeout(dctx, s.waitBudget())
626 defer waitCancel()
627 if err := WaitCgroupEmpty(wctx, s.CgroupRoot, SliceName(req.host), unit, 50*time.Millisecond); err != nil {
628 http.Error(w, "seed: "+err.Error(), http.StatusBadGateway)
629 return
630 }
631 if startErr != nil && m["Result"] == "" {
632 http.Error(w, "start: "+strings.TrimSpace(startOut+" "+startErr.Error()), http.StatusBadGateway)
633 return
634 }
635 writeJSON(w, http.StatusOK, SeedResponse{InvocationID: m["InvocationID"], Result: m["Result"], Status: m["ExecMainStatus"], Code: m["ExecMainCode"]})
636}
637
638// showValue reads one systemd property's --value for unit, returning "" on
639// any error. It is a probe, not a hard failure path: the caller's own
640// later show call is what surfaces a real systemctl error to the client.
641func (s *Server) showValue(ctx context.Context, prop, unit string) string {
642 out, err := s.Systemctl(ctx, "show", "-p", prop, "--value", unit)
643 if err != nil {
644 return ""
645 }
646 return strings.TrimSpace(out)
647}
648
649// stopUnit issues `systemctl stop` for unit on ctx, logging rather than
650// surfacing a failure to the caller: every call site in handleSeed is
651// already past the point where the unit is this server's responsibility,
652// not something a failed stop should turn into a different HTTP error for
653// the client.
654func (s *Server) stopUnit(ctx context.Context, unit string) {
655 if _, err := s.Systemctl(ctx, "stop", unit); err != nil {
656 s.logf("instance-control: stop %s: %v", unit, err)
657 }
658}
659
660func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
661 req, err := parseRequest(r, verbShape{roles: []Role{RoleRun, RoleSeed, RoleSidecar, RoleCmd}, gen: true, kill: true})
662 if err != nil {
663 http.Error(w, err.Error(), http.StatusBadRequest)
664 return
665 }
666 if req.gen != 0 {
667 s.stopGen(w, r, req)
668 return
669 }
670 if req.role == RoleRun {
671 // Every start of this host's run unit in flight is cancelled and
672 // joined first, and earlier requests' starts are refused from here
673 // on (legacystart.go, #292): once this answers, no start from an
674 // earlier request can still be submitted, and the stop below
675 // replaces any job a joined start had submitted.
676 release, err := s.fenceLegacyStarts(r.Context(), req.host)
677 defer release()
678 if err != nil {
679 // Stopped anyway, but not proven final: a start may follow.
680 _, _ = s.Systemctl(r.Context(), "stop", UnitName(req.role, req.host))
681 http.Error(w, "stop: "+err.Error(), http.StatusBadGateway)
682 return
683 }
684 }
685 if out, err := s.Systemctl(r.Context(), "stop", UnitName(req.role, req.host)); err != nil {
686 http.Error(w, "stop: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
687 return
688 }
689 if req.role == RoleRun {
690 if err := s.clearLegacyClaimLaunch(r.Context(), req.host); err != nil {
691 http.Error(w, "stop: "+err.Error(), http.StatusBadGateway)
692 return
693 }
694 }
695 if req.role == RoleCmd {
696 s.cleanCmdExe(r.Context(), req.host)
697 }
698 w.WriteHeader(http.StatusNoContent)
699}
700
701func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
702 req, err := parseRequest(r, verbShape{roles: []Role{RoleRun, RoleSeed, RoleSidecar, RoleCmd}, gen: true})
703 if err != nil {
704 http.Error(w, err.Error(), http.StatusBadRequest)
705 return
706 }
707 unit := UnitName(req.role, req.host)
708 if req.gen != 0 {
709 unit = GenUnitName(req.host, req.gen)
710 }
711 if out, err := s.Systemctl(r.Context(), "reset-failed", unit); err != nil && !strings.Contains(out+err.Error(), "not loaded") {
712 http.Error(w, "reset-failed: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway)
713 return
714 }
715 w.WriteHeader(http.StatusNoContent)
716}
717
718// handleSweep stops every instance of the four templates that is not
719// inactive or has a job, keyed on the names systemd reports, so a unit
720// whose identifier is hashed and whose row is gone is still stopped
721// (design §6.7, codex R7-1).
722func (s *Server) handleSweep(w http.ResponseWriter, r *http.Request) {
723 ctx := r.Context()
724 targets := map[string]bool{}
725 out, err := s.Systemctl(ctx, "list-units", "--all", "--plain", "--no-legend", "--no-pager",
726 UnitPrefixRun+"*.service", UnitPrefixSeed+"*.service", UnitPrefixSidecar+"*.service", UnitPrefixCmd+"*.service", UnitPrefixGen+"*.service")
727 if err != nil {
728 http.Error(w, "list-units: "+err.Error(), http.StatusBadGateway)
729 return
730 }
731 for _, line := range strings.Split(out, "\n") {
732 f := strings.Fields(line)
733 if len(f) < 3 {
734 continue
735 }
736 if _, _, ok := ParseUnitName(f[0]); !ok {
737 continue
738 }
739 if f[2] != "inactive" {
740 targets[f[0]] = true
741 }
742 }
743 jobs, err := s.Systemctl(ctx, "list-jobs", "--plain", "--no-legend", "--no-pager")
744 if err != nil {
745 http.Error(w, "list-jobs: "+err.Error(), http.StatusBadGateway)
746 return
747 }
748 for _, line := range strings.Split(jobs, "\n") {
749 f := strings.Fields(line)
750 if len(f) < 2 {
751 continue
752 }
753 if _, _, ok := ParseUnitName(f[1]); ok {
754 targets[f[1]] = true
755 }
756 }
757 // Every target gets stopped and waited on; a stuck unit is recorded and
758 // the sweep continues to the rest rather than aborting on the first one
759 // (design §6.7, codex review fix round 2 item 3): a sweep exists to
760 // clear the box for the next thing, and giving up after unit one would
761 // leave every unit after it running.
762 var stopped []string
763 failed := map[string]string{}
764 for unit := range targets {
765 wctx, cancel := context.WithTimeout(ctx, s.waitBudget())
766 if _, err := s.Systemctl(ctx, "stop", unit); err != nil {
767 failed[unit] = "stop: " + err.Error()
768 cancel()
769 continue
770 }
771 // A unit that is still activating has no cgroup yet; wait for
772 // systemd to settle it to inactive or failed before trusting an
773 // absent cgroup as "swept" (codex review fix round 2 item 4).
774 if err := s.waitInactiveOrFailed(wctx, unit, 50*time.Millisecond); err != nil {
775 failed[unit] = err.Error()
776 cancel()
777 continue
778 }
779 _, esc, _ := ParseUnitName(unit)
780 err := WaitCgroupEmpty(wctx, s.CgroupRoot, "carlos-instances-"+esc+".slice", unit, 50*time.Millisecond)
781 cancel()
782 if err != nil {
783 failed[unit] = err.Error()
784 continue
785 }
786 stopped = append(stopped, unit)
787 s.removeGenFiles(unit)
788 if role, esc, _ := ParseUnitName(unit); role == RoleCmd {
789 if iid, ok := Unescape(esc); ok {
790 s.removeCmdExe(iid)
791 }
792 }
793 }
794 s.sweepGenLeftovers(ctx, failed)
795 if len(failed) > 0 {
796 writeJSON(w, http.StatusBadGateway, SweepResponse{Units: stopped, Failed: failed})
797 return
798 }
799 writeJSON(w, http.StatusOK, SweepResponse{Units: stopped})
800}
801
802// waitInactiveOrFailed polls a unit's ActiveState until systemd reports it
803// settled (inactive after a clean stop, failed after one that wasn't),
804// bounded by ctx.
805func (s *Server) waitInactiveOrFailed(ctx context.Context, unit string, every time.Duration) error {
806 var last string
807 for {
808 if out, err := s.Systemctl(ctx, "show", "-p", "ActiveState", "--value", unit); err == nil {
809 last = strings.TrimSpace(out)
810 if last == "inactive" || last == "failed" {
811 return nil
812 }
813 }
814 select {
815 case <-ctx.Done():
816 return fmt.Errorf("%s: ActiveState still %q", unit, last)
817 case <-time.After(every):
818 }
819 }
820}
821
822func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
823 if s.Build != "" {
824 w.Header().Set(BuildHeader, s.Build)
825 }
826 rep, ready := s.Ready()
827 if ready {
828 w.WriteHeader(http.StatusNoContent)
829 return
830 }
831 writeJSON(w, http.StatusServiceUnavailable, rep)
832}
833
834// Ready returns the last probe verdict, plus the one check that cannot
835// wait for the next probe: that this process still runs the installed
836// binary (ExeCurrent). Before the first probe has ever run, there is no
837// verdict to report, so a synthetic line names that reason explicitly
838// rather than answering with an empty Lines (final review, plan 2, item
839// 6): a caller reading /ready during startup should see why, not a report
840// that looks like every check silently passed.
841func (s *Server) Ready() (ProbeReport, bool) {
842 s.mu.Lock()
843 rep, probed := s.report, s.probed
844 s.mu.Unlock()
845 if !probed {
846 rep = ProbeReport{Lines: []VerdictLine{{Check: "probe", Verdict: "fail", Detail: "not yet run"}}}
847 }
848 if s.ExeCurrent != nil {
849 if err := s.ExeCurrent(); err != nil {
850 // A copy: the stored report must not grow a line per call.
851 rep = ProbeReport{Ready: false, Lines: append(append([]VerdictLine(nil), rep.Lines...),
852 VerdictLine{Check: "binary", Verdict: "fail", Detail: err.Error()})}
853 }
854 }
855 return rep, rep.Ready
856}
857
858// installedBinaryCurrent is ExeCurrent in production: /proc/self/exe
859// resolves to the file this process was started from, even after that
860// file was renamed over (the kernel reports it as deleted), and
861// os.SameFile compares device and inode, so a service started from the
862// previous release's binary answers a mismatch the moment carlos system
863// apply renames the new one into place, and keeps answering it until it
864// is restarted (#256).
865func installedBinaryCurrent() error {
866 running, err := os.Stat("/proc/self/exe")
867 if err != nil {
868 return fmt.Errorf("stat /proc/self/exe: %v", err)
869 }
870 installed, err := os.Stat(SystemBinary)
871 if err != nil {
872 return fmt.Errorf("stat %s: %v", SystemBinary, err)
873 }
874 if !os.SameFile(running, installed) {
875 return fmt.Errorf("instance control is running a binary that is no longer %s; restart carlos-instance-control.service", SystemBinary)
876 }
877 return nil
878}
879
880func (s *Server) waitBudget() time.Duration {
881 if s.cgroupWaitBudget > 0 {
882 return s.cgroupWaitBudget
883 }
884 return 30 * time.Second
885}
886
887// WaitCgroupEmpty waits until the unit's cgroup under the host slice is
888// absent or reports "populated 0" in cgroup.events, polling every `every`,
889// bounded by ctx and the caller's budget (design §6.5, codex R2-5).
890func WaitCgroupEmpty(ctx context.Context, root, slice, unit string, every time.Duration) error {
891 events := filepath.Join(root, parentSlices, slice, unit, "cgroup.events")
892 for {
893 b, err := os.ReadFile(events)
894 switch {
895 case errors.Is(err, os.ErrNotExist):
896 return nil
897 case err != nil:
898 return fmt.Errorf("%s: %w", unit, err)
899 case strings.Contains(string(b), "populated 0"):
900 return nil
901 }
902 select {
903 case <-ctx.Done():
904 return fmt.Errorf("processes remain in %s", unit)
905 case <-time.After(every):
906 }
907 }
908}
909
910// probeLoop runs the probe at start and every ProbeEvery.
911func (s *Server) probeLoop(ctx context.Context) {
912 s.RunProbe(ctx)
913 t := time.NewTicker(s.ProbeEvery)
914 defer t.Stop()
915 for {
916 select {
917 case <-ctx.Done():
918 return
919 case <-t.C:
920 s.RunProbe(ctx)
921 }
922 }
923}
924
925// RunProbe starts the probe unit (a oneshot: start blocks until it exits),
926// reads the report it left, and stores the verdict. A fixture that is not
927// root:carlos 0640, a probe that could not be started, or a report that
928// could not be read all leave the box not ready, with the reason on a
929// verdict line; readiness is never the default.
930func (s *Server) RunProbe(ctx context.Context) {
931 set := func(r ProbeReport) {
932 s.mu.Lock()
933 s.report, s.probed = r, true
934 s.mu.Unlock()
935 }
936 if err := s.FixtureOK(); err != nil {
937 set(ProbeReport{Lines: []VerdictLine{{Check: "fixture", Verdict: "fail", Detail: err.Error()}}})
938 return
939 }
940 // The probe binds the same /opt view an instance does, so its sources
941 // must exist (#214 item 2), and the resolver allow it checks must be
942 // in place (#214 item 3).
943 if s.ExeRoot != "" {
944 if err := ensureExe(s.ExeRoot, ProbeDirName, SystemBinary, ProbeDirName); err != nil {
945 set(ProbeReport{Lines: []VerdictLine{{Check: "probe-exe", Verdict: "fail", Detail: err.Error()}}})
946 return
947 }
948 }
949 var resolverLines []VerdictLine
950 if s.ResolvConf != "" {
951 refused, err := s.allowResolvers(ctx)
952 if err != nil {
953 set(ProbeReport{Lines: []VerdictLine{{Check: "resolver-allow", Verdict: "fail", Detail: err.Error()}}})
954 return
955 }
956 resolverLines = append(resolverLines, resolverLocalLine(refused))
957 }
958 if out, err := s.Systemctl(ctx, "start", probeUnit); err != nil {
959 set(ProbeReport{Lines: []VerdictLine{{Check: "probe-unit", Verdict: "fail", Detail: strings.TrimSpace(out + " " + err.Error())}}})
960 return
961 }
962 b, err := os.ReadFile(s.ProbeOut)
963 if err != nil {
964 set(ProbeReport{Lines: []VerdictLine{{Check: "probe-output", Verdict: "fail", Detail: err.Error()}}})
965 return
966 }
967 var r ProbeReport
968 if err := json.Unmarshal(b, &r); err != nil {
969 set(ProbeReport{Lines: []VerdictLine{{Check: "probe-output", Verdict: "fail", Detail: err.Error()}}})
970 return
971 }
972 for _, l := range resolverLines {
973 r.Lines = append(r.Lines, l)
974 if l.Verdict == "fail" {
975 r.Ready = false
976 }
977 }
978 for _, l := range r.Lines {
979 if l.Verdict != "ok" {
980 s.logf("instance-control: probe: %s %s %s", l.Check, l.Verdict, l.Detail)
981 }
982 }
983 set(s.withStartLines(r))
984}
985
986// mountpoints are the two bind mounts whose underlying directories
987// inspectMountpoints looks under (design §8.2, "Recovery for a layout that
988// was unmounted anyway").
989var mountpoints = []struct{ name, where string }{
990 {"data-instances", "/data/instances"},
991 {"run-carlos-instances", "/run/carlos/instances"},
992}
993
994// InspectMountpoints runs inspectMountpoints once and keeps its lines for
995// every later report. Run calls it before the probe loop starts.
996func (s *Server) InspectMountpoints(ctx context.Context) {
997 lines := s.inspectMountpoints(ctx)
998 for _, l := range lines {
999 if l.Verdict != "ok" {
1000 s.logf("instance-control: %s %s %s", l.Check, l.Verdict, l.Detail)
1001 }
1002 }
1003 s.mu.Lock()
1004 s.startLines = lines
1005 s.mu.Unlock()
1006}
1007
1008// inspectMountpoints looks UNDER the two bind mounts: in a private mount
1009// namespace it unmounts each bind and lists the raw mountpoint directory.
1010// Anything there was written while the layout was unmounted (an operator
1011// removing units by hand, a boot without the mounts), and the layout
1012// pass's destination check would otherwise never see it: the mount hides
1013// it, and the old binary would restore a second database over it on the
1014// next unmount. The verdict is taken once at start, as root, because
1015// unmounting in a private namespace needs CAP_SYS_ADMIN; it clears only
1016// when an operator moves or removes the files and restarts the service.
1017// An inspection that cannot run (no unshare, no permission) is
1018// unverified, and does not block readiness on its own: the probe's own
1019// checks stand, and the unverified line is in every report for the
1020// operator to see.
1021func (s *Server) inspectMountpoints(ctx context.Context) []VerdictLine {
1022 if s.Run == nil {
1023 return nil
1024 }
1025 var lines []VerdictLine
1026 for _, m := range mountpoints {
1027 // The listing runs after the umount so it reads the underlying
1028 // directory; a umount that fails because nothing is mounted there
1029 // is fine (the listing is then of the directory itself, which is
1030 // what an unmounted layout looks like), so its error is dropped.
1031 out, err := s.Run(ctx, "unshare", "-m", "--propagation", "private", "sh", "-c",
1032 "umount "+m.where+" 2>/dev/null; ls -A "+m.where)
1033 if err != nil {
1034 lines = append(lines, VerdictLine{Check: "mountpoint-" + m.name, Verdict: "unverified", Detail: strings.TrimSpace(out + " " + err.Error())})
1035 continue
1036 }
1037 if n := len(strings.Fields(out)); n > 0 {
1038 lines = append(lines, VerdictLine{Check: "mountpoint-" + m.name, Verdict: "fail",
1039 Detail: fmt.Sprintf("mountpoint held files while unmounted: %d entries under %s; move or remove them, then restart carlos-instance-control", n, m.where)})
1040 continue
1041 }
1042 lines = append(lines, VerdictLine{Check: "mountpoint-" + m.name, Verdict: "ok"})
1043 }
1044 return lines
1045}
1046
1047// withStartLines prepends the start inspection to a probe report and
1048// recomputes Ready: the probe's own verdict, and no start line failing.
1049// A copy, so the stored report never aliases the probe's slice.
1050func (s *Server) withStartLines(r ProbeReport) ProbeReport {
1051 s.mu.Lock()
1052 start := append([]VerdictLine(nil), s.startLines...)
1053 s.mu.Unlock()
1054 if len(start) == 0 {
1055 return r
1056 }
1057 out := ProbeReport{Ready: r.Ready, Lines: append(start, r.Lines...)}
1058 for _, l := range start {
1059 if l.Verdict == "fail" {
1060 out.Ready = false
1061 }
1062 }
1063 return out
1064}
1065
1066func (s *Server) claimAwareLegacy(next http.HandlerFunc) http.HandlerFunc {
1067 return func(w http.ResponseWriter, r *http.Request) {
1068 if s.ClaimStop != nil {
1069 w.WriteHeader(http.StatusServiceUnavailable)
1070 return
1071 }
1072 next(w, r)
1073 }
1074}
1075