feat: order/delay in service files, enable cmd, --all on unit commands

- 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
This commit is contained in:
2026-10-12 21:31:41 +02:00
parent d1d0889eb3
commit 91811b4fa1
28 changed files with 1394 additions and 503 deletions
+8 -5
View File
@@ -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 <name> 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`
+33 -9
View File
@@ -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 <name>
```
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 <name> --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 <name> # start only
sudo unitdore enable <name> # enable at boot + start
sudo unitdore stop <name>
sudo unitdore uninstall <name> # 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 <prev>` 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
+130
View File
@@ -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 '<command> --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)
}
+460
View File
@@ -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)
}
}
}
+4
View File
@@ -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
+52
View File
@@ -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 <name> | --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
}
+46 -2
View File
@@ -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
}
+121 -39
View File
@@ -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 <name> | --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)
}
-89
View File
@@ -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
}
+1 -9
View File
@@ -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)
+67
View File
@@ -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
}
+29 -26
View File
@@ -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 <name>",
Short: "Start a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStart,
Use: "start <name> | --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
}
-68
View File
@@ -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
}
+1 -9
View File
@@ -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 {
+28 -26
View File
@@ -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 <name>",
Short: "Stop a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStop,
Use: "stop <name> | --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
}
-64
View File
@@ -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
}
+28 -28
View File
@@ -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 <name>",
Short: "Disable, stop, and remove the service file for a unit",
Args: cobra.ExactArgs(1),
RunE: runUninstall,
Use: "uninstall <name> | --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
}
-71
View File
@@ -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
}
+63 -32
View File
@@ -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
}
+57
View File
@@ -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)
+69
View File
@@ -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")
}
}
+28 -19
View File
@@ -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:
+25 -4
View File
@@ -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)
}
+32
View File
@@ -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)
}
}
+19 -1
View File
@@ -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
+29
View File
@@ -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)
}
}
+22 -2
View File
@@ -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
+42
View File
@@ -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)
}
}