- install/uninstall/start/stop/enable take <name> or --all; *all kept as shortcuts - add enable (enable at boot + start); startall no longer enables - order/delay resolved by config.ResolveOrder and written into systemd, OpenRC and runit service files (After=/sleep), so they apply at boot - install --all stops and disables units before removing their files - update regenerates all installed, enabled units and removes disabled ones - edit creates the config dir; docs and man page updated - tests: fake Init for cmd, ResolveOrder, backend ordering output
461 lines
14 KiB
Go
461 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/warkanum/unitdore/config"
|
|
"github.com/warkanum/unitdore/initsys"
|
|
)
|
|
|
|
// fakeInit records calls and simulates installed files in memory.
|
|
type fakeInit struct {
|
|
mu sync.Mutex
|
|
installed map[string]bool
|
|
calls []string
|
|
failOn map[string]bool // "op:name" -> error
|
|
}
|
|
|
|
func newFake(installed ...string) *fakeInit {
|
|
f := &fakeInit{installed: map[string]bool{}, failOn: map[string]bool{}}
|
|
for _, n := range installed {
|
|
f.installed[n] = true
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *fakeInit) rec(op string, u config.Unit) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, op+":"+u.Name)
|
|
if f.failOn[op+":"+u.Name] {
|
|
return errors.New("boom")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeInit) Name() string { return "fake" }
|
|
func (f *fakeInit) ServiceName(u config.Unit, p, s string) string { return p + u.Name + s }
|
|
func (f *fakeInit) UnitPath(u config.Unit, p, s string) (string, error) {
|
|
return "/fake/" + f.ServiceName(u, p, s), nil
|
|
}
|
|
func (f *fakeInit) Generate(u config.Unit, p, s string) (string, error) {
|
|
return "content-" + u.Name, nil
|
|
}
|
|
func (f *fakeInit) Install(u config.Unit, p, s string) error {
|
|
if err := f.rec("install", u); err != nil {
|
|
return err
|
|
}
|
|
f.mu.Lock()
|
|
f.installed[u.Name] = true
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeInit) Uninstall(u config.Unit, p, s string) error {
|
|
if err := f.rec("uninstall", u); err != nil {
|
|
return err
|
|
}
|
|
f.mu.Lock()
|
|
delete(f.installed, u.Name)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeInit) Enable(u config.Unit, p, s string) error { return f.rec("enable", u) }
|
|
func (f *fakeInit) Disable(u config.Unit, p, s string) error { return f.rec("disable", u) }
|
|
func (f *fakeInit) Start(u config.Unit, p, s string) error { return f.rec("start", u) }
|
|
func (f *fakeInit) Stop(u config.Unit, p, s string) error { return f.rec("stop", u) }
|
|
func (f *fakeInit) Status(u config.Unit, p, s string) string { return "active" }
|
|
func (f *fakeInit) IsInstalled(u config.Unit, p, s string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.installed[u.Name]
|
|
}
|
|
func (f *fakeInit) SupportsLinger() bool { return false }
|
|
|
|
func unit(name string, order int, delay string, enabled bool) config.Unit {
|
|
return config.Unit{Name: name, Runtime: "podman", Order: order, Delay: delay, Enabled: enabled}
|
|
}
|
|
|
|
func TestSortUnits(t *testing.T) {
|
|
in := []config.Unit{unit("b", 2, "", true), unit("a", 2, "", true), unit("c", 1, "", true)}
|
|
names := func(us []config.Unit) (n []string) {
|
|
for _, u := range us {
|
|
n = append(n, u.Name)
|
|
}
|
|
return
|
|
}
|
|
if got := names(sortUnits(in, false)); !reflect.DeepEqual(got, []string{"c", "a", "b"}) {
|
|
t.Errorf("forward = %v", got)
|
|
}
|
|
if got := names(sortUnits(in, true)); !reflect.DeepEqual(got, []string{"b", "a", "c"}) {
|
|
t.Errorf("reverse = %v", got)
|
|
}
|
|
if in[0].Name != "b" {
|
|
t.Error("input was mutated")
|
|
}
|
|
}
|
|
|
|
// grouped reports calls with each parallel group sorted, so order within a group is irrelevant.
|
|
func grouped(calls []string, sizes ...int) [][]string {
|
|
var out [][]string
|
|
i := 0
|
|
for _, n := range sizes {
|
|
g := append([]string(nil), calls[i:i+n]...)
|
|
sort.Strings(g)
|
|
out = append(out, g)
|
|
i += n
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestStartAllOrder(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{
|
|
unit("db", 1, "", true),
|
|
unit("web", 2, "3s", true),
|
|
unit("web2", 2, "5s", true),
|
|
unit("off", 2, "", false),
|
|
unit("app", 3, "1s", true),
|
|
unit("missing", 3, "", true),
|
|
}}
|
|
f := newFake("db", "web", "web2", "off", "app")
|
|
|
|
var out bytes.Buffer
|
|
bulkStart(cfg, f, &out, initsys.Init.Start, "started")
|
|
|
|
want := [][]string{{"start:db"}, {"start:web", "start:web2"}, {"start:app"}}
|
|
if got := grouped(f.calls, 1, 2, 1); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
o := out.String()
|
|
for _, s := range []string{"! missing: not installed", "Done. Started: 4 Failed: 0"} {
|
|
if !strings.Contains(o, s) {
|
|
t.Errorf("output missing %q:\n%s", s, o)
|
|
}
|
|
}
|
|
if strings.Contains(o, "off") {
|
|
t.Errorf("disabled unit should be skipped:\n%s", o)
|
|
}
|
|
}
|
|
|
|
func TestStartAllParallelWithinGroup(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 1, "", true)}}
|
|
f := newFake("a", "b")
|
|
started := make(chan string, 2)
|
|
release := make(chan struct{})
|
|
act := func(_ initsys.Init, u config.Unit, _, _ string) error {
|
|
started <- u.Name
|
|
<-release // blocks until both have started: fails (deadlocks) if sequential
|
|
return nil
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { bulkStart(cfg, f, &bytes.Buffer{}, act, "started"); close(done) }()
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("units in the same order did not start in parallel")
|
|
}
|
|
}
|
|
close(release)
|
|
<-done
|
|
}
|
|
|
|
func TestEnableAllUsesEnable(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
|
|
f := newFake("a")
|
|
var out bytes.Buffer
|
|
bulkStart(cfg, f, &out, initsys.Init.Enable, "enabled")
|
|
if !reflect.DeepEqual(f.calls, []string{"enable:a"}) || !strings.Contains(out.String(), "Done. Enabled: 1") {
|
|
t.Errorf("calls=%v out=%s", f.calls, out.String())
|
|
}
|
|
}
|
|
|
|
func TestStartAllFailureCounted(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true)}}
|
|
f := newFake("a", "b")
|
|
f.failOn["start:a"] = true
|
|
var out bytes.Buffer
|
|
bulkStart(cfg, f, &out, initsys.Init.Start, "started")
|
|
if !strings.Contains(out.String(), "Done. Started: 1 Failed: 1") {
|
|
t.Errorf("out=%s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestStopAllReverseOrder(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", false), unit("c", 3, "", true), unit("d", 3, "", true)}}
|
|
f := newFake("a", "b", "c", "d")
|
|
var out bytes.Buffer
|
|
stopAll(cfg, f, &out)
|
|
if want := []string{"stop:d", "stop:c", "stop:b", "stop:a"}; !reflect.DeepEqual(f.calls, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
if !strings.Contains(out.String(), "Stopped: 4") {
|
|
t.Errorf("out=%s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestUninstallAllReverseOrderAndFailure(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("c", 3, "", true)}}
|
|
f := newFake("a", "b") // c not installed
|
|
f.failOn["disable:b"] = true
|
|
var out bytes.Buffer
|
|
uninstallAll(cfg, f, &out)
|
|
if want := []string{"disable:b", "disable:a", "uninstall:a"}; !reflect.DeepEqual(f.calls, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
if !f.installed["b"] {
|
|
t.Error("b must stay installed when disable fails")
|
|
}
|
|
if !strings.Contains(out.String(), "Uninstalled: 1 Failed: 1") {
|
|
t.Errorf("out=%s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestInstallAll(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{
|
|
unit("on", 1, "", true),
|
|
unit("off-installed", 2, "", false),
|
|
unit("off-absent", 3, "", false),
|
|
}}
|
|
f := newFake("off-installed")
|
|
var out bytes.Buffer
|
|
installAll(cfg, f, false, &out)
|
|
|
|
// a disabled unit must be stopped before its file is removed
|
|
if want := []string{"install:on", "disable:off-installed", "uninstall:off-installed"}; !reflect.DeepEqual(f.calls, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
if !strings.Contains(out.String(), "Installed: 1 Removed: 1 Skipped: 1") {
|
|
t.Errorf("out=%s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestInstallAllDisableFailureKeepsFile(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("x", 1, "", false)}}
|
|
f := newFake("x")
|
|
f.failOn["disable:x"] = true
|
|
installAll(cfg, f, false, &bytes.Buffer{})
|
|
if !f.installed["x"] || len(f.calls) != 1 {
|
|
t.Errorf("file must be kept when disable fails; calls=%v", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestInstallAllDryRunWritesNothing(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("on", 1, "", true), unit("off", 2, "", false)}}
|
|
f := newFake("off")
|
|
var out bytes.Buffer
|
|
installAll(cfg, f, true, &out)
|
|
if len(f.calls) != 0 {
|
|
t.Errorf("dry run touched system: %v", f.calls)
|
|
}
|
|
for _, s := range []string{"--- /fake/on ---", "content-on", "would remove: off", "Would install: 1"} {
|
|
if !strings.Contains(out.String(), s) {
|
|
t.Errorf("output missing %q:\n%s", s, out.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateCfg(t *testing.T) (*config.Config, string) {
|
|
t.Helper()
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
|
|
return cfg, filepath.Join(t.TempDir(), "units.yaml")
|
|
}
|
|
|
|
func TestUpdateOrderReinstallsInstalled(t *testing.T) {
|
|
cfg, path := updateCfg(t)
|
|
f := newFake("a")
|
|
order := 7
|
|
if err := applyUpdate(cfg, f, "a", &order, false, false, path, &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(f.calls, []string{"install:a"}) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
saved, err := config.Load(path)
|
|
if err != nil || saved.Units[0].Order != 7 {
|
|
t.Errorf("saved = %+v, %v", saved, err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateDisableStopsAndRemoves(t *testing.T) {
|
|
cfg, path := updateCfg(t)
|
|
f := newFake("a")
|
|
if err := applyUpdate(cfg, f, "a", nil, false, true, path, &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []string{"disable:a", "uninstall:a"}; !reflect.DeepEqual(f.calls, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
saved, _ := config.Load(path)
|
|
if saved.Units[0].Enabled {
|
|
t.Error("unit should be saved as disabled")
|
|
}
|
|
}
|
|
|
|
func TestUpdateNotInstalledOnlySaves(t *testing.T) {
|
|
cfg, path := updateCfg(t)
|
|
f := newFake()
|
|
if err := applyUpdate(cfg, f, "a", nil, false, true, path, &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.calls) != 0 {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestUpdateEnableClearsReason(t *testing.T) {
|
|
cfg, path := updateCfg(t)
|
|
cfg.Units[0].Enabled = false
|
|
cfg.Units[0].DisabledReason = "container not found"
|
|
if err := applyUpdate(cfg, newFake(), "a", nil, true, false, path, &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, _ := config.Load(path)
|
|
if !saved.Units[0].Enabled || saved.Units[0].DisabledReason != "" {
|
|
t.Errorf("saved = %+v", saved.Units[0])
|
|
}
|
|
}
|
|
|
|
func TestUpdateUnknownUnit(t *testing.T) {
|
|
cfg, path := updateCfg(t)
|
|
if err := applyUpdate(cfg, newFake(), "nope", nil, false, false, path, &bytes.Buffer{}); err == nil {
|
|
t.Error("expected not found error")
|
|
}
|
|
}
|
|
|
|
func TestUpdateOrderRefreshesNeighbours(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("off", 3, "", false)}}
|
|
path := filepath.Join(t.TempDir(), "units.yaml")
|
|
f := newFake("a", "b", "off")
|
|
order := 5
|
|
if err := applyUpdate(cfg, f, "a", &order, false, false, path, &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// b's After= changes when a moves behind it; disabled units are left alone
|
|
if want := []string{"install:a", "install:b"}; !reflect.DeepEqual(f.calls, want) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
if got := cfg.Units[0].After; len(got) != 1 || got[0] != "b" {
|
|
t.Errorf("a.After = %v, want [b]", got)
|
|
}
|
|
}
|
|
|
|
func TestInstallOneOnlyNamedUnit(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("off", 3, "", false)}}
|
|
f := newFake("off")
|
|
var out bytes.Buffer
|
|
if err := installOne(cfg, f, "b", false, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(f.calls, []string{"install:b"}) {
|
|
t.Errorf("calls = %v", f.calls)
|
|
}
|
|
if !strings.Contains(out.String(), "installed: /fake/b") {
|
|
t.Errorf("out=%s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestInstallOneDryRun(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
|
|
f := newFake()
|
|
var out bytes.Buffer
|
|
if err := installOne(cfg, f, "a", true, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.calls) != 0 || !strings.Contains(out.String(), "content-a") {
|
|
t.Errorf("calls=%v out=%s", f.calls, out.String())
|
|
}
|
|
}
|
|
|
|
func TestInstallOneErrors(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("off", 1, "", false), unit("bad", 2, "", true)}}
|
|
f := newFake("off")
|
|
if err := installOne(cfg, f, "nope", false, &bytes.Buffer{}); err == nil {
|
|
t.Error("unknown unit must error")
|
|
}
|
|
if err := installOne(cfg, f, "off", false, &bytes.Buffer{}); err == nil {
|
|
t.Error("disabled unit must error")
|
|
}
|
|
f.failOn["install:bad"] = true
|
|
if err := installOne(cfg, f, "bad", false, &bytes.Buffer{}); err == nil {
|
|
t.Error("install failure must be returned")
|
|
}
|
|
if len(f.calls) != 1 || !f.installed["off"] {
|
|
t.Errorf("disabled unit must be left untouched; calls=%v", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestNameOrAll(t *testing.T) {
|
|
cases := []struct {
|
|
all bool
|
|
args []string
|
|
ok bool
|
|
}{
|
|
{false, nil, false},
|
|
{false, []string{"a", "b"}, false},
|
|
{false, []string{"a"}, true},
|
|
{true, nil, true},
|
|
{true, []string{"a"}, false},
|
|
}
|
|
for _, c := range cases {
|
|
all := c.all
|
|
if err := nameOrAll(&all)(nil, c.args); (err == nil) != c.ok {
|
|
t.Errorf("all=%v args=%v: err=%v", c.all, c.args, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSingleUnitCommands(t *testing.T) {
|
|
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("gone", 3, "", true)}}
|
|
f := newFake("a", "b")
|
|
var out bytes.Buffer
|
|
|
|
for _, step := range []struct {
|
|
op string
|
|
run func(*config.Config, initsys.Init, string, io.Writer) error
|
|
want string
|
|
}{
|
|
{"start", startOne, "start:a"},
|
|
{"enable", enableOne, "enable:a"},
|
|
{"stop", stopOne, "stop:a"},
|
|
{"uninstall", uninstallOne, "uninstall:a"},
|
|
} {
|
|
f.calls = nil
|
|
f.installed["a"] = true
|
|
if err := step.run(cfg, f, "a", &out); err != nil {
|
|
t.Fatalf("%s: %v", step.op, err)
|
|
}
|
|
// only the named unit is touched
|
|
for _, c := range f.calls {
|
|
if strings.HasSuffix(c, ":b") {
|
|
t.Errorf("%s touched b: %v", step.op, f.calls)
|
|
}
|
|
}
|
|
if got := f.calls[len(f.calls)-1]; got != step.want {
|
|
t.Errorf("%s last call = %s, want %s", step.op, got, step.want)
|
|
}
|
|
if step.op == "uninstall" && !reflect.DeepEqual(f.calls, []string{"disable:a", "uninstall:a"}) {
|
|
t.Errorf("uninstall calls = %v", f.calls)
|
|
}
|
|
|
|
f.calls = nil
|
|
if err := step.run(cfg, f, "gone", &out); err == nil {
|
|
t.Errorf("%s: not-installed unit must error", step.op)
|
|
}
|
|
if err := step.run(cfg, f, "nope", &out); err == nil {
|
|
t.Errorf("%s: unknown unit must error", step.op)
|
|
}
|
|
if len(f.calls) != 0 {
|
|
t.Errorf("%s: errors must not act: %v", step.op, f.calls)
|
|
}
|
|
}
|
|
}
|