docker-compose: the package, and its projects as tools (hq to-be 42 phase 2.9)
Compose and nothing else, for the two workstations, where it is already installed by hand; the runtime, buildx and the group stay the docker module's. Nine Go tools: projects (with their directories from the containers' labels), ps, logs, a rendered config with secret-looking values redacted, and up, down, restart and pull by directory or name. Acts run as jobs inside the bundle, waited on for 18 s and followed with docker_compose_job, because an up that pulls outlasts a call. down never removes volumes.
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user