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