- 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
208 lines
6.7 KiB
Go
208 lines
6.7 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const DefaultConfigPath = "/etc/unitdore/units.yaml"
|
|
|
|
// UserConfigPath returns the config path used when running as a normal user.
|
|
func UserConfigPath(home string) string {
|
|
return filepath.Join(home, ".config", "unitdore", "units.yaml")
|
|
}
|
|
|
|
// Unit kinds.
|
|
const (
|
|
KindContainer = "container"
|
|
KindStack = "stack"
|
|
)
|
|
|
|
// Runtimes for units that are not containers.
|
|
const (
|
|
RuntimeScript = "script" // run a shell script
|
|
RuntimeExec = "exec" // run an executable directly
|
|
|
|
DefaultShell = "/bin/bash"
|
|
)
|
|
|
|
// Unit represents a single managed container unit.
|
|
type Unit struct {
|
|
Name string `yaml:"name"`
|
|
Runtime string `yaml:"runtime"` // podman | docker | exec
|
|
User string `yaml:"user,omitempty"` // empty = root/system unit
|
|
Command string `yaml:"command,omitempty"` // override ExecStart
|
|
Order int `yaml:"order"`
|
|
Delay string `yaml:"delay,omitempty"` // e.g. "5s"
|
|
Enabled bool `yaml:"enabled"`
|
|
DisabledReason string `yaml:"disabled_reason,omitempty"`
|
|
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
|
|
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
|
|
WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory
|
|
ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute
|
|
ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose
|
|
|
|
// Script fields (runtime: script) — run a shell file as a service.
|
|
Script string `yaml:"script,omitempty"` // path to script run as ExecStart
|
|
StopScript string `yaml:"stop_script,omitempty"` // optional script run as ExecStop
|
|
Shell string `yaml:"shell,omitempty"` // interpreter, default /bin/bash
|
|
|
|
// Executable fields (runtime: exec) — run a binary directly.
|
|
Executable string `yaml:"executable,omitempty"` // path to binary run as ExecStart
|
|
Args []string `yaml:"args,omitempty"` // arguments for executable
|
|
StopExecutable string `yaml:"stop_executable,omitempty"` // optional binary run as ExecStop
|
|
|
|
// Script and exec units: simple (default) | oneshot. WorkingDir is also used.
|
|
ServiceType string `yaml:"service_type,omitempty"`
|
|
}
|
|
|
|
// IsStack reports whether the unit manages a compose stack.
|
|
func (u Unit) IsStack() bool { return u.Kind == KindStack }
|
|
|
|
// IsScript reports whether the unit runs a shell script.
|
|
func (u Unit) IsScript() bool { return u.Runtime == RuntimeScript }
|
|
|
|
// IsExec reports whether the unit runs an executable directly.
|
|
func (u Unit) IsExec() bool { return u.Runtime == RuntimeExec }
|
|
|
|
// ShellPath returns the configured interpreter, or the default.
|
|
func (u Unit) ShellPath() string {
|
|
if u.Shell == "" {
|
|
return DefaultShell
|
|
}
|
|
return u.Shell
|
|
}
|
|
|
|
// Config is the root config structure.
|
|
type Config struct {
|
|
Units []Unit `yaml:"units"`
|
|
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
|
|
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
|
|
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
|
|
|
|
Init string `yaml:"init,omitempty"` // systemd | openrc | runit (empty = autodetect)
|
|
ServiceDir string `yaml:"service_dir,omitempty"` // runit: supervised service dir (empty = autodetect)
|
|
}
|
|
|
|
// Load reads and parses the config file at path.
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config: %w", err)
|
|
}
|
|
var cfg Config
|
|
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)
|
|
if os.IsNotExist(err) {
|
|
return &Config{}, nil
|
|
}
|
|
return Load(path)
|
|
}
|
|
|
|
// Save writes the config back to disk, creating parent directories if needed.
|
|
func (c *Config) Save(path string) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return fmt.Errorf("creating config dir: %w", err)
|
|
}
|
|
data, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling config: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
return fmt.Errorf("writing config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindUnit returns a pointer to the unit with the given name, or nil.
|
|
func (c *Config) FindUnit(name string) *Unit {
|
|
for i := range c.Units {
|
|
if c.Units[i].Name == name {
|
|
return &c.Units[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddUnit appends a unit if one with that name doesn't already exist.
|
|
// Returns true if it was added.
|
|
func (c *Config) AddUnit(u Unit) bool {
|
|
if c.FindUnit(u.Name) != nil {
|
|
return false
|
|
}
|
|
c.Units = append(c.Units, u)
|
|
return true
|
|
}
|