ADR 0060, built. `make hosts` produces mesh-host-arch, mesh-host-alpine and mesh-host-android, each pinned to its system at link time. The claim that "almost all of it is shared" held up. All 36 existing apply tests pass unchanged -- the only edit was naming which system they run against, which was previously implicit. What moved into internal/system is two appliers' worth of code and the probes that go with them. Each system's differences are real and needed re-deriving rather than translating: apk reports absence by EMPTY OUTPUT and exits zero either way, where pacman exits non-zero. Reading apk's exit code the way pacman's is read reports every package as installed. That is the single most dangerous difference between the two and it is invisible until it bites. OpenRC has no LoadState, so "the service does not exist" is read from its prose rather than a field. Same distinction, different evidence -- and this is exactly what an interface spanning both would have had to drop, which is why 0060 rejected one. OpenRC has no is-enabled either. Boot state comes from the runlevel listing: "does it start at boot" becomes "does it appear in rc-update show default". Android is a partial host and that is the point. It implements file, directory and action -- the shapes needing only a filesystem and a way to run something -- and refuses the other three by name, before anything is applied. Its unreachable appliers return ErrUnsupported rather than a zero value, so "unreachable" fails loudly if it stops being true. A host also confirms it is on the machine it was built for, once, at the start. The alpine host on this Arch machine says "this machine is not Alpine" instead of failing later inside a package manager that is not there. And a host built without -X main.builtFor refuses everything, naming the hosts that exist. Two test problems found by injecting faults. One injection did not compile, so the check now reports that separately from a pass. The other passed with the behaviour removed: the missing-service assertion matched "does not exist", which the FALL-THROUGH error also contains because it echoes the raw output. It now asserts the diagnosis, which only the correct branch produces. Verified with the real binaries: android refuses a package naming what it does support; alpine on Arch refuses the machine; arch applies and is idempotent; a system-less build refuses everything.
145 lines
5.0 KiB
Go
145 lines
5.0 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// arch is pacman and systemd.
|
|
type arch struct{}
|
|
|
|
func (arch) Name() string { return "arch" }
|
|
func (arch) Shapes() []declaration.Type { return everyShape() }
|
|
|
|
func (a arch) Confirm(ctx context.Context, run Runner) error {
|
|
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
|
return fmt.Errorf(
|
|
"this is the arch host and pacman does not answer here. Either this machine is not "+
|
|
"Arch, or its package database is broken: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageInstalled asks the package database, having first established that it answers.
|
|
//
|
|
// The two-step is the trap this file exists to remember. `pacman -Q name` exits non-zero for a
|
|
// package that is not installed AND for a database that cannot be read, so believing the first
|
|
// answer reports a broken package manager as "nothing is installed" — absence read as fact.
|
|
// Proving the tool answers about something that certainly exists separates them.
|
|
func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) {
|
|
if err := a.Confirm(ctx, run); err != nil {
|
|
return false, fmt.Errorf("nothing can be said about %q: %w", name, err)
|
|
}
|
|
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
|
_, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
|
return err
|
|
}
|
|
|
|
// ServiceState reads what systemd says about a unit.
|
|
//
|
|
// Two traps, and both were hit before this read what it now reads.
|
|
//
|
|
// The exit code is not the answer: `is-active` exits non-zero for every state except active.
|
|
//
|
|
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
|
// DOES NOT EXIST exactly as it does for one installed and stopped, so declaring a unit stopped
|
|
// reported success for a unit the host cannot manage at all. LoadState is what separates them,
|
|
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
|
// would have had to drop.
|
|
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "show", unit,
|
|
"--property=LoadState", "--property=ActiveState")
|
|
|
|
var load, active string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "LoadState":
|
|
load = value
|
|
case "ActiveState":
|
|
active = value
|
|
}
|
|
}
|
|
|
|
switch load {
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
|
case "not-found":
|
|
return "", fmt.Errorf(
|
|
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
|
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
|
"absence as success", unit)
|
|
case "masked":
|
|
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
|
case "error", "bad-setting":
|
|
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
|
}
|
|
|
|
switch active {
|
|
case "active", "activating", "reloading":
|
|
return "running", nil
|
|
case "inactive", "failed", "deactivating":
|
|
return "stopped", nil
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
|
verb := "start"
|
|
if state == "stopped" {
|
|
verb = "stop"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// ServiceBoot reads whether a unit starts at boot.
|
|
//
|
|
// `is-enabled` has more than two answers, and `static` is the one that matters: the unit has no
|
|
// install section and CANNOT be enabled. Reading it as "disabled" would have the host try, fail,
|
|
// and blame the wrong thing — the same shape as reading a missing unit as "stopped".
|
|
func (arch) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "is-enabled", unit)
|
|
switch state := strings.TrimSpace(out); state {
|
|
case "enabled", "enabled-runtime", "alias":
|
|
return "enabled", nil
|
|
case "disabled":
|
|
return "disabled", nil
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit)
|
|
case "static":
|
|
return "", fmt.Errorf(
|
|
"%s is static — it has no install section, so it cannot be enabled or disabled. "+
|
|
"Something else pulls it in, and that is what a declaration should name", unit)
|
|
case "masked", "masked-runtime":
|
|
return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit)
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q at boot, which is neither enabled nor disabled",
|
|
unit, state)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
|
|
verb := "enable"
|
|
if boot == "disabled" {
|
|
verb = "disable"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|