Files
unitdore/config/config.go
T
warkanum 95143dd998 feat: script and exec runtimes with configurable shell
Add runtime: script (shell file, default /bin/bash, optional stop_script) and runtime: exec (executable with args, optional stop_executable). Both support service_type simple|oneshot and working_dir.
2026-10-12 19:45:54 +02:00

148 lines
5.0 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"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)
// 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
}
// 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)
}
return &cfg, 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
}