Files
mesh-host/internal/system/alpine.go
T
jschoubben 02f1fcc865 Three hosts: arch, alpine and android
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.
2026-08-28 01:08:11 +02:00

134 lines
4.9 KiB
Go

package system
import (
"context"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// alpine is apk and OpenRC.
//
// The intended first node. Where it differs from systemd is not cosmetic, and each difference
// below is a place where the systemd implementation's care had to be re-derived rather than
// translated.
type alpine struct{}
func (alpine) Name() string { return "alpine" }
func (alpine) Shapes() []declaration.Type { return everyShape() }
func (a alpine) Confirm(ctx context.Context, run Runner) error {
// `apk info -e apk-tools` asks the installed-package database about something that is
// certainly there. `apk --version` would prove only that a binary exists, which is the
// assumption 04-ISSUES/007 records.
if _, err := run(ctx, "apk", "info", "-e", "apk-tools"); err != nil {
return fmt.Errorf(
"this is the alpine host and apk does not answer here. Either this machine is not "+
"Alpine, or its package database is broken: %w", err)
}
return nil
}
// PackageInstalled asks apk, having first established that apk answers.
//
// `apk info -e <name>` prints the name when installed and NOTHING when not — and exits zero
// either way. So unlike pacman, the exit code cannot be used at all here: an empty answer is
// the negative. Reading the exit code would report every package as installed.
func (a alpine) 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)
}
out, err := run(ctx, "apk", "info", "-e", name)
if err != nil {
return false, nil
}
return strings.TrimSpace(out) != "", nil
}
func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error {
_, err := run(ctx, "apk", "add", "--no-cache", name)
return err
}
// ServiceState reads what OpenRC says about a service.
//
// The same trap as systemd's, and it needs answering differently because OpenRC has no
// LoadState. `rc-service <name> status` exits 3 for a stopped service and 1 for one that does
// not exist — but the exit code reaches us wrapped, so the output is read instead: OpenRC says
// "does not exist" plainly, and that distinction is the whole reason this function is not a
// one-liner.
func (alpine) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
out, err := run(ctx, "rc-service", unit, "status")
text := strings.ToLower(out + " " + errText(err))
switch {
case strings.Contains(text, "does not exist"):
return "", fmt.Errorf(
"%s does not exist on this machine. A declaration naming a service that is not "+
"installed cannot be satisfied, and reporting it stopped would be reporting "+
"absence as success", unit)
case strings.Contains(text, "status: started"), strings.Contains(text, "status: starting"):
return "running", nil
case strings.Contains(text, "status: stopped"), strings.Contains(text, "status: stopping"):
return "stopped", nil
case strings.Contains(text, "status: crashed"):
// Crashed is not running, and it is not the same as stopped either — but a
// declaration can only ask for one of two things, and the honest mapping is that the
// service is not up. Starting it is then the right next act.
return "stopped", nil
case strings.TrimSpace(text) == "":
return "", fmt.Errorf("the service manager said nothing about %s", unit)
default:
return "", fmt.Errorf(
"the service manager reports %s as %q, which is neither running nor stopped",
unit, strings.TrimSpace(out))
}
}
func (alpine) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
verb := "start"
if state == "stopped" {
verb = "stop"
}
_, err := run(ctx, "rc-service", unit, verb)
return err
}
// ServiceBoot reads whether a service is in a runlevel.
//
// OpenRC has no `is-enabled`. What it has is `rc-update show`, which lists services against the
// runlevels they are added to — so "does it start at boot" becomes "does it appear here", and
// there is no equivalent of systemd's `static` because OpenRC has no unit files without an
// install story.
func (alpine) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
out, err := run(ctx, "rc-update", "show", "default")
if err != nil {
return "", fmt.Errorf("cannot read which services start at boot: %w", err)
}
for _, line := range strings.Split(out, "\n") {
// A line looks like ` docker | default`. The name is the first field.
name, _, _ := strings.Cut(strings.TrimSpace(line), "|")
if strings.TrimSpace(name) == unit {
return "enabled", nil
}
}
return "disabled", nil
}
func (alpine) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
verb := "add"
if boot == "disabled" {
verb = "del"
}
_, err := run(ctx, "rc-update", verb, unit, "default")
return err
}
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}