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...)...) }