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:
@@ -1,6 +1,7 @@
|
||||
SYSTEM ?= arch
|
||||
# The gate. Green is the definition of done (novox/hq how-we-build §5).
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
LDFLAGS := -s -w -X main.builtFor=$(SYSTEM) -X main.version=$(VERSION)
|
||||
|
||||
# The bundle a host carries is built INTO it (novox/hq ADR 0038, ADR 0041): a host that needed
|
||||
# a second file to arrive with it is not "copy it and run it".
|
||||
@@ -10,6 +11,15 @@ BUNDLE ?=
|
||||
|
||||
check: fmt vet test packaging-test build
|
||||
|
||||
# One binary per operating system (novox/hq ADR 0060). The system is pinned at link time; a
|
||||
# host built without one refuses to touch a machine rather than guessing.
|
||||
hosts:
|
||||
@for s in arch alpine android; do \
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.builtFor=$$s -X main.version=$(VERSION)" \
|
||||
-o mesh-host-$$s ./cmd/mesh-host || exit 1; \
|
||||
echo "built mesh-host-$$s"; \
|
||||
done
|
||||
|
||||
packaging-test:
|
||||
@./packaging/rollback_test.sh
|
||||
@./packaging/launch_test.sh
|
||||
|
||||
+23
-1
@@ -24,11 +24,17 @@ import (
|
||||
"github.com/novox/mesh-host/internal/inventory"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
"github.com/novox/mesh-host/internal/upgrade"
|
||||
)
|
||||
|
||||
// version is stamped at build time. Unset in a development build, and said so rather than
|
||||
// defaulted to something that looks like a release.
|
||||
// builtFor names the operating system this host was built for, set at link time
|
||||
// (novox/hq ADR 0060). A host built without one refuses to do anything that touches the
|
||||
// machine, rather than guessing and calling a package manager that is not there.
|
||||
var builtFor = ""
|
||||
|
||||
var version = "development build"
|
||||
|
||||
const usage = `mesh-host — the node host
|
||||
@@ -295,7 +301,23 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, sou
|
||||
return nil
|
||||
}
|
||||
|
||||
report, updated, applyErr := apply.Apply(ctx, d, known, apply.ExecRunner, func(line string) {
|
||||
sys, err := system.For(builtFor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Refuse a declaration naming a shape this host cannot apply, before anything is applied.
|
||||
// An android host has no `package` applier, and finding that out half way through is the
|
||||
// half-configured machine this host exists to prevent (novox/hq ADR 0060).
|
||||
if err := system.Check(sys, d); err != nil {
|
||||
return err
|
||||
}
|
||||
// And prove this is the machine the host was built for. Installing the arch host on Alpine
|
||||
// must say so once, at the start, rather than failing later inside pacman.
|
||||
if err := sys.Confirm(ctx, apply.ExecRunner); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
report, updated, applyErr := apply.Apply(ctx, sys, d, known, apply.ExecRunner, func(line string) {
|
||||
if !opts.json {
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
+26
-135
@@ -25,11 +25,12 @@ import (
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// Runner executes a command. The real one is used everywhere outside unit tests; behaviour
|
||||
// against a real system is tested alongside rather than mocked (novox/hq ADR 0034).
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
type Runner = system.Runner
|
||||
|
||||
// Outcome is what happened to one resource.
|
||||
type Outcome struct {
|
||||
@@ -81,6 +82,7 @@ func (e *Error) Unwrap() error { return e.Err }
|
||||
// apply then fails — a recovery concern, where the other is a correctness one.
|
||||
func Apply(
|
||||
ctx context.Context,
|
||||
sys system.System,
|
||||
d *declaration.Declaration,
|
||||
known store.State,
|
||||
run Runner,
|
||||
@@ -97,7 +99,7 @@ func Apply(
|
||||
}
|
||||
|
||||
for _, orphan := range known.Orphans(declared) {
|
||||
action, detail, err := remove(ctx, orphan, run)
|
||||
action, detail, err := remove(ctx, sys, orphan, run)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: orphan.ID, Err: err, Done: report}
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func Apply(
|
||||
}
|
||||
|
||||
for _, resource := range d.Resources {
|
||||
outcome, err := applyOne(ctx, resource, run)
|
||||
outcome, err := applyOne(ctx, sys, resource, run)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report}
|
||||
}
|
||||
@@ -128,16 +130,16 @@ func Apply(
|
||||
return report, known, nil
|
||||
}
|
||||
|
||||
func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
return applyDirectory(res)
|
||||
case *declaration.File:
|
||||
return applyFile(res)
|
||||
case *declaration.Service:
|
||||
return applyService(ctx, res, run)
|
||||
return applyService(ctx, sys, res, run)
|
||||
case *declaration.Package:
|
||||
return applyPackage(ctx, res, run)
|
||||
return applyPackage(ctx, sys, res, run)
|
||||
case *declaration.Container:
|
||||
return applyContainer(ctx, res, run)
|
||||
case *declaration.Action:
|
||||
@@ -314,7 +316,7 @@ func writeAtomically(path string, content []byte, mode os.FileMode) error {
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outcome, error) {
|
||||
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
var changes []string
|
||||
|
||||
@@ -322,19 +324,15 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc
|
||||
// half way in the more useful direction: enabled-and-stopped comes back at the next boot,
|
||||
// where running-and-disabled does not.
|
||||
if r.Boot != "" {
|
||||
bootBefore, err := serviceBoot(ctx, r.Unit, run)
|
||||
bootBefore, err := sys.ServiceBoot(ctx, run, r.Unit)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if bootBefore != r.Boot {
|
||||
verb := "enable"
|
||||
if r.Boot == "disabled" {
|
||||
verb = "disable"
|
||||
if err := sys.SetServiceBoot(ctx, run, r.Unit, r.Boot); err != nil {
|
||||
return out, fmt.Errorf("setting %s to %s at boot: %w", r.Unit, r.Boot, err)
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", verb, r.Unit); err != nil {
|
||||
return out, fmt.Errorf("%s %s: %w", verb, r.Unit, err)
|
||||
}
|
||||
bootAfter, err := serviceBoot(ctx, r.Unit, run)
|
||||
bootAfter, err := sys.ServiceBoot(ctx, run, r.Unit)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -346,23 +344,18 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc
|
||||
}
|
||||
}
|
||||
|
||||
before, err := serviceState(ctx, r.Unit, run)
|
||||
before, err := sys.ServiceState(ctx, run, r.Unit)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if before != r.State {
|
||||
verb := "start"
|
||||
if r.State == "stopped" {
|
||||
verb = "stop"
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", verb, r.Unit); err != nil {
|
||||
return out, fmt.Errorf("%s %s: %w", verb, r.Unit, err)
|
||||
if err := sys.SetServiceState(ctx, run, r.Unit, r.State); err != nil {
|
||||
return out, fmt.Errorf("setting %s to %s: %w", r.Unit, r.State, err)
|
||||
}
|
||||
|
||||
// Read back. `systemctl start` returning zero says the transaction was accepted, not
|
||||
// that the unit is running — a unit that starts and immediately dies satisfies the
|
||||
// command.
|
||||
after, err := serviceState(ctx, r.Unit, run)
|
||||
// Read back. A service manager accepting a command says the transaction was accepted,
|
||||
// not that the unit is running — one that starts and immediately dies satisfies it.
|
||||
after, err := sys.ServiceState(ctx, run, r.Unit)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -382,90 +375,6 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// serviceBoot reads whether a unit starts at boot.
|
||||
//
|
||||
// The same trap as serviceState, in a new place. `systemctl is-enabled` exits non-zero for
|
||||
// nearly everything that is not "enabled", so the exit code says nothing useful — and it has
|
||||
// more than two answers. `static` in particular is neither enabled nor disabled: the unit has
|
||||
// no install section and CANNOT be enabled, so reporting it as "disabled" would let the host
|
||||
// try, fail, and blame the wrong thing.
|
||||
func serviceBoot(ctx context.Context, unit string, run Runner) (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)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceState reads what the service manager says about a unit.
|
||||
//
|
||||
// Two traps here, 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 —
|
||||
// the same shape as the capability detector reading a degraded init as no init at all.
|
||||
//
|
||||
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
||||
// DOES NOT EXIST exactly as it does for one that is installed and stopped. Declaring a unit
|
||||
// stopped therefore reported success for a unit the host cannot manage at all — absence read
|
||||
// as satisfaction, which is 04-ISSUES/007 wearing a different hat. LoadState is what separates
|
||||
// them, so LoadState is what is read.
|
||||
func serviceState(ctx context.Context, unit string, run Runner) (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)
|
||||
}
|
||||
}
|
||||
|
||||
// remove undoes one resource the host applied and the declaration no longer names, and reports
|
||||
// what it actually did.
|
||||
//
|
||||
@@ -475,7 +384,7 @@ func serviceState(ctx context.Context, unit string, run Runner) (string, error)
|
||||
// It returns the action rather than assuming "removed", because for half the vocabulary the
|
||||
// honest word is "forgotten". A host that reported a package removed when it left the package
|
||||
// installed would be describing an effect it declined to have.
|
||||
func remove(ctx context.Context, a store.Applied, run Runner) (string, string, error) {
|
||||
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner) (string, string, error) {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeFile, declaration.TypeDirectory:
|
||||
if err := os.RemoveAll(a.Target); err != nil {
|
||||
@@ -495,13 +404,13 @@ func remove(ctx context.Context, a store.Applied, run Runner) (string, string, e
|
||||
// host holding a record of an uninstalled unit would then be unable to apply anything,
|
||||
// ever, with no way out but editing its state by hand. Removal is idempotent for the
|
||||
// same reason `os.RemoveAll` is.
|
||||
if _, err := serviceState(ctx, a.Target, run); err != nil {
|
||||
if _, err := sys.ServiceState(ctx, run, a.Target); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist on this machine") {
|
||||
return "forgotten", "the unit no longer exists", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "stop", a.Target); err != nil {
|
||||
if err := sys.SetServiceState(ctx, run, a.Target, "stopped"); err != nil {
|
||||
return "", "", fmt.Errorf("stopping %s: %w", a.Target, err)
|
||||
}
|
||||
return "removed", "stopped; the unit file is not the host's to delete", nil
|
||||
@@ -562,10 +471,10 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
|
||||
// asserts, because version is the package manager's business and the mesh does not have a
|
||||
// second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes
|
||||
// not becoming a second package manager).
|
||||
func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outcome, error) {
|
||||
func applyPackage(ctx context.Context, sys system.System, r *declaration.Package, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
|
||||
installed, err := packageInstalled(ctx, r.Package, run)
|
||||
installed, err := sys.PackageInstalled(ctx, run, r.Package)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -575,12 +484,12 @@ func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if _, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", r.Package); err != nil {
|
||||
if err := sys.InstallPackage(ctx, run, r.Package); err != nil {
|
||||
return out, fmt.Errorf("installing %s: %w", r.Package, err)
|
||||
}
|
||||
|
||||
// Read back. A package manager exiting zero says the transaction was accepted.
|
||||
installed, err = packageInstalled(ctx, r.Package, run)
|
||||
installed, err = sys.PackageInstalled(ctx, run, r.Package)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -593,24 +502,6 @@ func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// packageInstalled asks the package database, having first established that it answers.
|
||||
//
|
||||
// The two-step is the same trap `serviceState` documents. `pacman -Q name` exits non-zero for
|
||||
// a package that is not installed AND for a package database that cannot be read, so believing
|
||||
// the first answer would report a broken package manager as "nothing is installed" — absence
|
||||
// read as fact. Proving the tool answers about something that certainly exists separates them.
|
||||
func packageInstalled(ctx context.Context, name string, run Runner) (bool, error) {
|
||||
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"the package database does not answer on this machine, so nothing can be said "+
|
||||
"about %q: %w", name, err)
|
||||
}
|
||||
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Labels the host puts on every container it creates.
|
||||
//
|
||||
// specLabel carries a digest of the declaration that made the container. It is what lets a
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// Each test names the decision it defends (novox/hq ADR 0034).
|
||||
@@ -38,7 +39,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+dir+`/etc/a.conf","content":"hello\n","mode":"0640"}
|
||||
]}`)
|
||||
|
||||
first, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,7 +47,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||
t.Fatal("the first apply on an empty machine changed nothing")
|
||||
}
|
||||
|
||||
second, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
second, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,7 +65,7 @@ func TestADriftedMachineIsReturned(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"correct\n","mode":"0644"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func TestADriftedMachineIsReturned(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,7 +97,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) {
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"},
|
||||
{"id":"drop","type":"file","path":"`+drop+`","content":"b\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), both, store.State{}, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), both, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -104,7 +105,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) {
|
||||
one := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), one, state, noServices, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), one, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func TestNothingTheHostDidNotCreateIsTouched(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"ours","type":"file","path":"`+filepath.Join(dir, "ours.conf")+`","content":"a\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, noServices, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
||||
before := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"old","type":"file","path":"`+path+`","content":"old\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), before, store.State{}, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), before, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -163,7 +164,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
||||
after := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"new","type":"file","path":"`+path+`","content":"new\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), after, state, noServices, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), after, state, noServices, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ func TestAFailedStepFailsTheApply(t *testing.T) {
|
||||
{"id":"never","type":"file","path":"`+filepath.Join(dir, "never.conf")+`","content":"b\n"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an impossible resource did not fail the apply")
|
||||
}
|
||||
@@ -224,7 +225,7 @@ func TestNothingIsRecordedUntilItWorked(t *testing.T) {
|
||||
{"id":"doomed","type":"directory","path":"`+blocker+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure")
|
||||
}
|
||||
@@ -243,7 +244,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"s\n","mode":"0600"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -251,7 +252,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -282,7 +283,7 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"doomed.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a service that died immediately was reported as running")
|
||||
}
|
||||
@@ -298,7 +299,7 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"odd.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "neither running nor stopped") {
|
||||
t.Errorf("an unrecognised service state was not refused: %v", err)
|
||||
}
|
||||
@@ -322,7 +323,7 @@ func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
|
||||
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), d, state, run, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, state, run, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
@@ -348,7 +349,7 @@ func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
|
||||
{"id":"s","type":"service","unit":"never-installed.service","state":"stopped"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, absent, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, absent, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a unit that does not exist was reported as satisfactorily stopped")
|
||||
}
|
||||
@@ -369,7 +370,7 @@ func TestAMaskedUnitIsRefused(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"masked.service","state":"running"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, masked, nil); err == nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, masked, nil); err == nil {
|
||||
t.Fatal("a masked unit was accepted")
|
||||
}
|
||||
}
|
||||
@@ -397,7 +398,7 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), d, known, run, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a vanished unit stranded the apply: %v", err)
|
||||
}
|
||||
@@ -441,7 +442,7 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a broken package database was read as 'not installed'")
|
||||
}
|
||||
@@ -462,7 +463,7 @@ func TestAnInstalledPackageIsNotReinstalled(t *testing.T) {
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -492,7 +493,7 @@ func TestAPackageIsNeverUninstalled(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), d, known, run, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dropping a package stranded the apply: %v", err)
|
||||
}
|
||||
@@ -522,7 +523,7 @@ func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) {
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -548,7 +549,7 @@ func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) {
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an action that reported success and did nothing was accepted")
|
||||
}
|
||||
@@ -575,7 +576,7 @@ func TestAnActionRunsInsideTheContainerItNames(t *testing.T) {
|
||||
{"id":"db","type":"action","in":"store","command":["createdb","mesh"],"verify":["psql","-lqt"]}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, run, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil); err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if !sawExec {
|
||||
@@ -602,7 +603,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a container that exited immediately was reported as applied")
|
||||
}
|
||||
@@ -644,7 +645,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -674,7 +675,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -733,7 +734,7 @@ func TestAServiceIsEnabledAtBootWhenAsked(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{},
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
@@ -753,7 +754,7 @@ func TestBootIsEnabledBeforeTheUnitIsStarted(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{},
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -768,7 +769,7 @@ func TestAlreadyEnabledAndRunningIsUnchanged(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{},
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "active", "enabled", &verbs), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
@@ -788,7 +789,7 @@ func TestOmittingBootLeavesItAlone(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{},
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "inactive", "enabled", &verbs), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -808,7 +809,7 @@ func TestAStaticUnitCannotBeEnabled(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"dbus.socket","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{},
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "active", "static", &verbs), nil)
|
||||
if err == nil {
|
||||
t.Fatal("a static unit was accepted as enable-able")
|
||||
@@ -823,7 +824,7 @@ func TestAnUnknownBootStateIsRefusedNotGuessed(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"rt","type":"service","unit":"x.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), d, store.State{},
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
systemctlStub(t, "loaded", "active", "indirect", &verbs), nil)
|
||||
if err == nil {
|
||||
t.Fatal("an unrecognised boot state was guessed at instead of refused")
|
||||
@@ -899,7 +900,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
|
||||
|
||||
// It will fail at read-back — the stub never reports it running — and what matters is
|
||||
// WHICH binary it used getting there.
|
||||
_, _, _ = Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
|
||||
for _, c := range calledWith {
|
||||
if c != "podman" {
|
||||
@@ -921,7 +922,7 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) {
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a machine with no container runtime applied a container")
|
||||
}
|
||||
@@ -931,3 +932,14 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// archHost is the system these tests run against. They were written for pacman and systemd, and
|
||||
// naming that is better than the implicit default it used to be.
|
||||
func archHost(t *testing.T) system.System {
|
||||
t.Helper()
|
||||
s, err := system.For("arch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// android is a partial host, and being partial is the point.
|
||||
//
|
||||
// It implements `file`, `directory` and `action` — the shapes that need only a filesystem and a
|
||||
// way to run something — and refuses the other three. That is not a broken host: a declaration
|
||||
// naming a shape this host does not implement is refused whole, the same treatment an unknown
|
||||
// type gets, and the profile tells the control plane which shapes exist so it never sends one
|
||||
// it cannot do (novox/hq ADR 0060).
|
||||
//
|
||||
// What it cannot do, and why:
|
||||
//
|
||||
// - **package** — there is no package manager an ordinary app may drive. Installing software
|
||||
// on Android means the framework installing an APK, which is not something a process asks
|
||||
// for on its own behalf.
|
||||
// - **service** — Android's init reads .rc files from the system partition, which needs root
|
||||
// and an unlocked bootloader. On a normal device nothing can register with it.
|
||||
// - **container** — no container runtime, and no kernel access to give one.
|
||||
//
|
||||
// **Being STARTED on Android is not solved by this file, and it is the real gap.** Everywhere
|
||||
// else an init runs the launcher at boot. Here the equivalent is the app framework — a
|
||||
// foreground service, or something under Termux — both of which the system may kill when it
|
||||
// wants memory. That is a different mechanism from every other node rather than a variant of
|
||||
// one, and nothing here designs it.
|
||||
type android struct{}
|
||||
|
||||
func (android) Name() string { return "android" }
|
||||
func (android) Shapes() []declaration.Type { return portableShapes() }
|
||||
|
||||
func (android) Confirm(ctx context.Context, run Runner) error {
|
||||
// Ask the property service, which exists on every Android and nowhere else. A file path
|
||||
// check would pass inside a chroot; this asks something only Android answers.
|
||||
if _, err := run(ctx, "getprop", "ro.build.version.sdk"); err != nil {
|
||||
return fmt.Errorf(
|
||||
"this is the android host and the property service does not answer here. Either "+
|
||||
"this is not Android, or it is a container without it: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The four below are unreachable through the ordinary path: Check refuses a declaration naming
|
||||
// these shapes before anything is applied. They are here so that "unreachable" fails loudly if
|
||||
// it ever stops being true, rather than a nil applier being called.
|
||||
|
||||
func (a android) PackageInstalled(context.Context, Runner, string) (bool, error) {
|
||||
return false, fmt.Errorf("%w: package (there is no package manager an app may drive)", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) InstallPackage(context.Context, Runner, string) error {
|
||||
return fmt.Errorf("%w: package", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) ServiceState(context.Context, Runner, string) (string, error) {
|
||||
return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) SetServiceState(context.Context, Runner, string, string) error {
|
||||
return fmt.Errorf("%w: service", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) ServiceBoot(context.Context, Runner, string) (string, error) {
|
||||
return "", fmt.Errorf("%w: service", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) SetServiceBoot(context.Context, Runner, string, string) error {
|
||||
return fmt.Errorf("%w: service", ErrUnsupported)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
@@ -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