not a service A shell, a terminal, a chat client, a desktop are a package plus configuration in somebody's home. A mesh with no notion of a user can own /etc and nothing anybody looks at, which is most of the reason to manage a machine at all. Three shapes, and the vocabulary test asserts the count precisely because widening it widens what a compromised control plane can express: user a login, its shell and its groups archive a set of files, fetched by digest and unpacked (file) gains `bytes` for what is not text, and `owner` `user` also makes "zsh is my login shell" declared state. chsh is a command, the link may not carry one, and a shell settable only by hand is a shell the mesh cannot manage. Groups are additive and never pruned — usermod without --append REPLACES them, which would silently remove every group that makes a login able to use the machine. A machine's own groups are not the mesh's to know about. The archive is the one place this host reaches out on its own; everywhere else it holds one outbound connection and fetches nothing. So it carries the discipline the bootstrap already uses for images: pinned by digest, and the digest checked before a single file is written. Two decisions in the unpacker worth naming: - an entry naming a path outside the archive is REFUSED, not sanitised. Rewriting it to land inside would put a file somewhere nobody asked for and report success. Found by the test: the first version quietly relocated it. - symlinks and device nodes are refused rather than skipped, or an archive that needed one arrives silently incomplete. A partial host does archives and refuses users: an archive needs a filesystem and a way to fetch; a user needs a user database it is allowed to write.
348 lines
13 KiB
Go
348 lines
13 KiB
Go
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 0005: 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)
|
|
}
|
|
}
|
|
|
|
func TestALoginThatIsNotThereIsAnAnswerAndABrokenDatabaseIsNot(t *testing.T) {
|
|
// The distinction this package takes trouble over everywhere else, applied to users. A user
|
|
// database that cannot be read must not be reported as "no such user" — absence read as
|
|
// fact is the fault the whole host exists to prevent.
|
|
notFound := func(context.Context, string, ...string) (string, error) {
|
|
return "", errors.New("exit status 2")
|
|
}
|
|
if _, exists, err := LookUpUser(context.Background(), notFound, "nobody"); err != nil {
|
|
t.Fatalf("a missing user was reported as a failure: %v", err)
|
|
} else if exists {
|
|
t.Fatal("a missing user was reported as present")
|
|
}
|
|
|
|
broken := func(context.Context, string, ...string) (string, error) {
|
|
return "", errors.New("exit status 71: cannot read /etc/passwd")
|
|
}
|
|
if _, exists, err := LookUpUser(context.Background(), broken, "somebody"); err == nil {
|
|
t.Fatal("a broken user database was reported as an answer")
|
|
} else if exists {
|
|
t.Fatal("a broken user database reported a user as present")
|
|
}
|
|
}
|
|
|
|
func TestALoginIsReadFromThePasswdEntry(t *testing.T) {
|
|
answering := func(context.Context, string, ...string) (string, error) {
|
|
return "worker:x:1001:1001:,,,:/home/worker:/usr/bin/zsh\n", nil
|
|
}
|
|
login, exists, err := LookUpUser(context.Background(), answering, "worker")
|
|
if err != nil || !exists {
|
|
t.Fatalf("exists=%v err=%v", exists, err)
|
|
}
|
|
if login.Home != "/home/worker" || login.Shell != "/usr/bin/zsh" {
|
|
t.Fatalf("got %+v", login)
|
|
}
|
|
}
|
|
|
|
func TestSomethingThatIsNotAPasswdEntryIsRefused(t *testing.T) {
|
|
// Rather than read as a login with empty fields, which would have the host decide the shell
|
|
// differs and set it on every apply for ever.
|
|
nonsense := func(context.Context, string, ...string) (string, error) {
|
|
return "who knows\n", nil
|
|
}
|
|
if _, _, err := LookUpUser(context.Background(), nonsense, "worker"); err == nil {
|
|
t.Fatal("nonsense was read as a login")
|
|
}
|
|
}
|
|
|
|
func TestAPartialHostRefusesUsersAndAllowsArchives(t *testing.T) {
|
|
// An archive needs a filesystem and a way to fetch; a user needs a user database this host is
|
|
// allowed to write, which Android does not have.
|
|
speaks := map[declaration.Type]bool{}
|
|
for _, shape := range (android{}).Shapes() {
|
|
speaks[shape] = true
|
|
}
|
|
if !speaks[declaration.TypeArchive] {
|
|
t.Error("a partial host refuses archives, which need only a filesystem")
|
|
}
|
|
if speaks[declaration.TypeUser] {
|
|
t.Error("a partial host claims to manage users")
|
|
}
|
|
if err := (android{}).CreateUser(context.Background(), nil, "a", "", ""); err == nil {
|
|
t.Error("a partial host created a user")
|
|
}
|
|
}
|