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) } } }