diff --git a/AGENTS.md b/AGENTS.md index 9277441..91c8788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,10 @@ make release-version # Bump patch version, commit, tag, and push ## Architecture ``` -cmd/ # Cobra commands (edit, install, list, root, start, startall, - # status, stop, stopall, syncup, uninstall, update) +cmd/ # Cobra commands (edit, install, enable, list, root, start, status, + # stop, syncup, uninstall, update; each of install/enable/start/ + # stop/uninstall takes or --all, *all = shortcuts in + # bulk.go); order.go = ordering; bulk_test.go = fake Init config/ # Config struct + YAML load/save (units.yaml) runtime/ # Docker and Podman runtime abstraction, compose labels sysuser/ # run commands as root/user (runuser), home/linger lookup @@ -43,7 +45,7 @@ docs/ # generated-units.md (unit file examples), unitdore.1 (man page) ### Key packages -- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit` +- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`, `ResolveOrder` (fills `Unit.After`/`StartDelay`, run by `Load`) - **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations - **`service`** — `Resolve(unit)` → start/stop commands, type, workdir; shared by all backends - **`initsys`** — `Init` interface; `New(cfg)` picks backend; cmd/ only talks to `Init` @@ -66,8 +68,8 @@ units: runtime: podman # podman | docker | script | exec user: "" # empty = system unit; set for rootless user unit command: "" # override ExecStart entirely - order: 1 # startup order, lower = earlier; same = parallel - delay: 0s # delay after previous order group + order: 1 # startup order, lower = earlier; same = parallel; written into service files (After=/sleep) + delay: 0s # wait before start, own value, ignored in first group enabled: true disabled_reason: "" # auto-set by syncup reconciliation restart: false # Restart=on-failure @@ -109,6 +111,7 @@ Tests exist in: - `config/config_test.go` - `runtime/runtime_test.go` - `cmd/syncup_test.go` +- `cmd/bulk_test.go` - `sysuser/sysuser_test.go` - `service/spec_test.go` - `initsys/initsys_test.go` diff --git a/README.md b/README.md index 021f733..793dbe5 100644 --- a/README.md +++ b/README.md @@ -85,27 +85,51 @@ Queries Podman and Docker for running containers, adds new ones to the config, a sudo unitdore edit ``` -Opens `/etc/unitdore/units.yaml` in `$EDITOR` (falls back to `vi`). +Opens `/etc/unitdore/units.yaml` in `$EDITOR` (falls back to `nvim`, `nano`, `vi`; creates the config dir if missing). -### Install systemd unit files +### Install service files ```bash -sudo unitdore install +sudo unitdore install ``` -Generates `.service` files for all enabled units and writes them to systemd. Use `--dry-run` to preview without writing. +Generates the service file for one enabled unit (error if unknown or disabled). Does not enable or start it. ```bash -sudo unitdore install --dry-run +sudo unitdore install --dry-run # preview, write nothing +sudo unitdore install --all # all enabled units; installed + disabled ones are stopped and removed +sudo unitdore installall # same as install --all ``` -### Enable and start units +### Start / stop / enable / uninstall ```bash -sudo unitdore active +sudo unitdore start # start only +sudo unitdore enable # enable at boot + start +sudo unitdore stop +sudo unitdore uninstall # stop, disable, remove service file ``` -Runs `systemctl enable --now` for all installed, enabled units in startup order. +Each takes `--all` instead of a name to act on every unit (shortcuts: `startall`, `enableall`, `stopall`, `uninstallall`, `installall`). + +- `start/enable --all`: installed, enabled units in `order` (lowest first); same `order` → parallel +- `stop/uninstall --all`: all installed units, reverse `order`, one at a time + +### Order and delay at boot + +`order`/`delay` are written into the generated service files, so they also apply at boot. + +| Backend | `order` | `delay` | +|---|---|---| +| systemd | `After=` units of the previous group | `ExecStartPre=sleep N`, `TimeoutStartSec=N+90` | +| OpenRC | `depend() { after … }` | `start_pre() { sleep N }` | +| runit | `sv -w 30 check ` in `run` | `sleep N` in `run` | + +- Previous group = nearest lower `order` among enabled units of the same user +- `delay` is the unit's own, ignored in the first group +- Changing `order`/`enabled` via `update` regenerates all installed, enabled units +- Delay also applies to manual `start` and to restarts +- `delay` must be a valid non-negative duration (`5s`, `1m`); an invalid value fails config load ### Check status @@ -139,7 +163,7 @@ units: user: "" # empty = root/system unit command: "" # optional: override ExecStart entirely order: 1 # startup order (lower = earlier) - delay: 0s # delay after previous order group + delay: 0s # wait before start (own delay, not first group) enabled: true disabled_reason: "" # auto-set by syncup reconciliation diff --git a/cmd/bulk.go b/cmd/bulk.go new file mode 100644 index 0000000..1ece1c1 --- /dev/null +++ b/cmd/bulk.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/initsys" +) + +// The *all commands are kept as shortcuts for ' --all'. +var ( + startallCmd = &cobra.Command{ + Use: "startall", + Short: "Start all installed, enabled units (same as start --all)", + RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { + bulkStart(cfg, ini, os.Stdout, initsys.Init.Start, "started") + }), + } + enableallCmd = &cobra.Command{ + Use: "enableall", + Short: "Enable at boot and start all installed, enabled units (same as enable --all)", + RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { + bulkStart(cfg, ini, os.Stdout, initsys.Init.Enable, "enabled") + }), + } + stopallCmd = &cobra.Command{ + Use: "stopall", + Short: "Stop all installed units (same as stop --all)", + RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { stopAll(cfg, ini, os.Stdout) }), + } + uninstallallCmd = &cobra.Command{ + Use: "uninstallall", + Short: "Stop, disable and remove all installed units (same as uninstall --all)", + RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { uninstallAll(cfg, ini, os.Stdout) }), + } +) + +func init() { + rootCmd.AddCommand(startallCmd, enableallCmd, stopallCmd, uninstallallCmd) +} + +func bulkRun(fn func(*config.Config, initsys.Init)) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cfg, ini, err := loadInit() + if err != nil { + return err + } + fn(cfg, ini) + return nil + } +} + +type unitAction func(ini initsys.Init, u config.Unit, prefix, suffix string) error + +// bulkStart applies act to all enabled, installed units in startup order. +func bulkStart(cfg *config.Config, ini initsys.Init, w io.Writer, act unitAction, past string) { + prefix, suffix := cfg.Prefix, cfg.Suffix + var todo []config.Unit + for _, u := range sortUnits(cfg.Units, false) { + if !u.Enabled { + continue + } + if !ini.IsInstalled(u, prefix, suffix) { + fmt.Fprintf(w, " ! %s: not installed — run 'unitdore install %s' first\n", u.Name, u.Name) + continue + } + todo = append(todo, u) + } + + ok, failed := runOrdered(todo, w, func(u config.Unit) (string, error) { + if err := act(ini, u, prefix, suffix); err != nil { + return "", err + } + return past + ": " + u.Name, nil + }) + fmt.Fprintf(w, "\nDone. %s: %d Failed: %d\n", strings.ToUpper(past[:1])+past[1:], ok, failed) +} + +// stopAll stops every installed unit in reverse startup order. +func stopAll(cfg *config.Config, ini initsys.Init, w io.Writer) { + prefix, suffix := cfg.Prefix, cfg.Suffix + stopped, failed := 0, 0 + for _, u := range sortUnits(cfg.Units, true) { + if !ini.IsInstalled(u, prefix, suffix) { + continue + } + fmt.Fprintf(w, " ■ stopping: %s...\n", ini.ServiceName(u, prefix, suffix)) + if err := ini.Stop(u, prefix, suffix); err != nil { + fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, err) + failed++ + continue + } + fmt.Fprintf(w, " ✓ stopped: %s\n", u.Name) + stopped++ + } + fmt.Fprintf(w, "\nDone. Stopped: %d Failed: %d\n", stopped, failed) +} + +// uninstallAll disables and removes every installed unit in reverse startup order. +func uninstallAll(cfg *config.Config, ini initsys.Init, w io.Writer) { + prefix, suffix := cfg.Prefix, cfg.Suffix + removed, failed := 0, 0 + + for _, u := range sortUnits(cfg.Units, true) { + if !ini.IsInstalled(u, prefix, suffix) { + continue + } + fmt.Fprintf(w, " ■ disabling %s...\n", ini.ServiceName(u, prefix, suffix)) + if err := ini.Disable(u, prefix, suffix); err != nil { + fmt.Fprintf(w, " ✗ failed to disable %s: %v\n", u.Name, err) + failed++ + continue + } + path, _ := ini.UnitPath(u, prefix, suffix) + fmt.Fprintf(w, " ✗ removing %s...\n", path) + if err := ini.Uninstall(u, prefix, suffix); err != nil { + fmt.Fprintf(w, " ✗ failed to remove %s: %v\n", u.Name, err) + failed++ + continue + } + fmt.Fprintf(w, " ✓ uninstalled: %s\n", u.Name) + removed++ + } + + fmt.Fprintf(w, "\nDone. Uninstalled: %d Failed: %d\n", removed, failed) +} diff --git a/cmd/bulk_test.go b/cmd/bulk_test.go new file mode 100644 index 0000000..f56dd32 --- /dev/null +++ b/cmd/bulk_test.go @@ -0,0 +1,460 @@ +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) + } + } +} diff --git a/cmd/edit.go b/cmd/edit.go index 95d42ed..f5938d7 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "github.com/spf13/cobra" ) @@ -31,6 +32,9 @@ func runEdit(cmd *cobra.Command, args []string) error { if editor == "" { return fmt.Errorf("no editor found; set $EDITOR") } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } c := exec.Command(editor, configPath) c.Stdin = os.Stdin c.Stdout = os.Stdout diff --git a/cmd/enable.go b/cmd/enable.go new file mode 100644 index 0000000..f937ee2 --- /dev/null +++ b/cmd/enable.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/initsys" +) + +var enableAll bool + +var enableCmd = &cobra.Command{ + Use: "enable | --all", + Short: "Enable at boot and start a unit (or all with --all)", + Long: `Enable enables the named installed unit at boot and starts it. + +With --all (same as 'enableall') it does so for every enabled, installed unit +in startup order, like 'start --all'.`, + Args: nameOrAll(&enableAll), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ini, err := loadInit() + if err != nil { + return err + } + if enableAll { + bulkStart(cfg, ini, os.Stdout, initsys.Init.Enable, "enabled") + return nil + } + return enableOne(cfg, ini, args[0], os.Stdout) + }, +} + +func init() { + enableCmd.Flags().BoolVar(&enableAll, "all", false, "enable all enabled, installed units") + rootCmd.AddCommand(enableCmd) +} + +func enableOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error { + u, err := installedUnit(cfg, ini, name) + if err != nil { + return err + } + fmt.Fprintf(w, " ▶ enabling %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix)) + if err := ini.Enable(*u, cfg.Prefix, cfg.Suffix); err != nil { + return err + } + fmt.Fprintf(w, " ✓ enabled: %s\n", name) + return nil +} diff --git a/cmd/helpers.go b/cmd/helpers.go index e3237d5..16af7da 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -1,7 +1,10 @@ package cmd import ( + "errors" "fmt" + "github.com/spf13/cobra" + "io" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/initsys" @@ -17,10 +20,51 @@ func runtimeLabel(u config.Unit) string { } // lingerHint prints, once per user, how to make user units start at boot. -func lingerHint(ini initsys.Init, seen map[string]bool, u config.Unit) { +func lingerHint(w io.Writer, ini initsys.Init, seen map[string]bool, u config.Unit) { if !ini.SupportsLinger() || u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) { return } seen[u.User] = true - fmt.Printf(" ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User) + fmt.Fprintf(w, " ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User) +} + +// nameOrAll requires exactly one unit name, or none when --all is set. +func nameOrAll(all *bool) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if *all { + if len(args) != 0 { + return errors.New("--all takes no unit name") + } + return nil + } + if len(args) != 1 { + return errors.New("requires a unit name, or --all") + } + return nil + } +} + +// installedUnit finds a unit by name and checks that its service file exists. +func installedUnit(cfg *config.Config, ini initsys.Init, name string) (*config.Unit, error) { + u := cfg.FindUnit(name) + if u == nil { + return nil, fmt.Errorf("unit %q not found", name) + } + if !ini.IsInstalled(*u, cfg.Prefix, cfg.Suffix) { + return nil, fmt.Errorf("%s is not installed — run 'unitdore install %s' first", name, name) + } + return u, nil +} + +// loadInit loads the config and selects the init backend. +func loadInit() (*config.Config, initsys.Init, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, nil, err + } + ini, err := initsys.New(cfg) + if err != nil { + return nil, nil, err + } + return cfg, ini, nil } diff --git a/cmd/install.go b/cmd/install.go index 38b08fa..d1f0c7b 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,27 +2,48 @@ package cmd import ( "fmt" + "io" + "os" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/initsys" ) -var dryRun bool +var dryRun, installAllFlag bool var installCmd = &cobra.Command{ - Use: "install", - Short: "Generate and install systemd unit files for all enabled units", - Long: `Install generates .service files for all enabled units and writes them -to the appropriate systemd directory. It does not enable or start them. + Use: "install | --all", + Short: "Generate and install the service file for one unit (or all with --all)", + Long: `Install generates the service file for the named unit using the configured +init system. It does not enable or start it. The unit must be enabled in the +config. -Use --dry-run to preview the generated unit files without writing anything.`, +With --all it installs every enabled unit (same as 'installall'); disabled +units that are installed are stopped, disabled and removed. + +Use --dry-run to preview the generated files without writing anything.`, + Args: nameOrAll(&installAllFlag), RunE: runInstall, } +var installallCmd = &cobra.Command{ + Use: "installall", + Short: "Generate and install service files for all enabled units", + Long: `Installall generates service files for all enabled units using the configured +init system. It does not enable or start them. Units that are disabled in the +config are stopped, disabled and their service files removed. + +Use --dry-run to preview the generated files without writing anything.`, + RunE: runInstallall, +} + func init() { - installCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview unit files without writing") - rootCmd.AddCommand(installCmd) + installCmd.Flags().BoolVar(&installAllFlag, "all", false, "install all enabled units") + for _, c := range []*cobra.Command{installCmd, installallCmd} { + c.Flags().BoolVar(&dryRun, "dry-run", false, "preview service files without writing") + rootCmd.AddCommand(c) + } } func runInstall(cmd *cobra.Command, args []string) error { @@ -30,61 +51,122 @@ func runInstall(cmd *cobra.Command, args []string) error { if err != nil { return err } - - prefix, suffix := cfg.Prefix, cfg.Suffix ini, err := initsys.New(cfg) if err != nil { return err } - installed := 0 - skipped := 0 - removed := 0 + if installAllFlag { + installAll(cfg, ini, dryRun, os.Stdout) + return nil + } + return installOne(cfg, ini, args[0], dryRun, os.Stdout) +} + +func runInstallall(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + ini, err := initsys.New(cfg) + if err != nil { + return err + } + installAll(cfg, ini, dryRun, os.Stdout) + return nil +} + +// installOne installs a single enabled unit. Disabled units are an error: +// removing files is installall's and update's job. +func installOne(cfg *config.Config, ini initsys.Init, name string, dry bool, w io.Writer) error { + u := cfg.FindUnit(name) + if u == nil { + return fmt.Errorf("unit %q not found", name) + } + if !u.Enabled { + return fmt.Errorf("unit %q is disabled — run 'unitdore update %s --enable' first", name, name) + } + + prefix, suffix := cfg.Prefix, cfg.Suffix + path, err := ini.UnitPath(*u, prefix, suffix) + if err != nil { + return err + } + if dry { + content, err := ini.Generate(*u, prefix, suffix) + if err != nil { + return err + } + fmt.Fprintf(w, "\n--- %s ---\n%s\n", path, content) + return nil + } + if err := ini.Install(*u, prefix, suffix); err != nil { + return err + } + fmt.Fprintf(w, " ✓ installed: %s\n", path) + lingerHint(w, ini, map[string]bool{}, *u) + return nil +} + +func installAll(cfg *config.Config, ini initsys.Init, dry bool, w io.Writer) { + prefix, suffix := cfg.Prefix, cfg.Suffix + installed, skipped, removed := 0, 0, 0 lingerSeen := map[string]bool{} for _, u := range cfg.Units { + name := ini.ServiceName(u, prefix, suffix) + if !u.Enabled { - // Remove unit file if it exists for disabled units - if ini.IsInstalled(u, prefix, suffix) { - if dryRun { - fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix)) - } else { - if err := ini.Uninstall(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) - } else { - fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix)) - removed++ - } - } - } else { + if !ini.IsInstalled(u, prefix, suffix) { skipped++ + continue } + if dry { + fmt.Fprintf(w, " ~ would remove: %s\n", name) + continue + } + // Stop and disable first so the service doesn't outlive its file + if err := ini.Disable(u, prefix, suffix); err != nil { + fmt.Fprintf(w, " ✗ failed to disable %s: %v\n", u.Name, err) + continue + } + if err := ini.Uninstall(u, prefix, suffix); err != nil { + fmt.Fprintf(w, " ✗ failed to remove %s: %v\n", u.Name, err) + continue + } + fmt.Fprintf(w, " - removed: %s (disabled)\n", name) + removed++ continue } - if dryRun { + if dry { content, err := ini.Generate(u, prefix, suffix) if err != nil { - fmt.Printf(" ✗ %s: %v\n", u.Name, err) + fmt.Fprintf(w, " ✗ %s: %v\n", u.Name, err) continue } - path, _ := ini.UnitPath(u, prefix, suffix) - fmt.Printf("\n--- %s ---\n%s\n", path, content) + path, err := ini.UnitPath(u, prefix, suffix) + if err != nil { + fmt.Fprintf(w, " ✗ %s: %v\n", u.Name, err) + continue + } + fmt.Fprintf(w, "\n--- %s ---\n%s\n", path, content) installed++ continue } if err := ini.Install(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) - } else { - path, _ := ini.UnitPath(u, prefix, suffix) - fmt.Printf(" ✓ installed: %s\n", path) - lingerHint(ini, lingerSeen, u) - installed++ + fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, err) + continue } + path, _ := ini.UnitPath(u, prefix, suffix) + fmt.Fprintf(w, " ✓ installed: %s\n", path) + lingerHint(w, ini, lingerSeen, u) + installed++ } - if !dryRun { - fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped) + verb := "Installed" + if dry { + verb = "Would install" } - return nil + fmt.Fprintf(w, "\nDone. %s: %d Removed: %d Skipped: %d\n", verb, installed, removed, skipped) } diff --git a/cmd/installall.go b/cmd/installall.go deleted file mode 100644 index a690772..0000000 --- a/cmd/installall.go +++ /dev/null @@ -1,89 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/warkanum/unitdore/config" - "github.com/warkanum/unitdore/initsys" -) - -var installallDryRun bool - -var installallCmd = &cobra.Command{ - Use: "installall", - Short: "Generate and install systemd unit files for all enabled units", - Long: `Installall generates .service files for all enabled units and writes them -to the appropriate systemd directory. It does not enable or start them. - -Use --dry-run to preview the generated unit files without writing anything.`, - RunE: runInstallall, -} - -func init() { - installallCmd.Flags().BoolVar(&installallDryRun, "dry-run", false, "preview unit files without writing") - rootCmd.AddCommand(installallCmd) -} - -func runInstallall(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return err - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { - return err - } - installed := 0 - skipped := 0 - removed := 0 - lingerSeen := map[string]bool{} - - for _, u := range cfg.Units { - if !u.Enabled { - if ini.IsInstalled(u, prefix, suffix) { - if installallDryRun { - fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix)) - } else { - if err := ini.Uninstall(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) - } else { - fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix)) - removed++ - } - } - } else { - skipped++ - } - continue - } - - if installallDryRun { - content, err := ini.Generate(u, prefix, suffix) - if err != nil { - fmt.Printf(" ✗ %s: %v\n", u.Name, err) - continue - } - path, _ := ini.UnitPath(u, prefix, suffix) - fmt.Printf("\n--- %s ---\n%s\n", path, content) - installed++ - continue - } - - if err := ini.Install(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) - } else { - path, _ := ini.UnitPath(u, prefix, suffix) - fmt.Printf(" ✓ installed: %s\n", path) - lingerHint(ini, lingerSeen, u) - installed++ - } - } - - if !installallDryRun { - fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped) - } - return nil -} diff --git a/cmd/list.go b/cmd/list.go index b1824df..578ba7c 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "sort" "strings" "github.com/spf13/cobra" @@ -31,14 +30,7 @@ func runList(cmd *cobra.Command, args []string) error { return nil } - units := make([]config.Unit, len(cfg.Units)) - copy(units, cfg.Units) - sort.Slice(units, func(i, j int) bool { - if units[i].Order != units[j].Order { - return units[i].Order < units[j].Order - } - return units[i].Name < units[j].Name - }) + units := sortUnits(cfg.Units, false) prefix, suffix := cfg.Prefix, cfg.Suffix ini, err := initsys.New(cfg) diff --git a/cmd/order.go b/cmd/order.go new file mode 100644 index 0000000..14fb37c --- /dev/null +++ b/cmd/order.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "fmt" + "io" + "sort" + "sync" + + "github.com/warkanum/unitdore/config" +) + +// sortUnits returns a copy of units sorted by order then name; reverse flips both. +func sortUnits(units []config.Unit, reverse bool) []config.Unit { + out := make([]config.Unit, len(units)) + copy(out, units) + sort.SliceStable(out, func(i, j int) bool { + if out[i].Order != out[j].Order { + return (out[i].Order < out[j].Order) != reverse + } + return (out[i].Name < out[j].Name) != reverse + }) + return out +} + +// groupByOrder splits order-sorted units into groups sharing the same order. +func groupByOrder(sorted []config.Unit) [][]config.Unit { + var groups [][]config.Unit + for _, u := range sorted { + n := len(groups) + if n > 0 && groups[n-1][0].Order == u.Order { + groups[n-1] = append(groups[n-1], u) + continue + } + groups = append(groups, []config.Unit{u}) + } + return groups +} + +// runOrdered runs fn for every unit: groups of equal order one after another, +// units within a group in parallel. Start delays live in the generated service +// files, so a blocking start covers them. Output is printed per group in name +// order. Returns the number of units fn succeeded and failed for. +func runOrdered(units []config.Unit, w io.Writer, fn func(u config.Unit) (string, error)) (ok, failed int) { + for _, g := range groupByOrder(sortUnits(units, false)) { + msgs := make([]string, len(g)) + errs := make([]error, len(g)) + var wg sync.WaitGroup + for j, u := range g { + wg.Add(1) + go func(j int, u config.Unit) { + defer wg.Done() + msgs[j], errs[j] = fn(u) + }(j, u) + } + wg.Wait() + for j, u := range g { + if errs[j] != nil { + fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, errs[j]) + failed++ + continue + } + fmt.Fprintf(w, " ✓ %s\n", msgs[j]) + ok++ + } + } + return ok, failed +} diff --git a/cmd/start.go b/cmd/start.go index 15fc541..5b3b9a6 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -2,49 +2,52 @@ package cmd import ( "fmt" + "io" + "os" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/initsys" ) +var startAll bool + var startCmd = &cobra.Command{ - Use: "start ", - Short: "Start a unit by name", - Args: cobra.ExactArgs(1), - RunE: runStart, + Use: "start | --all", + Short: "Start a unit by name (or all with --all)", + Long: `Start starts the named installed unit without enabling it at boot. + +With --all (same as 'startall') it starts every enabled, installed unit in +startup order: equal 'order' values in parallel, groups one after another. +'delay' is waited inside the generated service.`, + Args: nameOrAll(&startAll), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ini, err := loadInit() + if err != nil { + return err + } + if startAll { + bulkStart(cfg, ini, os.Stdout, initsys.Init.Start, "started") + return nil + } + return startOne(cfg, ini, args[0], os.Stdout) + }, } func init() { + startCmd.Flags().BoolVar(&startAll, "all", false, "start all enabled, installed units") rootCmd.AddCommand(startCmd) } -func runStart(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) +func startOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error { + u, err := installedUnit(cfg, ini, name) if err != nil { return err } - - name := args[0] - u := cfg.FindUnit(name) - if u == nil { - return fmt.Errorf("unit %q not found", name) - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { + fmt.Fprintf(w, " ▶ starting %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix)) + if err := ini.Start(*u, cfg.Prefix, cfg.Suffix); err != nil { return err } - - if !ini.IsInstalled(*u, prefix, suffix) { - return fmt.Errorf("%s is not installed — run 'unitdore install' first", name) - } - - fmt.Printf(" ▶ starting %s...\n", ini.ServiceName(*u, prefix, suffix)) - if err := ini.Start(*u, prefix, suffix); err != nil { - return err - } - fmt.Printf(" ✓ started: %s\n", name) + fmt.Fprintf(w, " ✓ started: %s\n", name) return nil } diff --git a/cmd/startall.go b/cmd/startall.go deleted file mode 100644 index 9b4c44f..0000000 --- a/cmd/startall.go +++ /dev/null @@ -1,68 +0,0 @@ -package cmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - "github.com/warkanum/unitdore/config" - "github.com/warkanum/unitdore/initsys" -) - -var startallCmd = &cobra.Command{ - Use: "startall", - Short: "Start all installed, enabled units", - Long: `Startall runs 'systemctl start' for all enabled units that have been installed. -Units must be installed first via 'unitdore installall'.`, - RunE: runStartall, -} - -func init() { - rootCmd.AddCommand(startallCmd) -} - -func runStartall(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return err - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { - return err - } - - units := make([]config.Unit, len(cfg.Units)) - copy(units, cfg.Units) - sort.Slice(units, func(i, j int) bool { - if units[i].Order != units[j].Order { - return units[i].Order < units[j].Order - } - return units[i].Name < units[j].Name - }) - - started := 0 - failed := 0 - - for _, u := range units { - if !u.Enabled { - continue - } - if !ini.IsInstalled(u, prefix, suffix) { - fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name) - continue - } - fmt.Printf(" ▶ starting: %s...\n", ini.ServiceName(u, prefix, suffix)) - if err := ini.Start(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) - failed++ - } else { - fmt.Printf(" ✓ started: %s\n", u.Name) - started++ - } - } - - fmt.Printf("\nDone. Started: %d Failed: %d\n", started, failed) - return nil -} diff --git a/cmd/status.go b/cmd/status.go index 641699e..54a8d11 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "sort" "strings" "github.com/spf13/cobra" @@ -47,14 +46,7 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } - units := make([]config.Unit, len(cfg.Units)) - copy(units, cfg.Units) - sort.Slice(units, func(i, j int) bool { - if units[i].Order != units[j].Order { - return units[i].Order < units[j].Order - } - return units[i].Name < units[j].Name - }) + units := sortUnits(cfg.Units, false) rows := make([]statusRow, 0, len(units)) for _, u := range units { diff --git a/cmd/stop.go b/cmd/stop.go index 8ad4d8b..275fc79 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -2,49 +2,51 @@ package cmd import ( "fmt" + "io" + "os" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/initsys" ) +var stopAllFlag bool + var stopCmd = &cobra.Command{ - Use: "stop ", - Short: "Stop a unit by name", - Args: cobra.ExactArgs(1), - RunE: runStop, + Use: "stop | --all", + Short: "Stop a unit by name (or all with --all)", + Long: `Stop stops the named installed unit. + +With --all (same as 'stopall') it stops every installed unit in reverse +startup order, one at a time.`, + Args: nameOrAll(&stopAllFlag), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ini, err := loadInit() + if err != nil { + return err + } + if stopAllFlag { + stopAll(cfg, ini, os.Stdout) + return nil + } + return stopOne(cfg, ini, args[0], os.Stdout) + }, } func init() { + stopCmd.Flags().BoolVar(&stopAllFlag, "all", false, "stop all installed units") rootCmd.AddCommand(stopCmd) } -func runStop(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) +func stopOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error { + u, err := installedUnit(cfg, ini, name) if err != nil { return err } - - name := args[0] - u := cfg.FindUnit(name) - if u == nil { - return fmt.Errorf("unit %q not found", name) - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { + fmt.Fprintf(w, " ■ stopping %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix)) + if err := ini.Stop(*u, cfg.Prefix, cfg.Suffix); err != nil { return err } - - if !ini.IsInstalled(*u, prefix, suffix) { - return fmt.Errorf("%s is not installed", name) - } - - fmt.Printf(" ■ stopping %s...\n", ini.ServiceName(*u, prefix, suffix)) - if err := ini.Stop(*u, prefix, suffix); err != nil { - return err - } - fmt.Printf(" ✓ stopped: %s\n", name) + fmt.Fprintf(w, " ✓ stopped: %s\n", name) return nil } diff --git a/cmd/stopall.go b/cmd/stopall.go deleted file mode 100644 index c91ba56..0000000 --- a/cmd/stopall.go +++ /dev/null @@ -1,64 +0,0 @@ -package cmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - "github.com/warkanum/unitdore/config" - "github.com/warkanum/unitdore/initsys" -) - -var stopallCmd = &cobra.Command{ - Use: "stopall", - Short: "Stop all installed units", - Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`, - RunE: runStopall, -} - -func init() { - rootCmd.AddCommand(stopallCmd) -} - -func runStopall(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return err - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { - return err - } - - // Reverse order for shutdown - units := make([]config.Unit, len(cfg.Units)) - copy(units, cfg.Units) - sort.Slice(units, func(i, j int) bool { - if units[i].Order != units[j].Order { - return units[i].Order > units[j].Order - } - return units[i].Name > units[j].Name - }) - - stopped := 0 - failed := 0 - - for _, u := range units { - if !ini.IsInstalled(u, prefix, suffix) { - continue - } - fmt.Printf(" ■ stopping: %s...\n", ini.ServiceName(u, prefix, suffix)) - if err := ini.Stop(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) - failed++ - } else { - fmt.Printf(" ✓ stopped: %s\n", u.Name) - stopped++ - } - } - - fmt.Printf("\nDone. Stopped: %d Failed: %d\n", stopped, failed) - return nil -} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 1a2be9e..d5aa40e 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,58 +2,58 @@ package cmd import ( "fmt" + "io" + "os" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/initsys" ) +var uninstallAllFlag bool + var uninstallCmd = &cobra.Command{ - Use: "uninstall ", - Short: "Disable, stop, and remove the service file for a unit", - Args: cobra.ExactArgs(1), - RunE: runUninstall, + Use: "uninstall | --all", + Short: "Stop, disable and remove the service file for a unit (or all with --all)", + Long: `Uninstall stops, disables and removes the service file of the named unit. + +With --all (same as 'uninstallall') it does so for every installed unit in +reverse startup order.`, + Args: nameOrAll(&uninstallAllFlag), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ini, err := loadInit() + if err != nil { + return err + } + if uninstallAllFlag { + uninstallAll(cfg, ini, os.Stdout) + return nil + } + return uninstallOne(cfg, ini, args[0], os.Stdout) + }, } func init() { + uninstallCmd.Flags().BoolVar(&uninstallAllFlag, "all", false, "uninstall all installed units") rootCmd.AddCommand(uninstallCmd) } -func runUninstall(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) +func uninstallOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error { + u, err := installedUnit(cfg, ini, name) if err != nil { return err } - - name := args[0] - u := cfg.FindUnit(name) - if u == nil { - return fmt.Errorf("unit %q not found", name) - } - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { - return err - } - if !ini.IsInstalled(*u, prefix, suffix) { - return fmt.Errorf("%s is not installed", name) - } - - svcName := ini.ServiceName(*u, prefix, suffix) - - fmt.Printf(" ■ disabling %s...\n", svcName) + fmt.Fprintf(w, " ■ disabling %s...\n", ini.ServiceName(*u, prefix, suffix)) if err := ini.Disable(*u, prefix, suffix); err != nil { return fmt.Errorf("disabling unit: %w", err) } - path, _ := ini.UnitPath(*u, prefix, suffix) - fmt.Printf(" ✗ removing %s...\n", path) + fmt.Fprintf(w, " ✗ removing %s...\n", path) if err := ini.Uninstall(*u, prefix, suffix); err != nil { return fmt.Errorf("removing service file: %w", err) } - - fmt.Printf(" ✓ uninstalled: %s\n", name) + fmt.Fprintf(w, " ✓ uninstalled: %s\n", name) return nil } diff --git a/cmd/uninstallall.go b/cmd/uninstallall.go deleted file mode 100644 index 164614a..0000000 --- a/cmd/uninstallall.go +++ /dev/null @@ -1,71 +0,0 @@ -package cmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - "github.com/warkanum/unitdore/config" - "github.com/warkanum/unitdore/initsys" -) - -var uninstallallCmd = &cobra.Command{ - Use: "uninstallall", - Short: "Stop, disable, and remove service files for all installed units", - Long: `Uninstallall runs 'systemctl disable --now' and removes the .service file for every installed unit, in reverse startup order.`, - RunE: runUninstallall, -} - -func init() { - rootCmd.AddCommand(uninstallallCmd) -} - -func runUninstallall(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return err - } - - prefix, suffix := cfg.Prefix, cfg.Suffix - ini, err := initsys.New(cfg) - if err != nil { - return err - } - - units := make([]config.Unit, len(cfg.Units)) - copy(units, cfg.Units) - sort.Slice(units, func(i, j int) bool { - if units[i].Order != units[j].Order { - return units[i].Order > units[j].Order - } - return units[i].Name > units[j].Name - }) - - removed := 0 - failed := 0 - - for _, u := range units { - if !ini.IsInstalled(u, prefix, suffix) { - continue - } - svcName := ini.ServiceName(u, prefix, suffix) - fmt.Printf(" ■ disabling %s...\n", svcName) - if err := ini.Disable(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err) - failed++ - continue - } - path, _ := ini.UnitPath(u, prefix, suffix) - fmt.Printf(" ✗ removing %s...\n", path) - if err := ini.Uninstall(u, prefix, suffix); err != nil { - fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) - failed++ - continue - } - fmt.Printf(" ✓ uninstalled: %s\n", u.Name) - removed++ - } - - fmt.Printf("\nDone. Uninstalled: %d Failed: %d\n", removed, failed) - return nil -} diff --git a/cmd/update.go b/cmd/update.go index fc43b5c..bf5af59 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -3,6 +3,8 @@ package cmd import ( "errors" "fmt" + "io" + "os" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" @@ -38,44 +40,73 @@ func runUpdate(cmd *cobra.Command, args []string) error { if err != nil { return err } - - name := args[0] - u := cfg.FindUnit(name) - if u == nil { - return fmt.Errorf("unit %q not found", name) - } - - if cmd.Flags().Changed("order") { - u.Order = updateOrder - fmt.Printf(" order → %d\n", u.Order) - } - if updateEnable { - u.Enabled = true - u.DisabledReason = "" - fmt.Printf(" enabled\n") - } - if updateDisable { - u.Enabled = false - fmt.Printf(" disabled\n") - } - - if err := cfg.Save(configPath); err != nil { - return err - } - - prefix, suffix := cfg.Prefix, cfg.Suffix ini, err := initsys.New(cfg) if err != nil { return err } - if ini.IsInstalled(*u, prefix, suffix) { - if err := ini.Install(*u, prefix, suffix); err != nil { - return fmt.Errorf("recreating service file: %w", err) - } - path, _ := ini.UnitPath(*u, prefix, suffix) - fmt.Printf(" ✓ service file recreated: %s\n", path) + var order *int + if cmd.Flags().Changed("order") { + order = &updateOrder + } + return applyUpdate(cfg, ini, args[0], order, updateEnable, updateDisable, configPath, os.Stdout) +} + +// applyUpdate changes a unit's config, saves it and brings the installed +// service file in line: re-created if enabled, stopped and removed if disabled. +func applyUpdate(cfg *config.Config, ini initsys.Init, name string, order *int, enable, disable bool, path string, w io.Writer) error { + u := cfg.FindUnit(name) + if u == nil { + return fmt.Errorf("unit %q not found", name) } + if order != nil { + u.Order = *order + fmt.Fprintf(w, " order → %d\n", u.Order) + } + if enable { + u.Enabled = true + u.DisabledReason = "" + fmt.Fprintf(w, " enabled\n") + } + if disable { + u.Enabled = false + fmt.Fprintf(w, " disabled\n") + } + + if err := cfg.ResolveOrder(); err != nil { + return err + } + if err := cfg.Save(path); err != nil { + return err + } + + prefix, suffix := cfg.Prefix, cfg.Suffix + + // The target is stopped and removed if now disabled + if ini.IsInstalled(*u, prefix, suffix) && !u.Enabled { + svcPath, _ := ini.UnitPath(*u, prefix, suffix) + if err := ini.Disable(*u, prefix, suffix); err != nil { + return fmt.Errorf("disabling unit: %w", err) + } + if err := ini.Uninstall(*u, prefix, suffix); err != nil { + return fmt.Errorf("removing service file: %w", err) + } + fmt.Fprintf(w, " ✓ service stopped and removed: %s\n", svcPath) + } + + // Order and enabled state change the After=/delay of other units too, so + // every installed, enabled unit is regenerated + for i := range cfg.Units { + o := cfg.Units[i] + if !o.Enabled || !ini.IsInstalled(o, prefix, suffix) { + continue + } + if err := ini.Install(o, prefix, suffix); err != nil { + return fmt.Errorf("recreating service file for %s: %w", o.Name, err) + } + svcPath, _ := ini.UnitPath(o, prefix, suffix) + fmt.Fprintf(w, " ✓ service file recreated: %s\n", svcPath) + } return nil } diff --git a/config/config.go b/config/config.go index 1d39678..7f3f324 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "time" "gopkg.in/yaml.v3" ) @@ -43,6 +45,10 @@ type Unit struct { RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst) + // Resolved from order/delay by Config.ResolveOrder; never stored. + After []string `yaml:"-"` // enabled same-user units of the previous order group + StartDelay time.Duration `yaml:"-"` // wait before start (zero for the first group) + // Stack fields (kind: stack) — one unit for a whole podman-compose project. Kind string `yaml:"kind,omitempty"` // container (default) | stack Project string `yaml:"project,omitempty"` // compose project name @@ -102,9 +108,60 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } + if err := cfg.ResolveOrder(); err != nil { + return nil, err + } return &cfg, nil } +// ResolveOrder fills After and StartDelay on every unit from order and delay. +// After lists the enabled units of the nearest lower order group in the same +// scope (user). StartDelay is the unit's own delay, ignored for the first +// group. Call again after changing order, delay or enabled. +func (c *Config) ResolveOrder() error { + delays := make([]time.Duration, len(c.Units)) + for i := range c.Units { + if c.Units[i].Delay == "" { + continue + } + d, err := time.ParseDuration(c.Units[i].Delay) + if err != nil || d < 0 { + return fmt.Errorf("unit %q: invalid delay %q", c.Units[i].Name, c.Units[i].Delay) + } + delays[i] = d + } + + for i := range c.Units { + u := &c.Units[i] + u.After, u.StartDelay = nil, 0 + if !u.Enabled { + continue + } + hasLower, best := false, 0 + for j := range c.Units { + o := c.Units[j] + if !o.Enabled || o.Order >= u.Order { + continue + } + hasLower = true + if o.User != u.User { + continue + } + switch { + case u.After == nil || o.Order > best: + best, u.After = o.Order, []string{o.Name} + case o.Order == best: + u.After = append(u.After, o.Name) + } + } + sort.Strings(u.After) + if hasLower { + u.StartDelay = delays[i] + } + } + return nil +} + // LoadOrEmpty loads the config file, or returns an empty config if it doesn't exist. func LoadOrEmpty(path string) (*Config, error) { _, err := os.Stat(path) diff --git a/config/config_test.go b/config/config_test.go index 82b3c39..1c730f1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,7 +3,9 @@ package config import ( "os" "path/filepath" + "reflect" "testing" + "time" ) func TestLoadOrEmpty_NoFile(t *testing.T) { @@ -183,3 +185,70 @@ func TestUserConfigPath(t *testing.T) { t.Errorf("UserConfigPath() = %q", got) } } + +func TestResolveOrder(t *testing.T) { + cfg := &Config{Units: []Unit{ + {Name: "db", Order: 1, Enabled: true, Delay: "9s"}, + {Name: "cache", Order: 1, Enabled: true}, + {Name: "off", Order: 2, Enabled: false}, + {Name: "web", Order: 3, Enabled: true, Delay: "5s"}, + {Name: "web-user", Order: 3, Enabled: true, User: "hein"}, + {Name: "user-db", Order: 1, Enabled: true, User: "hein"}, + }} + if err := cfg.ResolveOrder(); err != nil { + t.Fatal(err) + } + get := func(n string) Unit { return *cfg.FindUnit(n) } + + if u := get("db"); len(u.After) != 0 || u.StartDelay != 0 { + t.Errorf("first group must not wait: %+v", u) + } + if u := get("off"); len(u.After) != 0 || u.StartDelay != 0 { + t.Errorf("disabled unit gets no ordering: %+v", u) + } + // skips the disabled order-2 unit, picks the enabled order-1 same-scope units + if u := get("web"); !reflect.DeepEqual(u.After, []string{"cache", "db"}) || u.StartDelay != 5*time.Second { + t.Errorf("web: %+v", u) + } + // user units only order after units of the same user + if u := get("web-user"); !reflect.DeepEqual(u.After, []string{"user-db"}) || u.StartDelay != 0 { + t.Errorf("web-user: %+v", u) + } + + // re-resolving after a change resets stale values + cfg.FindUnit("db").Order = 9 + if err := cfg.ResolveOrder(); err != nil { + t.Fatal(err) + } + if u := get("web"); !reflect.DeepEqual(u.After, []string{"cache"}) { + t.Errorf("web after reorder: %+v", u) + } +} + +func TestResolveOrder_InvalidDelay(t *testing.T) { + for _, d := range []string{"abc", "-2s"} { + cfg := &Config{Units: []Unit{{Name: "a", Delay: d, Enabled: true}}} + if err := cfg.ResolveOrder(); err == nil { + t.Errorf("delay %q should be rejected", d) + } + } +} + +func TestLoad_ResolvesOrderAndRejectsBadDelay(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good.yaml") + os.WriteFile(good, []byte("units:\n - {name: a, order: 1, enabled: true}\n - {name: b, order: 2, delay: 4s, enabled: true}\n"), 0644) + cfg, err := Load(good) + if err != nil { + t.Fatal(err) + } + if b := cfg.Units[1]; !reflect.DeepEqual(b.After, []string{"a"}) || b.StartDelay != 4*time.Second { + t.Errorf("b = %+v", b) + } + + bad := filepath.Join(dir, "bad.yaml") + os.WriteFile(bad, []byte("units:\n - {name: a, delay: nope, enabled: true}\n"), 0644) + if _, err := Load(bad); err == nil { + t.Error("Load must reject invalid delay") + } +} diff --git a/docs/unitdore.1 b/docs/unitdore.1 index 3bb8ea1..06ebf18 100644 --- a/docs/unitdore.1 +++ b/docs/unitdore.1 @@ -30,29 +30,38 @@ podman\-compose containers become one \fBkind: stack\fR unit per project; existing member units are disabled. As root, rootless Podman containers of each user in \fBusers:\fR are also discovered. .TP -.B install -Generate \fI.service\fR files for all enabled units and write them to the -appropriate systemd directory. Use \fB\-\-dry\-run\fR to preview without writing. +.B install \fIname\fR | \fB\-\-all\fR +Generate the service file for one enabled unit. Does not enable or start it. +Use \fB\-\-dry\-run\fR to preview without writing. With \fB\-\-all\fR (and no +name) it behaves like \fBinstallall\fR. .TP -.B uninstall \fIname\fR -Disable, stop, and remove the service file for a specific unit. +.B installall +Generate service files for all enabled units. Disabled units that are +installed are stopped, disabled and removed. Supports \fB\-\-dry\-run\fR. .TP -.B startall -Run \fBsystemctl enable \-\-now\fR for all enabled, installed units in startup -order. +.B uninstall \fIname\fR | \fB\-\-all\fR +Disable, stop, and remove the service file for a unit. With \fB\-\-all\fR, for +every installed unit in reverse startup order (same as \fBuninstallall\fR). .TP -.B stopall -Run \fBsystemctl disable \-\-now\fR for all installed units in reverse startup -order. +.B start \fIname\fR | \fB\-\-all\fR +Start an installed unit without enabling it at boot. With \fB\-\-all\fR, start +all enabled, installed units in startup order: equal \fBorder\fR in parallel, +groups one after another (same as \fBstartall\fR). \fBorder\fR and \fBdelay\fR are +also written into the service files (systemd \fBAfter=\fR and \fBExecStartPre\fR, +OpenRC \fBafter\fR and \fBstart_pre\fR, runit \fBsv check\fR and \fBsleep\fR), so +they apply at boot. .TP -.B start \fIname\fR -Start a specific unit by name (\fBsystemctl start\fR). +.B enable \fIname\fR | \fB\-\-all\fR +Like \fBstart\fR, but also enable at boot (same as \fBenableall\fR). .TP -.B stop \fIname\fR -Stop a specific unit by name (\fBsystemctl stop\fR). +.B stop \fIname\fR | \fB\-\-all\fR +Stop an installed unit. With \fB\-\-all\fR, stop all installed units one at a time +in reverse startup order (same as \fBstopall\fR). .TP .B update \fIname\fR -Update a unit's config and recreate its service file. +Update a unit's config. An installed unit's service file is recreated, or +stopped and removed if the unit is now disabled. Other installed units are +regenerated too, as ordering depends on their neighbours. .RS .TP .BR \-\-order " " \fIn\fR @@ -127,15 +136,15 @@ Discover containers and install their service files: .RS .nf sudo unitdore syncup -sudo unitdore install -sudo unitdore startall +sudo unitdore install \-\-all +sudo unitdore start \-\-all .fi .RE .PP Preview generated service files without writing: .PP .RS -sudo unitdore install \-\-dry\-run +sudo unitdore install nginx \-\-dry\-run .RE .PP Start a single container unit: diff --git a/openrc/openrc.go b/openrc/openrc.go index d92068b..29959e8 100644 --- a/openrc/openrc.go +++ b/openrc/openrc.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/service" @@ -194,13 +195,28 @@ func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) if u.Runtime == "docker" { w.WriteString("\tneed docker\n") } + if len(u.After) > 0 { + names := make([]string, len(u.After)) + for i, n := range u.After { + names[i] = service.BaseName(config.Unit{Name: n}, prefix, suffix) + } + fmt.Fprintf(&w, "\tafter %s\n", strings.Join(names, " ")) + } w.WriteString("}\n") + var pre []string + if spec.Type != service.TypeOneshot && user != nil { + pre = append(pre, fmt.Sprintf("checkpath -d -m 0700 -o %s %s", + service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir))) + } + if secs := delaySeconds(u); secs > 0 { + pre = append(pre, fmt.Sprintf("sleep %d", secs)) + } + if len(pre) > 0 { + fmt.Fprintf(&w, "\nstart_pre() {\n\t%s\n}\n", strings.Join(pre, "\n\t")) + } + if spec.Type != service.TypeOneshot { - if user != nil { - fmt.Fprintf(&w, "\nstart_pre() {\n\tcheckpath -d -m 0700 -o %s %s\n}\n", - service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir)) - } if stop != "" { fmt.Fprintf(&w, "\nstop_pre() {\n\t%s || true\n}\n", asUser(user, stop)) } @@ -277,3 +293,8 @@ func dq(s string) string { r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`, "`", "\\`") return `"` + r.Replace(s) + `"` } + +// delaySeconds rounds the unit's start delay up to whole seconds. +func delaySeconds(u config.Unit) int { + return int((u.StartDelay + time.Second - 1) / time.Second) +} diff --git a/openrc/openrc_test.go b/openrc/openrc_test.go index 92f6180..4dd0153 100644 --- a/openrc/openrc_test.go +++ b/openrc/openrc_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/service" @@ -219,3 +220,34 @@ func TestParseStatus(t *testing.T) { } } } + +func TestGenerate_OrderAndDelay(t *testing.T) { + setup(t) + u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db", "cache"}, StartDelay: 5 * time.Second} + got, err := Backend{}.Generate(u, "p-", "") + if err != nil { + t.Fatal(err) + } + mustContain(t, got, "need net\n\tafter unitdore-p-db unitdore-p-cache\n}", "start_pre() {\n\tsleep 5\n}") +} + +func TestGenerate_OneshotDelay(t *testing.T) { + setup(t) + u := config.Unit{Name: "job", Runtime: "exec", Executable: "/bin/true", ServiceType: "oneshot", Enabled: true, StartDelay: time.Second} + got, err := Backend{}.Generate(u, "", "") + if err != nil { + t.Fatal(err) + } + mustContain(t, got, "start_pre() {\n\tsleep 1\n}") +} + +func TestGenerate_NoOrderNoExtras(t *testing.T) { + setup(t) + got, err := Backend{}.Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "after ") || strings.Contains(got, "start_pre") { + t.Errorf("unexpected ordering output:\n%s", got) + } +} diff --git a/runit/runit.go b/runit/runit.go index c55fd09..2ef1f8c 100644 --- a/runit/runit.go +++ b/runit/runit.go @@ -125,7 +125,7 @@ func (b Backend) files(u config.Unit, prefix, suffix string) ([]file, error) { header := "#!/bin/sh\n# " + b.ServiceName(u, prefix, suffix) + " — generated by unitdore, do not edit manually\n" - run := header + "exec 2>&1\n" + preamble(user, spec.WorkingDir) + run := header + "exec 2>&1\n" + b.waitFor(u, prefix, suffix) + preamble(user, spec.WorkingDir) if spec.Type == service.TypeOneshot { // Run once, then stay up (like RemainAfterExit) until stopped run += asUser(user, start) + " || exit 1\nexec tail -f /dev/null\n" @@ -140,6 +140,24 @@ func (b Backend) files(u config.Unit, prefix, suffix string) ([]file, error) { return files, nil } +// predecessorWait is how long run waits for each unit of the previous group. +const predecessorWait = 30 + +// waitFor holds the service until the previous order group is up, then sleeps +// the unit's delay. `sv check` never starts a service, and returns at once for +// one that is down or not installed. +func (b Backend) waitFor(u config.Unit, prefix, suffix string) string { + var s string + for _, n := range u.After { + link := b.link(config.Unit{Name: n}, prefix, suffix) + s += fmt.Sprintf("sv -w %d check %s >/dev/null 2>&1 || true\n", predecessorWait, service.ShellQuote(link)) + } + if secs := int((u.StartDelay + time.Second - 1) / time.Second); secs > 0 { + s += fmt.Sprintf("sleep %d\n", secs) + } + return s +} + // preamble prepares the environment: working dir and rootless user runtime. func preamble(user *service.UserEnv, workDir string) string { var s string diff --git a/runit/runit_test.go b/runit/runit_test.go index c79d6f3..75e18eb 100644 --- a/runit/runit_test.go +++ b/runit/runit_test.go @@ -293,3 +293,32 @@ func TestParseStatus(t *testing.T) { } } } + +func TestGenerate_OrderAndDelay(t *testing.T) { + b, _ := setup(t) + u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db"}, StartDelay: 3 * time.Second} + got, err := b.Generate(u, "", "") + if err != nil { + t.Fatal(err) + } + link := filepath.Join(b.ServiceDir, "unitdore-db") + wait := "sv -w 30 check " + link + " >/dev/null 2>&1 || true\nsleep 3\n" + if !strings.Contains(got, wait) { + t.Errorf("missing %q in:\n%s", wait, got) + } + // waiting happens before the service command runs + if strings.Index(got, "sleep 3") > strings.Index(got, "exec /usr/bin/podman") { + t.Errorf("delay must precede exec:\n%s", got) + } +} + +func TestGenerate_NoOrderNoExtras(t *testing.T) { + b, _ := setup(t) + got, err := b.Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "sv -w") || strings.Contains(got, "sleep") { + t.Errorf("unexpected ordering output:\n%s", got) + } +} diff --git a/systemd/generator.go b/systemd/generator.go index e5fb079..6dcfb3a 100644 --- a/systemd/generator.go +++ b/systemd/generator.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "text/template" + "time" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/service" @@ -14,7 +15,7 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}} [Unit] Description=Unitdore: {{.Unit.Name}} ({{.Label}}) -After=network.target{{with .RuntimeService}} {{.}} +After=network.target{{with .RuntimeService}} {{.}}{{end}}{{range .After}} {{.}}{{end}}{{with .RuntimeService}} Requires={{.}}{{end}} [Service] @@ -25,6 +26,10 @@ RemainAfterExit=yes {{- with .WorkingDir}} WorkingDirectory={{.}} {{- end}} +{{- with .DelaySec}} +ExecStartPre=/usr/bin/env sleep {{.}} +TimeoutStartSec={{$.StartTimeout}} +{{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} @@ -44,7 +49,7 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}} [Unit] Description=Unitdore: {{.Unit.Name}} ({{.Label}}) -After=default.target +After=default.target{{range .After}} {{.}}{{end}} [Service] Type={{.ServiceType}} @@ -54,6 +59,10 @@ RemainAfterExit=yes {{- with .WorkingDir}} WorkingDirectory={{.}} {{- end}} +{{- with .DelaySec}} +ExecStartPre=/usr/bin/env sleep {{.}} +TimeoutStartSec={{$.StartTimeout}} +{{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} @@ -77,6 +86,9 @@ type templateData struct { Label string // shown in Description ServiceType string // simple | oneshot WorkingDir string + After []string // service names of the previous order group + DelaySec int // ExecStartPre sleep, from unit delay + StartTimeout int // TimeoutStartSec when a delay is set } // ServiceName returns the systemd service name for a unit, with optional prefix/suffix. @@ -102,6 +114,14 @@ func Generate(u config.Unit, prefix, suffix string) (string, error) { WorkingDir: spec.WorkingDir, } + for _, n := range u.After { + data.After = append(data.After, ServiceName(config.Unit{Name: n}, prefix, suffix)) + } + if u.StartDelay > 0 { + data.DelaySec = int((u.StartDelay + time.Second - 1) / time.Second) + data.StartTimeout = data.DelaySec + 90 + } + tmplStr := systemUnitTemplate if u.User != "" { tmplStr = userUnitTemplate diff --git a/systemd/generator_test.go b/systemd/generator_test.go index 4e791bc..0924e37 100644 --- a/systemd/generator_test.go +++ b/systemd/generator_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/warkanum/unitdore/config" ) @@ -437,3 +438,44 @@ func TestGenerate_ExecutableMissingPath(t *testing.T) { t.Error("Generate() should error when executable is empty") } } + +func TestGenerate_OrderAndDelay(t *testing.T) { + u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db", "cache"}, StartDelay: 1500 * time.Millisecond} + content, err := Generate(u, "p-", "") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "After=network.target unitdore-p-db.service unitdore-p-cache.service\n", + "ExecStartPre=/usr/bin/env sleep 2\n", + "TimeoutStartSec=92\n", + } { + if !strings.Contains(content, want) { + t.Errorf("missing %q in:\n%s", want, content) + } + } +} + +func TestGenerate_UserUnitOrder(t *testing.T) { + u := config.Unit{Name: "web", Runtime: "podman", User: "hein", Enabled: true, After: []string{"db"}} + content, err := Generate(u, "", "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(content, "After=default.target unitdore-db.service\n") { + t.Errorf("user unit missing ordering:\n%s", content) + } + if strings.Contains(content, "ExecStartPre") { + t.Errorf("no delay expected:\n%s", content) + } +} + +func TestGenerate_NoOrderNoExtras(t *testing.T) { + content, err := Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(content, "ExecStartPre") || strings.Contains(content, "TimeoutStartSec") || !strings.Contains(content, "After=network.target\n") { + t.Errorf("unexpected ordering output:\n%s", content) + } +}