Files
mesh-catalog/modules/power/cmd/power/machine.go
T

124 lines
3.8 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool runs: a manager that hangs on its bus must cost a tool
// call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// OutputCap is the most a tool answers of one command's output.
const OutputCap = 16 * 1024
// The module's places on the machine (novox/hq ADR 0211): the runner it ships, and the files the
// controller fills with every module's code for each moment.
const (
RunnerPath = "/usr/local/lib/mesh-power/bin/power-moment"
MomentsDir = "/etc/mesh-power/moments"
LogindFile = "/etc/systemd/logind.conf.d/power.conf"
UdevRule = "/etc/udev/rules.d/90-mesh-power.rules"
)
// Moments are the power moments a module may contribute code for, in the order a machine meets them.
var Moments = []string{"after-boot", "on-mains", "on-battery", "before-sleep", "after-wake", "before-shutdown"}
// Units are the module's own units and the moment each runs.
var Units = map[string]string{
"mesh-power-after-boot.service": "after-boot",
"mesh-power-before-shutdown.service": "before-shutdown",
"mesh-power-before-sleep.service": "before-sleep",
"mesh-power-after-wake.service": "after-wake",
"mesh-power-supply.service": "on-mains / on-battery",
}
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's managers.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String())
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said))
}
return out, nil
}
func capped(s string) string {
if len(s) <= OutputCap {
return s
}
return s[:OutputCap] + "\n… (cut at 16 KiB)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree)
// and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) readInt(p string) (int64, bool) {
n, err := strconv.ParseInt(m.read(p), 10, 64)
return n, err == nil
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the module's other
// action tools do: the runtime runs as the operator's account, and these files are root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}