package liveness import ( "context" "errors" "fmt" "strconv" "strings" "sync" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look // reads the machine exactly as an apply does. type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the container runtime and the service manager through their command lines: one inspect of // every container, one show of every unit per manager. **Reads only** — the whole of what this file may // ask either of them is `container inspect`, `version` and `show` (ADR 0240 rule 6, and a test holds it). type Exec struct { Run Runner mu sync.Mutex // cri is the runtime that answered, once one has. cri string } // runtimes are the container runtimes the apply knows, asked for their version in the form each // understands (apply.containerRuntimes): the first that answers is the machine's. var runtimes = []struct { command string probe []string }{ {"docker", []string{"version", "--format", "{{.Server.Version}}"}}, {"podman", []string{"version", "--format", "{{.Version}}"}}, } func (e *Exec) runtime(ctx context.Context) (string, error) { e.mu.Lock() defer e.mu.Unlock() if e.cri != "" { return e.cri, nil } var tried []string for _, rt := range runtimes { if _, err := e.Run(ctx, rt.command, rt.probe...); err == nil { e.cri = rt.command return rt.command, nil } tried = append(tried, rt.command) } return "", fmt.Errorf("no container runtime answers on this machine (tried %s)", strings.Join(tried, ", ")) } // inspectFormat is what one inspect says of each container: its name, id, status, the runtime's restart // count and when its current run started. const inspectFormat = "{{.Name}}\t{{.Id}}\t{{.State.Status}}\t{{.RestartCount}}\t{{.State.StartedAt}}" // Containers is one inspect of every container named. A name the runtime does not have is not found; // a runtime that does not answer is an error — unknown, never down. func (e *Exec) Containers(ctx context.Context, names []string) (map[string]Observed, error) { out := map[string]Observed{} if len(names) == 0 { return out, nil } cri, err := e.runtime(ctx) if err != nil { return nil, err } args := append([]string{"container", "inspect", "--format", inspectFormat}, names...) said, err := e.Run(ctx, cri, args...) if err != nil && !missing(err) { // Asked again next look, in case another runtime is what answers now. e.mu.Lock() e.cri = "" e.mu.Unlock() return nil, fmt.Errorf("the container runtime could not be read: %w", err) } for _, line := range strings.Split(strings.TrimSpace(said), "\n") { parts := strings.Split(line, "\t") if len(parts) < 5 { continue } name := strings.TrimPrefix(strings.TrimSpace(parts[0]), "/") restarts, _ := strconv.ParseInt(strings.TrimSpace(parts[3]), 10, 64) status := strings.TrimSpace(parts[2]) out[name] = Observed{Found: true, Identity: strings.TrimSpace(parts[1]), Running: status == "running", Restarting: status == "restarting", Restarts: restarts, Started: strings.TrimSpace(parts[4])} } return out, nil } // missing is an inspect that failed only because a name is not there: what it printed for the others is // still the answer. func missing(err error) bool { s := strings.ToLower(err.Error()) return strings.Contains(s, "no such container") || strings.Contains(s, "no such object") } // unitProperties are what one show says of each unit. const unitProperties = "Id,LoadState,ActiveState,SubState,NRestarts,InvocationID" // Units is one show of every unit named, in the manager it is in: the machine's, or an account's own. func (e *Exec) Units(ctx context.Context, scope, user string, units []string) (map[string]Observed, error) { out := map[string]Observed{} if len(units) == 0 { return out, nil } args := []string{"show", "--property=" + unitProperties, "--"} if scope == "user" && user != "" { args = append([]string{"--user", "--machine=" + user + "@"}, args...) } said, err := e.Run(ctx, "systemctl", append(args, units...)...) if err != nil { return nil, fmt.Errorf("the service manager could not be read: %w", err) } blocks := strings.Split(strings.TrimSpace(said), "\n\n") if len(blocks) != len(units) { return nil, errors.New("the service manager answered for a different number of units than were asked") } for i, block := range blocks { props := map[string]string{} for _, line := range strings.Split(block, "\n") { if k, v, ok := strings.Cut(line, "="); ok { props[strings.TrimSpace(k)] = strings.TrimSpace(v) } } restarts, _ := strconv.ParseInt(props["NRestarts"], 10, 64) out[units[i]] = Observed{Found: props["LoadState"] != "not-found", Identity: props["InvocationID"], Running: props["ActiveState"] == "active", Restarting: props["ActiveState"] == "activating" && props["SubState"] == "auto-restart", Restarts: restarts} } return out, nil }