Liveness alone could not see a web application whose port was open and whose program ran while every request hung for eleven hours (issue 145). A resource now carries the `health` its module declared: the engine makes http and tcp looks itself from the machine to the endpoint's published port, reads a unit's readiness from the show it already makes, hands an exec command or the image's own check to the runtime as the container's check with the declared timing and reads its state from the inspect it already makes, and asks a module's tool on its own node tools. Starting until the check passed, unhealthy once its looks after the grace fail the declared number of times; never more looks than the measured budget; nothing restarted. The statement says contract 2, which tells the controller this engine may be sent the field.
174 lines
6.1 KiB
Go
174 lines
6.1 KiB
Go
package liveness
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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, when its current run started — and the state of the check the runtime runs as the container's
|
|
// own, read out of the whole state as one line of JSON, for a check declared exec or runtime (Phase B).
|
|
// The whole state and not its Health: a container that carries no check has no such key, and the runtime
|
|
// refuses a template naming a key that is not there — for every container in the read.
|
|
const inspectFormat = "{{.Name}}\t{{.Id}}\t{{.State.Status}}\t{{.RestartCount}}\t{{.State.StartedAt}}\t{{json .State}}"
|
|
|
|
// 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])
|
|
o := Observed{Found: true, Identity: strings.TrimSpace(parts[1]), Running: status == "running",
|
|
Restarting: status == "restarting", Restarts: restarts, Started: strings.TrimSpace(parts[4])}
|
|
if len(parts) > 5 {
|
|
o.Health, o.HealthSaid = runtimeHealth(strings.Join(parts[5:], "\t"))
|
|
}
|
|
out[name] = o
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// runtimeHealth reads, from a container's state, the runtime's state of its own check: its status and what its last look
|
|
// printed, in a line. Nothing when the container carries no check.
|
|
func runtimeHealth(said string) (string, string) {
|
|
var state struct {
|
|
Health *struct {
|
|
Status string
|
|
Log []struct {
|
|
ExitCode int
|
|
Output string
|
|
}
|
|
}
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(said)), &state); err != nil || state.Health == nil ||
|
|
state.Health.Status == "" {
|
|
return "", ""
|
|
}
|
|
h := state.Health
|
|
last := ""
|
|
if n := len(h.Log); n > 0 {
|
|
l := h.Log[n-1]
|
|
last = firstLine(l.Output)
|
|
if last == "" && l.ExitCode != 0 {
|
|
last = fmt.Sprintf("its check exited %d", l.ExitCode)
|
|
}
|
|
if len(last) > 200 {
|
|
last = last[:200] + "…"
|
|
}
|
|
}
|
|
return strings.ToLower(h.Status), last
|
|
}
|
|
|
|
// 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
|
|
}
|