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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// Package system is the part of the host that differs between operating systems.
|
||||
//
|
||||
// novox/hq ADR 0060. A machine has apk because it is Alpine; the package manager, the service
|
||||
// manager and the packaging format arrive together as one decision somebody made at install
|
||||
// time. So they are not independent knobs — they are one implementation, named after the system
|
||||
// it belongs to.
|
||||
//
|
||||
// Everything else in the host is shared: the declaration vocabulary, the store, the apply loop,
|
||||
// the read-back discipline, the refusal model, the link. What lives here is two appliers' worth
|
||||
// of difference and the probes that go with them.
|
||||
//
|
||||
// Not abstracted behind a lowest common denominator, deliberately. `systemctl show` reports a
|
||||
// LoadState that separates *not installed* from *stopped*, and OpenRC has no equivalent — an
|
||||
// interface spanning both would have to drop it, and dropping it is how absence gets reported
|
||||
// as success. Each system says what it can say.
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// Runner executes a command. The real one runs a process; tests pass one that records what was
|
||||
// asked for, because what is being tested is which commands each system issues.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// ErrUnsupported is what a system returns for a shape it cannot implement.
|
||||
//
|
||||
// Not an error in the ordinary sense — an Android host declining to install packages is
|
||||
// correct, not broken. It is refused at the declaration rather than attempted and failed, so a
|
||||
// control plane learns the difference from the profile instead of from a stack trace.
|
||||
var ErrUnsupported = errors.New("this host does not implement that")
|
||||
|
||||
// System is one operating system's half of the host.
|
||||
type System interface {
|
||||
// Name is what this host was built for: "arch", "alpine", "android".
|
||||
Name() string
|
||||
|
||||
// Shapes are the declaration types this host can apply. Anything else is refused whole.
|
||||
Shapes() []declaration.Type
|
||||
|
||||
// Confirm proves this is the system the host was built for.
|
||||
//
|
||||
// A host installed on the wrong machine must say so, not discover it by calling a package
|
||||
// manager that is not there. The failure is legible exactly once, at start.
|
||||
Confirm(ctx context.Context, run Runner) error
|
||||
|
||||
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
|
||||
InstallPackage(ctx context.Context, run Runner, name string) error
|
||||
|
||||
// ServiceState is "running" or "stopped". A unit that does not exist is an error, never
|
||||
// "stopped" — reporting absence as satisfaction is the fault this host exists to prevent.
|
||||
ServiceState(ctx context.Context, run Runner, unit string) (string, error)
|
||||
SetServiceState(ctx context.Context, run Runner, unit, state string) error
|
||||
|
||||
// ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot.
|
||||
ServiceBoot(ctx context.Context, run Runner, unit string) (string, error)
|
||||
SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error
|
||||
}
|
||||
|
||||
// Supports reports whether this host can apply a shape.
|
||||
func Supports(s System, t declaration.Type) bool {
|
||||
for _, shape := range s.Shapes() {
|
||||
if shape == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check refuses a declaration naming a shape this host cannot apply.
|
||||
//
|
||||
// Refused whole and before anything is applied, which is the same treatment an unknown type
|
||||
// gets (novox/hq ADR 0043) — a host that applied the parts it understood would leave a machine
|
||||
// that looks configured and is not. The reason differs and the outcome does not.
|
||||
func Check(s System, d *declaration.Declaration) error {
|
||||
var problems []string
|
||||
seen := map[declaration.Type]bool{}
|
||||
for _, r := range d.Resources {
|
||||
t := r.Kind()
|
||||
if Supports(s, t) || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"resource %q is a %s, and the %s host does not implement that shape. This host "+
|
||||
"applies %s",
|
||||
r.Identity(), t, s.Name(), shapeList(s)))
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &declaration.RefusalError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shapeList(s System) string {
|
||||
names := make([]string, 0, len(s.Shapes()))
|
||||
for _, t := range s.Shapes() {
|
||||
names = append(names, string(t))
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// everyShape is what a host on a full operating system can apply.
|
||||
func everyShape() []declaration.Type {
|
||||
return []declaration.Type{
|
||||
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService,
|
||||
declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction,
|
||||
}
|
||||
}
|
||||
|
||||
// portableShapes need only a filesystem and a way to run something.
|
||||
//
|
||||
// The floor. A host that can do nothing else can still do these, which is what makes a partial
|
||||
// host a real thing rather than a broken one (novox/hq ADR 0060).
|
||||
func portableShapes() []declaration.Type {
|
||||
return []declaration.Type{
|
||||
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction,
|
||||
}
|
||||
}
|
||||
|
||||
// For returns the system with this name, or an error naming the ones that exist.
|
||||
func For(name string) (System, error) {
|
||||
for _, s := range All() {
|
||||
if s.Name() == name {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for _, s := range All() {
|
||||
names = append(names, s.Name())
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"this host was built for %q, which is not a system it knows. Built hosts are: %s",
|
||||
name, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// All is every system the host can be built for.
|
||||
func All() []System {
|
||||
return []System{arch{}, alpine{}, android{}}
|
||||
}
|
||||
Reference in New Issue
Block a user