Unify trunk on main: initialization → main #3
@@ -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