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