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