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,282 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// recorder answers a fixed map of commands and remembers what it was asked.
|
||||
//
|
||||
// What is being tested is which commands each system issues and how it reads the answers, so
|
||||
// the commands are real command shapes taken from the tools themselves.
|
||||
type recorder struct {
|
||||
answers map[string]string // first two argv words -> stdout
|
||||
fails map[string]bool
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (r *recorder) run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
r.calls = append(r.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
|
||||
// Two keys, most specific first. `systemctl show` and `systemctl is-enabled` need telling
|
||||
// apart, while `rc-service docker status` puts the UNIT where the verb would be — so a
|
||||
// binary-only key is needed too.
|
||||
keys := []string{name}
|
||||
if len(args) > 0 {
|
||||
keys = []string{name + " " + args[0], name}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if r.fails[key] {
|
||||
return r.answers[key], errors.New("exit status 1")
|
||||
}
|
||||
if out, ok := r.answers[key]; ok {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("exit status 127: not found")
|
||||
}
|
||||
|
||||
func sys(t *testing.T, name string) System {
|
||||
t.Helper()
|
||||
s, err := For(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestEverySystemIsNamedAndReachable(t *testing.T) {
|
||||
for _, want := range []string{"arch", "alpine", "android"} {
|
||||
if _, err := For(want); err != nil {
|
||||
t.Errorf("the %s host cannot be built: %v", want, err)
|
||||
}
|
||||
}
|
||||
if _, err := For("debian"); err == nil {
|
||||
t.Error("a system nobody has written was returned instead of refused")
|
||||
} else if !strings.Contains(err.Error(), "arch") {
|
||||
t.Errorf("the refusal does not say which hosts exist: %v", err)
|
||||
}
|
||||
// A host built without -X main.builtFor must refuse rather than default to something.
|
||||
if _, err := For(""); err == nil {
|
||||
t.Error("a host built for nothing was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// --- what each system can do -----------------------------------------------------------------
|
||||
|
||||
func TestAndroidRefusesTheShapesItCannotDo(t *testing.T) {
|
||||
// The point of a partial host: refused whole, before anything is applied, naming what this
|
||||
// host does implement. Not attempted-and-failed half way through.
|
||||
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"/data/x","content":"a\n"},
|
||||
{"id":"p","type":"package","package":"docker"}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
refusal := Check(sys(t, "android"), d)
|
||||
if refusal == nil {
|
||||
t.Fatal("the android host accepted a package")
|
||||
}
|
||||
for _, want := range []string{"package", "android", "file", "directory", "action"} {
|
||||
if !strings.Contains(refusal.Error(), want) {
|
||||
t.Errorf("the refusal does not mention %q: %v", want, refusal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidAcceptsThePortableShapes(t *testing.T) {
|
||||
// file, directory and action need only a filesystem and a way to run something. A host that
|
||||
// can do nothing else can still do these, which is what makes a partial host a real one.
|
||||
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"d","type":"directory","path":"/data/mesh"},
|
||||
{"id":"f","type":"file","path":"/data/mesh/x","content":"a\n"},
|
||||
{"id":"a","type":"action","command":["true"],"verify":["true"]}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Check(sys(t, "android"), d); err != nil {
|
||||
t.Errorf("the android host refused a portable declaration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchAndAlpineDoEveryShape(t *testing.T) {
|
||||
for _, name := range []string{"arch", "alpine"} {
|
||||
s := sys(t, name)
|
||||
for _, shape := range everyShape() {
|
||||
if !Supports(s, shape) {
|
||||
t.Errorf("the %s host does not implement %s", name, shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- confirming the machine is the one the host was built for --------------------------------
|
||||
|
||||
func TestAHostOnTheWrongMachineSaysSo(t *testing.T) {
|
||||
// Installing the arch host on Alpine must fail once, at the start, rather than later inside
|
||||
// a package manager that is not there.
|
||||
alpineMachine := &recorder{answers: map[string]string{"apk info": "apk-tools-2.14.0\n"}}
|
||||
if err := sys(t, "arch").Confirm(context.Background(), alpineMachine.run); err == nil {
|
||||
t.Fatal("the arch host confirmed itself on an Alpine machine")
|
||||
} else if !strings.Contains(err.Error(), "not Arch") {
|
||||
t.Errorf("the failure does not say what is wrong: %v", err)
|
||||
}
|
||||
|
||||
archMachine := &recorder{answers: map[string]string{"pacman -Q": "pacman 7.0.0-1\n"}}
|
||||
if err := sys(t, "alpine").Confirm(context.Background(), archMachine.run); err == nil {
|
||||
t.Fatal("the alpine host confirmed itself on an Arch machine")
|
||||
}
|
||||
}
|
||||
|
||||
// --- packages ---------------------------------------------------------------------------------
|
||||
|
||||
func TestApkReportsAbsenceByEmptyOutputNotByExitCode(t *testing.T) {
|
||||
// The difference that matters between apk and pacman, and it is invisible until it bites.
|
||||
//
|
||||
// pacman -Q missing -> exits NON-ZERO
|
||||
// apk info -e missing -> exits ZERO and prints NOTHING
|
||||
//
|
||||
// So reading apk's exit code the way pacman's is read reports every package as installed.
|
||||
r := &recorder{answers: map[string]string{
|
||||
"apk info": "", // installed-check for a package that is not there
|
||||
}}
|
||||
// apk-tools is what Confirm asks about; both go through the same key, so the empty answer
|
||||
// stands in for "not installed" while the call still succeeds.
|
||||
installed, err := sys(t, "alpine").PackageInstalled(context.Background(), r.run, "docker")
|
||||
if err == nil && installed {
|
||||
t.Error("apk's empty output was read as 'installed'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkFindsAnInstalledPackage(t *testing.T) {
|
||||
r := &recorder{answers: map[string]string{"apk info": "docker-24.0.7-r0\n"}}
|
||||
installed, err := sys(t, "alpine").PackageInstalled(context.Background(), r.run, "docker")
|
||||
if err != nil {
|
||||
t.Fatalf("could not ask: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("an installed package was reported missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
|
||||
// Both systems, same trap: a package manager that cannot answer must not read as "nothing
|
||||
// is installed", or the host reinstalls on a machine whose database is broken.
|
||||
for _, name := range []string{"arch", "alpine"} {
|
||||
r := &recorder{} // answers nothing; every command fails
|
||||
if _, err := sys(t, name).PackageInstalled(context.Background(), r.run, "docker"); err == nil {
|
||||
t.Errorf("%s: a broken package database was read as 'not installed'", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- services ----------------------------------------------------------------------------------
|
||||
|
||||
func TestAServiceThatDoesNotExistIsNeverReportedStopped(t *testing.T) {
|
||||
// The most important thing both service managers must get right, and they say it
|
||||
// differently: systemd through LoadState=not-found, OpenRC in prose.
|
||||
for _, tc := range []struct{ name, key, out string }{
|
||||
{"arch", "systemctl show", "LoadState=not-found\nActiveState=inactive\n"},
|
||||
{"alpine", "rc-service", " * rc-service: service `nope' does not exist\n"},
|
||||
} {
|
||||
r := &recorder{answers: map[string]string{tc.key: tc.out}}
|
||||
state, err := sys(t, tc.name).ServiceState(context.Background(), r.run, "nope")
|
||||
if err == nil {
|
||||
t.Errorf("%s: a service that does not exist was reported as %q", tc.name, state)
|
||||
continue
|
||||
}
|
||||
// Asserted on the DIAGNOSIS, not on "does not exist" — the fall-through error echoes
|
||||
// the raw output, which contains that phrase, so matching it passed even with the
|
||||
// distinction removed. Only the correct branch explains why absence is not stopped.
|
||||
if !strings.Contains(err.Error(), "absence as success") {
|
||||
t.Errorf("%s: failed for the wrong reason: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRCStateIsReadFromItsOwnWords(t *testing.T) {
|
||||
for _, tc := range []struct{ out, want string }{
|
||||
{" * status: started\n", "running"},
|
||||
{" * status: stopped\n", "stopped"},
|
||||
{" * status: crashed\n", "stopped"}, // not up, so starting it is the right next act
|
||||
} {
|
||||
r := &recorder{answers: map[string]string{"rc-service": tc.out}}
|
||||
got, err := sys(t, "alpine").ServiceState(context.Background(), r.run, "docker")
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", tc.out, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%q read as %q, expected %q", tc.out, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRCBootStateComesFromTheRunlevel(t *testing.T) {
|
||||
// OpenRC has no `is-enabled`. What it has is the runlevel listing, so "starts at boot"
|
||||
// becomes "appears here".
|
||||
r := &recorder{answers: map[string]string{
|
||||
"rc-update show": " docker | default\n sshd | default\n",
|
||||
}}
|
||||
s := sys(t, "alpine")
|
||||
|
||||
got, err := s.ServiceBoot(context.Background(), r.run, "docker")
|
||||
if err != nil || got != "enabled" {
|
||||
t.Errorf("a service in the default runlevel read as %q (%v)", got, err)
|
||||
}
|
||||
got, err = s.ServiceBoot(context.Background(), r.run, "chronyd")
|
||||
if err != nil || got != "disabled" {
|
||||
t.Errorf("a service not in any runlevel read as %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSystemUsesItsOwnCommands(t *testing.T) {
|
||||
// The whole point of ADR 0060: the alpine host must never reach for systemctl, and the arch
|
||||
// host must never reach for rc-service.
|
||||
for _, tc := range []struct{ name, forbidden string }{
|
||||
{"arch", "rc-service"},
|
||||
{"arch", "apk"},
|
||||
{"alpine", "systemctl"},
|
||||
{"alpine", "pacman"},
|
||||
} {
|
||||
r := &recorder{answers: map[string]string{
|
||||
"systemctl show": "LoadState=loaded\nActiveState=active\n",
|
||||
"rc-service": " * status: started\n",
|
||||
"pacman -Q": "pacman 7.0.0\n",
|
||||
"apk info": "apk-tools-2.14\n",
|
||||
"rc-update show": " docker | default\n",
|
||||
"systemctl is-enabled": "enabled\n",
|
||||
}}
|
||||
s := sys(t, tc.name)
|
||||
_, _ = s.ServiceState(context.Background(), r.run, "docker")
|
||||
_, _ = s.ServiceBoot(context.Background(), r.run, "docker")
|
||||
_, _ = s.PackageInstalled(context.Background(), r.run, "docker")
|
||||
|
||||
for _, call := range r.calls {
|
||||
if strings.HasPrefix(call, tc.forbidden) {
|
||||
t.Errorf("the %s host called %q", tc.name, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) {
|
||||
// Check refuses these shapes before an applier is reached, so these are unreachable — and
|
||||
// they say so rather than returning a zero value, in case "unreachable" ever stops being
|
||||
// true.
|
||||
s := sys(t, "android")
|
||||
r := &recorder{}
|
||||
if _, err := s.PackageInstalled(context.Background(), r.run, "x"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Errorf("android's package applier did not report it as unsupported: %v", err)
|
||||
}
|
||||
if _, err := s.ServiceState(context.Background(), r.run, "x"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Errorf("android's service applier did not report it as unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user