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 }