feat: OpenRC and runit init backends behind an Init interface

Extract init-neutral unit resolution into service/, add initsys.Init with systemd, openrc and runit backends, selected by 'init:' config or autodetect. OpenRC and runit are root only; rootless units run as system services as that user. Add docs and generated examples for all three.
This commit is contained in:
2026-10-12 20:19:58 +02:00
parent 95143dd998
commit df9b226c7e
32 changed files with 2329 additions and 322 deletions
+15 -2
View File
@@ -1,6 +1,6 @@
## Project Overview
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and systemd. It discovers running containers, stores them in a YAML config, and generates + manages systemd `.service` unit files for each one.
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and the init system (systemd, OpenRC, runit). It discovers running containers, stores them in a YAML config, and generates + manages service files for each one.
# Agent Rules
@@ -33,7 +33,11 @@ config/ # Config struct + YAML load/save (units.yaml)
runtime/ # Docker and Podman runtime abstraction, compose labels
sysuser/ # run commands as root/user (runuser), home/linger lookup
testdata/stack # podman-compose test stack + captured `podman ps` fixture
systemd/ # .service file generator and systemd manager
service/ # init-neutral unit resolution: commands, quoting, user env
initsys/ # Init interface, backend selection (config init: / autodetect)
systemd/ # .service generator + systemctl manager (Backend)
openrc/ # /etc/init.d script generator + rc-service manager (Backend)
runit/ # /etc/sv run/finish generator + sv manager (Backend)
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
```
@@ -41,13 +45,18 @@ docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
- **`service`** — `Resolve(unit)` → start/stop commands, type, workdir; shared by all backends
- **`initsys`** — `Init` interface; `New(cfg)` picks backend; cmd/ only talks to `Init`
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
- **`openrc`**, **`runit`** — same role; root only; tests stub command execution via package vars
### Config file
**Location:** `/etc/unitdore/units.yaml`
```yaml
init: "" # systemd | openrc | runit (empty = autodetect)
service_dir: "" # runit: supervised dir (empty = autodetect)
prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc")
users: [] # root only: rootless users scanned by syncup
@@ -101,6 +110,10 @@ Tests exist in:
- `runtime/runtime_test.go`
- `cmd/syncup_test.go`
- `sysuser/sysuser_test.go`
- `service/spec_test.go`
- `initsys/initsys_test.go`
- `openrc/openrc_test.go`
- `runit/runit_test.go`
- `systemd/generator_test.go`
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
+24 -1
View File
@@ -2,7 +2,7 @@
> *A door you open and close for container units.*
Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages systemd `.service` units for each one.
Unitdore bridges your container runtime (Podman, Docker) and your init system (systemd, OpenRC, runit). It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages a service for each one.
## Install
@@ -107,6 +107,8 @@ oldthing podman hein no no — container
**Location:** `/etc/unitdore/units.yaml`
```yaml
init: "" # optional: systemd | openrc | runit (empty = autodetect)
service_dir: "" # optional: runit supervised dir (empty = /var/service or /etc/service)
prefix: "" # optional: prepended to all service names, e.g. "prod-"
suffix: "" # optional: appended to all service names, e.g. "-svc"
users: [hein] # root only: rootless users scanned by syncup
@@ -158,6 +160,27 @@ units:
enabled: true
```
### Init systems
Set `init:` in the config, or leave empty to autodetect (`/run/systemd/system` → systemd, `/run/openrc` → openrc, `/etc/runit` → runit, else systemd).
| | systemd | openrc | runit |
|---|---|---|---|
| Service file | `/etc/systemd/system/unitdore-<n>.service` | `/etc/init.d/unitdore-<n>` | `/etc/sv/unitdore-<n>/{run,finish}` + symlink in `service_dir` |
| Start / stop | `systemctl` | `rc-service` | `sv` |
| Enable at boot | `systemctl enable` | `rc-update add … default` | remove `down` file |
| Installed state | disabled until enabled | not in runlevel | symlinked with `down` file (no autostart) |
| Rootless `user:` | `systemctl --user` unit (needs linger) | system service, `command_user` | system service, `chpst -u` |
| Non-root use | yes (own user units) | **root only** | **root only** |
| `restart*` | `Restart=on-failure`, `StartLimitBurst` | `supervise-daemon` `respawn_*` (always respawns) | `finish`: on-failure, `RestartSec`; **no retry limit** |
| `docker` dependency | `Requires=docker.service` | `need docker` | none |
| `oneshot` | `RemainAfterExit` | `start()`/`stop()` functions | run once, then `tail -f /dev/null` |
- Rootless units on openrc/runit set `HOME` and `XDG_RUNTIME_DIR=/run/user/<uid>` and create that dir if missing.
- `command` / `args` use systemd quoting; they are re-quoted for sh on openrc/runit.
- No log redirection is generated on openrc; use the runtime's own logs.
- `docs/generated-units.md` shows systemd output only.
### Script / executable units
Added manually (`unitdore edit`); syncup never creates or disables them.
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/initsys"
"github.com/warkanum/unitdore/sysuser"
)
@@ -16,8 +17,8 @@ func runtimeLabel(u config.Unit) string {
}
// lingerHint prints, once per user, how to make user units start at boot.
func lingerHint(seen map[string]bool, u config.Unit) {
if u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) {
func lingerHint(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
+14 -10
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var dryRun bool
@@ -32,6 +32,10 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
installed := 0
skipped := 0
removed := 0
@@ -40,14 +44,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
for _, u := range cfg.Units {
if !u.Enabled {
// Remove unit file if it exists for disabled units
if systemd.IsInstalled(u, prefix, suffix) {
if ini.IsInstalled(u, prefix, suffix) {
if dryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
} else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
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", systemd.ServiceName(u, prefix, suffix))
fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
removed++
}
}
@@ -58,23 +62,23 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
if dryRun {
content, err := systemd.Generate(u, prefix, suffix)
content, err := ini.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := systemd.Install(u, prefix, suffix); err != nil {
if err := ini.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else {
path, _ := systemd.UnitPath(u, prefix, suffix)
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
lingerHint(ini, lingerSeen, u)
installed++
}
}
+14 -10
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var installallDryRun bool
@@ -32,6 +32,10 @@ func runInstallall(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
installed := 0
skipped := 0
removed := 0
@@ -39,14 +43,14 @@ func runInstallall(cmd *cobra.Command, args []string) error {
for _, u := range cfg.Units {
if !u.Enabled {
if systemd.IsInstalled(u, prefix, suffix) {
if ini.IsInstalled(u, prefix, suffix) {
if installallDryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
} else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
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", systemd.ServiceName(u, prefix, suffix))
fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
removed++
}
}
@@ -57,23 +61,23 @@ func runInstallall(cmd *cobra.Command, args []string) error {
}
if installallDryRun {
content, err := systemd.Generate(u, prefix, suffix)
content, err := ini.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := systemd.Install(u, prefix, suffix); err != nil {
if err := ini.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else {
path, _ := systemd.UnitPath(u, prefix, suffix)
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
lingerHint(ini, lingerSeen, u)
installed++
}
}
+7 -3
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var listCmd = &cobra.Command{
@@ -41,6 +41,10 @@ func runList(cmd *cobra.Command, args []string) error {
})
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
type row struct{ name, runtime, enabled, installed, path string }
@@ -53,9 +57,9 @@ func runList(cmd *cobra.Command, args []string) error {
installed := "no"
unitPath := "—"
if p, err := systemd.UnitPath(u, prefix, suffix); err == nil {
if p, err := ini.UnitPath(u, prefix, suffix); err == nil {
unitPath = p
if systemd.IsInstalled(u, prefix, suffix) {
if ini.IsInstalled(u, prefix, suffix) {
installed = "yes"
}
}
+3 -2
View File
@@ -16,10 +16,11 @@ var configPath string
var rootCmd = &cobra.Command{
Use: "unitdore",
Short: "A door you open and close for container units",
Long: `Unitdore manages container units via systemd.
Long: `Unitdore manages container units via the system init (systemd, OpenRC or runit).
It discovers running containers and podman-compose stacks, stores them in a
config file, and generates + manages systemd .service units for each one.
config file, and generates + manages init service units for each one (systemd by default;
set 'init:' in the config for openrc or runit).
As root: system units, plus user units for users listed in 'users:'.
As a normal user: own config and user units only.`,
+8 -4
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var startCmd = &cobra.Command{
@@ -32,13 +32,17 @@ func runStart(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) {
if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
}
fmt.Printf(" ▶ starting %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Start(*u, prefix, suffix); err != nil {
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)
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var startallCmd = &cobra.Command{
@@ -28,6 +28,10 @@ func runStartall(cmd *cobra.Command, args []string) error {
}
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)
@@ -45,12 +49,12 @@ func runStartall(cmd *cobra.Command, args []string) error {
if !u.Enabled {
continue
}
if !systemd.IsInstalled(u, prefix, suffix) {
if !ini.IsInstalled(u, prefix, suffix) {
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
continue
}
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Start(u, prefix, suffix); err != nil {
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 {
+8 -4
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var statusCmd = &cobra.Command{
@@ -37,6 +37,10 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if len(cfg.Units) == 0 {
fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.")
@@ -65,13 +69,13 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
installed := "yes"
if !systemd.IsInstalled(u, prefix, suffix) {
if !ini.IsInstalled(u, prefix, suffix) {
installed = "no"
}
state := "—"
if systemd.IsInstalled(u, prefix, suffix) {
state = systemd.Status(u, prefix, suffix)
if ini.IsInstalled(u, prefix, suffix) {
state = ini.Status(u, prefix, suffix)
}
rows = append(rows, statusRow{
+8 -4
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var stopCmd = &cobra.Command{
@@ -32,13 +32,17 @@ func runStop(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) {
if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
fmt.Printf(" ■ stopping %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Stop(*u, prefix, suffix); err != nil {
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)
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var stopallCmd = &cobra.Command{
@@ -27,6 +27,10 @@ func runStopall(cmd *cobra.Command, args []string) error {
}
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))
@@ -42,11 +46,11 @@ func runStopall(cmd *cobra.Command, args []string) error {
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
if !ini.IsInstalled(u, prefix, suffix) {
continue
}
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Stop(u, prefix, suffix); err != nil {
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 {
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/service"
"github.com/warkanum/unitdore/sysuser"
)
@@ -221,7 +221,7 @@ func mergeDiscovered(cfg *config.Config, containers []runtime.Container, compose
// unitExists checks whether a unit's container or stack still exists.
func unitExists(u config.Unit) (bool, error) {
if u.IsStack() {
if systemd.ComposeAvailable(u) {
if service.ComposeAvailable(u) {
return true, nil
}
return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project))
+10 -6
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var uninstallCmd = &cobra.Command{
@@ -32,21 +32,25 @@ func runUninstall(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) {
if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
svcName := systemd.ServiceName(*u, prefix, suffix)
svcName := ini.ServiceName(*u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(*u, prefix, suffix); err != nil {
if err := ini.Disable(*u, prefix, suffix); err != nil {
return fmt.Errorf("disabling unit: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
path, _ := ini.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(*u, prefix, suffix); err != nil {
if err := ini.Uninstall(*u, prefix, suffix); err != nil {
return fmt.Errorf("removing service file: %w", err)
}
+10 -6
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var uninstallallCmd = &cobra.Command{
@@ -27,6 +27,10 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
}
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)
@@ -41,19 +45,19 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
if !ini.IsInstalled(u, prefix, suffix) {
continue
}
svcName := systemd.ServiceName(u, prefix, suffix)
svcName := ini.ServiceName(u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(u, prefix, suffix); err != nil {
if err := ini.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
failed++
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
failed++
continue
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var (
@@ -64,12 +64,16 @@ func runUpdate(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if systemd.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix); err != nil {
if ini.IsInstalled(*u, prefix, suffix) {
if err := ini.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
path, _ := ini.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✓ service file recreated: %s\n", path)
}
+3
View File
@@ -87,6 +87,9 @@ type Config struct {
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.
+323 -1
View File
@@ -1,4 +1,4 @@
# Generated Systemd Unit Files
# Generated Unit Files (systemd, OpenRC, runit)
Unitdore generates `.service` files from your `units.yaml` config.
This document shows every combination with annotated examples.
@@ -330,6 +330,326 @@ ExecStop=-/usr/bin/podman pod stop pod_shop
---
## Config Behind the Init System Examples
```yaml
init: systemd # systemd | openrc | runit
units:
- {name: nginx, runtime: podman, order: 1, enabled: true}
- {name: redis, runtime: docker, order: 2, enabled: true, restart: true, restart_sec: 5, restart_retries: 3}
- {name: myapp, runtime: podman, user: alice, order: 3, enabled: true}
- {name: backup, runtime: script, script: /opt/scripts/backup.sh, stop_script: /opt/scripts/stop.sh, shell: /bin/sh, working_dir: /opt/scripts, order: 4, enabled: true}
- {name: agent, runtime: exec, executable: /opt/agent/bin/agent, args: [--config, /etc/agent.toml], service_type: oneshot, order: 5, enabled: true}
- {name: shop, runtime: podman, kind: stack, project: shop, order: 6, enabled: true}
```
---
## Script and Executable Units (systemd)
`script` runs `<shell> <script>` (default shell `/bin/bash`); `exec` runs the binary directly.
```ini
# /etc/systemd/system/unitdore-backup.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: backup (sh script)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/scripts
ExecStart=/bin/sh /opt/scripts/backup.sh
ExecStop=-/bin/sh /opt/scripts/stop.sh
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/unitdore-agent.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: agent (executable)
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/agent/bin/agent --config /etc/agent.toml
ExecStop=
[Install]
WantedBy=multi-user.target
```
---
## OpenRC
Root only. Files in `/etc/init.d/`. Rootless `user:` units run as a system service via `command_user`.
### nginx
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-nginx
# Generated by unitdore — do not edit manually
description="Unitdore: nginx (podman)"
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/usr/bin/podman"
command_args="start -a nginx"
depend() {
need net
}
stop_pre() {
/usr/bin/podman stop nginx || true
}
```
### redis
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-redis
# Generated by unitdore — do not edit manually
description="Unitdore: redis (docker)"
supervisor=supervise-daemon
respawn_delay=5
respawn_max=3
command="/usr/bin/docker"
command_args="start -a redis"
depend() {
need net
need docker
}
stop_pre() {
/usr/bin/docker stop redis || true
}
```
### myapp
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-myapp
# Generated by unitdore — do not edit manually
description="Unitdore: myapp (podman)"
export HOME=/home/alice
export XDG_RUNTIME_DIR=/run/user/1000
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/usr/bin/podman"
command_args="start -a myapp"
command_user="alice"
depend() {
need net
}
start_pre() {
checkpath -d -m 0700 -o alice /run/user/1000
}
stop_pre() {
su -m -s /bin/sh -c '/usr/bin/podman stop myapp' alice || true
}
```
### backup
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-backup
# Generated by unitdore — do not edit manually
description="Unitdore: backup (sh script)"
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/bin/sh"
command_args="/opt/scripts/backup.sh"
directory="/opt/scripts"
depend() {
need net
}
stop_pre() {
/bin/sh /opt/scripts/stop.sh || true
}
```
### agent
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-agent
# Generated by unitdore — do not edit manually
description="Unitdore: agent (executable)"
start() {
ebegin "Starting $RC_SVCNAME"
/opt/agent/bin/agent --config /etc/agent.toml
eend $?
}
depend() {
need net
}
```
### shop
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-shop
# Generated by unitdore — do not edit manually
description="Unitdore: shop (podman pod)"
start() {
ebegin "Starting $RC_SVCNAME"
/usr/bin/podman pod start pod_shop
eend $?
}
stop() {
ebegin "Stopping $RC_SVCNAME"
/usr/bin/podman pod stop pod_shop
eend $?
}
depend() {
need net
}
```
---
## runit
Root only. Each unit is `/etc/sv/unitdore-<name>/{run,finish}` symlinked into the service dir. Install adds a `down` file (no autostart until started/enabled). Both files are shown per unit.
### nginx
```sh
# ===== /etc/sv/unitdore-nginx/run =====
#!/bin/sh
# unitdore-nginx — generated by unitdore, do not edit manually
exec 2>&1
exec /usr/bin/podman start -a nginx
# ===== /etc/sv/unitdore-nginx/finish =====
#!/bin/sh
# unitdore-nginx — generated by unitdore, do not edit manually
/usr/bin/podman stop nginx || true
exec sv down .
```
### redis
```sh
# ===== /etc/sv/unitdore-redis/run =====
#!/bin/sh
# unitdore-redis — generated by unitdore, do not edit manually
exec 2>&1
exec /usr/bin/docker start -a redis
# ===== /etc/sv/unitdore-redis/finish =====
#!/bin/sh
# unitdore-redis — generated by unitdore, do not edit manually
/usr/bin/docker stop redis || true
[ "$1" = 0 ] && exec sv down .
sleep 5
```
### myapp
```sh
# ===== /etc/sv/unitdore-myapp/run =====
#!/bin/sh
# unitdore-myapp — generated by unitdore, do not edit manually
exec 2>&1
[ -d /run/user/1000 ] || install -d -m 0700 -o alice /run/user/1000
export HOME=/home/alice
export XDG_RUNTIME_DIR=/run/user/1000
exec chpst -u alice /usr/bin/podman start -a myapp
# ===== /etc/sv/unitdore-myapp/finish =====
#!/bin/sh
# unitdore-myapp — generated by unitdore, do not edit manually
chpst -u alice /usr/bin/podman stop myapp || true
exec sv down .
```
### backup
```sh
# ===== /etc/sv/unitdore-backup/run =====
#!/bin/sh
# unitdore-backup — generated by unitdore, do not edit manually
exec 2>&1
cd /opt/scripts || exit 1
exec /bin/sh /opt/scripts/backup.sh
# ===== /etc/sv/unitdore-backup/finish =====
#!/bin/sh
# unitdore-backup — generated by unitdore, do not edit manually
cd /opt/scripts 2>/dev/null
/bin/sh /opt/scripts/stop.sh || true
exec sv down .
```
### agent
```sh
# ===== /etc/sv/unitdore-agent/run =====
#!/bin/sh
# unitdore-agent — generated by unitdore, do not edit manually
exec 2>&1
/opt/agent/bin/agent --config /etc/agent.toml || exit 1
exec tail -f /dev/null
# ===== /etc/sv/unitdore-agent/finish =====
#!/bin/sh
# unitdore-agent — generated by unitdore, do not edit manually
exec sv down .
```
### shop
```sh
# ===== /etc/sv/unitdore-shop/run =====
#!/bin/sh
# unitdore-shop — generated by unitdore, do not edit manually
exec 2>&1
/usr/bin/podman pod start pod_shop || exit 1
exec tail -f /dev/null
# ===== /etc/sv/unitdore-shop/finish =====
#!/bin/sh
# unitdore-shop — generated by unitdore, do not edit manually
/usr/bin/podman pod stop pod_shop || true
exec sv down .
```
---
## Quick Reference: Service File Locations
| Scenario | Path |
@@ -339,3 +659,5 @@ ExecStop=-/usr/bin/podman pod stop pod_shop
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
| With both | `/etc/systemd/system/unitdore-prod-<name>-svc.service` |
| OpenRC | `/etc/init.d/unitdore-<name>` |
| runit | `/etc/sv/unitdore-<name>/` (+ symlink in `/var/service` or `/etc/service`) |
+9 -2
View File
@@ -1,6 +1,6 @@
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
.SH NAME
unitdore \- manage container units via systemd
unitdore \- manage container units via systemd, OpenRC or runit
.SH SYNOPSIS
.B unitdore
[\fB\-\-config\fR \fIpath\fR]
@@ -106,6 +106,13 @@ units:
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
pod_\fR\fIproject\fR.
.SH INIT SYSTEMS
The \fBinit:\fR config key selects \fBsystemd\fR, \fBopenrc\fR or \fBrunit\fR.
Empty means autodetect, falling back to systemd. OpenRC and runit are root only;
rootless units run as system services as that user. runit service links go in
\fBservice_dir:\fR (default \fI/var/service\fR or \fI/etc/service\fR).
OpenRC scripts are written to \fI/etc/init.d/unitdore\-*\fR and runit services to
\fI/etc/sv/unitdore\-*\fR.
.SH GENERATED SERVICE FILES
System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR.
@@ -156,7 +163,7 @@ Generated system unit files.
.I ~/.config/systemd/user/unitdore-*.service
Generated rootless user unit files.
.SH REQUIREMENTS
systemd; Podman and/or Docker (only installed runtimes are used);
systemd, OpenRC or runit; Podman and/or Docker (only installed runtimes are used);
podman\-compose for compose\-mode stacks.
User units need linger to start at boot: \fBloginctl enable\-linger\fR \fIuser\fR.
Root is required for system units; the relevant user for rootless units.
+77
View File
@@ -0,0 +1,77 @@
// Package initsys selects the init system backend that installs and controls
// unitdore services.
package initsys
import (
"fmt"
"os"
"path/filepath"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/openrc"
"github.com/warkanum/unitdore/runit"
"github.com/warkanum/unitdore/systemd"
)
// Backend names.
const (
Systemd = "systemd"
OpenRC = "openrc"
Runit = "runit"
)
// Init is implemented by every init system backend. u.User selects the scope;
// backends that cannot do per-user managers run user units as system services.
type Init interface {
Name() string
ServiceName(u config.Unit, prefix, suffix string) string
UnitPath(u config.Unit, prefix, suffix string) (string, error)
Generate(u config.Unit, prefix, suffix string) (string, error)
Install(u config.Unit, prefix, suffix string) error
Uninstall(u config.Unit, prefix, suffix string) error
Enable(u config.Unit, prefix, suffix string) error
Disable(u config.Unit, prefix, suffix string) error
Start(u config.Unit, prefix, suffix string) error
Stop(u config.Unit, prefix, suffix string) error
// Status returns active | inactive | failed | activating | deactivating | unknown.
Status(u config.Unit, prefix, suffix string) string
IsInstalled(u config.Unit, prefix, suffix string) bool
// SupportsLinger reports whether user units need systemd-style linger.
SupportsLinger() bool
}
// New returns the backend named in cfg.Init, or the detected one.
func New(cfg *config.Config) (Init, error) {
name := cfg.Init
if name == "" {
name = Detect("/")
}
switch name {
case Systemd:
return systemd.Backend{}, nil
case OpenRC:
return openrc.Backend{}, nil
case Runit:
return runit.Backend{ServiceDir: cfg.ServiceDir}, nil
default:
return nil, fmt.Errorf("unknown init %q (want systemd, openrc or runit)", name)
}
}
// Detect guesses the running init system below root. Falls back to systemd.
func Detect(root string) string {
has := func(p string) bool {
_, err := os.Stat(filepath.Join(root, p))
return err == nil
}
switch {
case has("run/systemd/system"):
return Systemd
case has("run/openrc"), has("sbin/openrc-run"):
return OpenRC
case has("run/runit"), has("etc/runit"):
return Runit
default:
return Systemd
}
}
+52
View File
@@ -0,0 +1,52 @@
package initsys
import (
"os"
"path/filepath"
"testing"
"github.com/warkanum/unitdore/config"
)
func TestDetect(t *testing.T) {
tests := []struct {
name string
paths []string
want string
}{
{"systemd", []string{"run/systemd/system"}, Systemd},
{"openrc", []string{"run/openrc"}, OpenRC},
{"openrc binary", []string{"sbin/openrc-run"}, OpenRC},
{"runit", []string{"etc/runit"}, Runit},
{"systemd wins", []string{"run/systemd/system", "etc/runit"}, Systemd},
{"fallback", nil, Systemd},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
for _, p := range tt.paths {
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, p), nil, 0644); err != nil {
t.Fatal(err)
}
}
if got := Detect(root); got != tt.want {
t.Errorf("Detect() = %q, want %q", got, tt.want)
}
})
}
}
func TestNew(t *testing.T) {
for _, name := range []string{Systemd, OpenRC, Runit} {
ini, err := New(&config.Config{Init: name})
if err != nil || ini.Name() != name {
t.Errorf("New(%q) = %v, %v", name, ini, err)
}
}
if _, err := New(&config.Config{Init: "sysv"}); err == nil {
t.Error("New() should reject an unknown init")
}
}
+279
View File
@@ -0,0 +1,279 @@
// Package openrc generates and manages OpenRC init scripts.
//
// Services run at system level (root only). Units with a user run as that
// user through command_user, with HOME and XDG_RUNTIME_DIR set.
package openrc
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
const runlevel = "default"
// Overridable for tests.
var (
initDir = "/etc/init.d"
// runCmd runs a command with stdio passed through.
runCmd = func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// outCmd runs a command and returns its combined output.
outCmd = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
)
// Backend is the OpenRC implementation of the init interface.
type Backend struct{}
func (Backend) Name() string { return "openrc" }
// SupportsLinger is false: OpenRC services need no linger.
func (Backend) SupportsLinger() bool { return false }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return service.BaseName(u, prefix, suffix)
}
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(initDir, b.ServiceName(u, prefix, suffix)), nil
}
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
path, _ := b.UnitPath(u, prefix, suffix)
_, err := os.Stat(path)
return err == nil
}
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
content, err := b.Generate(u, prefix, suffix)
if err != nil {
return err
}
path, _ := b.UnitPath(u, prefix, suffix)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating init dir: %w", err)
}
if err := os.WriteFile(path, []byte(content), 0755); err != nil {
return fmt.Errorf("writing init script: %w", err)
}
return nil
}
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
path, _ := b.UnitPath(u, prefix, suffix)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing init script: %w", err)
}
return nil
}
// Enable adds the service to the default runlevel and starts it.
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
if err := runCmd("rc-update", "add", name, runlevel); err != nil {
return err
}
return runCmd("rc-service", name, "start")
}
// Disable stops the service and removes it from the default runlevel.
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
if err := runCmd("rc-service", name, "stop"); err != nil {
return err
}
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
return nil
}
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "start")
}
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "stop")
}
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
// rc-service status exits non-zero for stopped services but still prints
out, _ := outCmd("rc-service", b.ServiceName(u, prefix, suffix), "status")
return parseStatus(string(out))
}
// parseStatus maps `rc-service <name> status` output to a systemd-like state.
func parseStatus(out string) string {
out = strings.ToLower(out)
switch {
case strings.Contains(out, "crashed"):
return "failed"
case strings.Contains(out, "starting"):
return "activating"
case strings.Contains(out, "stopping"):
return "deactivating"
case strings.Contains(out, "started"):
return "active"
case strings.Contains(out, "stopped"):
return "inactive"
default:
return "unknown"
}
}
// Generate produces the init script for a unit.
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
if err := service.Validate(u); err != nil {
return "", err
}
user, err := service.LookupUser(u.User)
if err != nil {
return "", err
}
spec := service.Resolve(u)
var w strings.Builder
w.WriteString("#!/sbin/openrc-run\n")
w.WriteString("# " + filepath.Join(initDir, b.ServiceName(u, prefix, suffix)) + "\n")
w.WriteString("# Generated by unitdore — do not edit manually\n\n")
fmt.Fprintf(&w, "description=%s\n", dq(fmt.Sprintf("Unitdore: %s (%s)", u.Name, spec.Label)))
if user != nil {
fmt.Fprintf(&w, "export HOME=%s\n", service.ShellQuote(user.Home))
fmt.Fprintf(&w, "export XDG_RUNTIME_DIR=%s\n", service.ShellQuote(user.RuntimeDir))
}
start, err := service.ShellCommand(spec.Start)
if err != nil {
return "", fmt.Errorf("unit %s: %w", u.Name, err)
}
stop := ""
if spec.Stop != "" {
if stop, err = service.ShellCommand(spec.Stop); err != nil {
return "", fmt.Errorf("unit %s: %w", u.Name, err)
}
}
if spec.Type == service.TypeOneshot {
writeOneshot(&w, spec, user, start, stop)
} else if err := writeSupervised(&w, u, spec, user); err != nil {
return "", err
}
w.WriteString("\ndepend() {\n\tneed net\n")
if u.Runtime == "docker" {
w.WriteString("\tneed docker\n")
}
w.WriteString("}\n")
if spec.Type != service.TypeOneshot {
if user != nil {
fmt.Fprintf(&w, "\nstart_pre() {\n\tcheckpath -d -m 0700 -o %s %s\n}\n",
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir))
}
if stop != "" {
fmt.Fprintf(&w, "\nstop_pre() {\n\t%s || true\n}\n", asUser(user, stop))
}
}
return w.String(), nil
}
// writeSupervised writes the variables for a long-running foreground command.
func writeSupervised(w *strings.Builder, u config.Unit, spec service.Spec, user *service.UserEnv) error {
argv, err := service.SplitCommand(spec.Start)
if err != nil {
return fmt.Errorf("unit %s: %w", u.Name, err)
}
if len(argv) == 0 {
return fmt.Errorf("unit %s: empty command", u.Name)
}
w.WriteString("\n")
if u.Restart {
w.WriteString("supervisor=supervise-daemon\n")
fmt.Fprintf(w, "respawn_delay=%d\n", u.RestartSec)
if u.RestartRetries > 0 {
fmt.Fprintf(w, "respawn_max=%d\n", u.RestartRetries)
}
} else {
w.WriteString("command_background=yes\n")
w.WriteString("pidfile=\"/run/${RC_SVCNAME}.pid\"\n")
}
fmt.Fprintf(w, "command=%s\n", dq(argv[0]))
if len(argv) > 1 {
quoted := make([]string, len(argv)-1)
for i, a := range argv[1:] {
quoted[i] = service.ShellQuote(a)
}
fmt.Fprintf(w, "command_args=%s\n", dq(strings.Join(quoted, " ")))
}
if spec.WorkingDir != "" {
fmt.Fprintf(w, "directory=%s\n", dq(spec.WorkingDir))
}
if user != nil {
fmt.Fprintf(w, "command_user=%s\n", dq(user.Name))
}
return nil
}
// writeOneshot writes start/stop functions; OpenRC tracks the started state.
func writeOneshot(w *strings.Builder, spec service.Spec, user *service.UserEnv, start, stop string) {
w.WriteString("\nstart() {\n\tebegin \"Starting $RC_SVCNAME\"\n")
if spec.WorkingDir != "" {
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
}
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, start))
if stop != "" {
w.WriteString("\nstop() {\n\tebegin \"Stopping $RC_SVCNAME\"\n")
if spec.WorkingDir != "" {
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
}
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, stop))
}
}
// asUser wraps a shell command line so it runs as the unit's user, keeping the
// exported HOME / XDG_RUNTIME_DIR.
func asUser(user *service.UserEnv, cmd string) string {
if user == nil {
return cmd
}
return fmt.Sprintf("su -m -s /bin/sh -c %s %s", service.ShellQuote(cmd), service.ShellQuote(user.Name))
}
// dq double-quotes s for an OpenRC (sh) variable assignment.
func dq(s string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`, "`", "\\`")
return `"` + r.Replace(s) + `"`
}
+221
View File
@@ -0,0 +1,221 @@
package openrc
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// setup points the backend at a temp init dir with recorded commands.
func setup(t *testing.T) *[]string {
t.Helper()
oldDir, oldRun, oldOut, oldRoot := initDir, runCmd, outCmd, service.IsRoot
t.Cleanup(func() { initDir, runCmd, outCmd, service.IsRoot = oldDir, oldRun, oldOut, oldRoot })
initDir = t.TempDir()
service.IsRoot = func() bool { return true }
var calls []string
runCmd = func(name string, args ...string) error {
calls = append(calls, name+" "+strings.Join(args, " "))
return nil
}
return &calls
}
func mustContain(t *testing.T, content string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(content, w) {
t.Errorf("missing %q in:\n%s", w, content)
}
}
}
func TestGenerate_Container(t *testing.T) {
setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman", Enabled: true}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"#!/sbin/openrc-run",
`description="Unitdore: nginx (podman)"`,
"command_background=yes",
`pidfile="/run/${RC_SVCNAME}.pid"`,
`command="/usr/bin/podman"`,
`command_args="start -a nginx"`,
"need net",
"stop_pre() {\n\t/usr/bin/podman stop nginx || true\n}",
)
if strings.Contains(got, "supervise-daemon") || strings.Contains(got, "need docker") {
t.Errorf("unexpected supervisor or docker dep:\n%s", got)
}
}
func TestGenerate_DockerRestart(t *testing.T) {
setup(t)
u := config.Unit{Name: "redis", Runtime: "docker", Restart: true, RestartSec: 5, RestartRetries: 3}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "supervisor=supervise-daemon", "respawn_delay=5", "respawn_max=3", "need docker")
if strings.Contains(got, "command_background") {
t.Errorf("supervised unit must not background:\n%s", got)
}
}
func TestGenerate_ScriptCustomShellAndDir(t *testing.T) {
setup(t)
u := config.Unit{
Name: "job", Runtime: config.RuntimeScript, Shell: "/bin/sh",
Script: "/opt/my jobs/run.sh", WorkingDir: "/opt",
}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"(sh script)",
`command="/bin/sh"`,
`command_args="'/opt/my jobs/run.sh'"`,
`directory="/opt"`,
)
}
func TestGenerate_OneshotStack(t *testing.T) {
setup(t)
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"start() {",
"/usr/bin/podman pod start pod_shop",
"stop() {",
"/usr/bin/podman pod stop pod_shop",
)
if strings.Contains(got, "command=") {
t.Errorf("oneshot must not set command:\n%s", got)
}
}
func TestGenerate_RootlessUser(t *testing.T) {
setup(t)
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"export HOME=/root",
"export XDG_RUNTIME_DIR=/run/user/0",
`command_user="root"`,
"checkpath -d -m 0700 -o root /run/user/0",
"su -m -s /bin/sh -c '/usr/bin/podman stop app' root",
)
}
func TestGenerate_Invalid(t *testing.T) {
setup(t)
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: config.RuntimeExec}, "", ""); err == nil {
t.Error("expected validation error")
}
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: "podman", Command: `/bin/x "open`}, "", ""); err == nil {
t.Error("expected quote error")
}
}
func TestInstallUninstall(t *testing.T) {
calls := setup(t)
b := Backend{}
u := config.Unit{Name: "nginx", Runtime: "podman"}
if b.IsInstalled(u, "", "") {
t.Fatal("should not be installed yet")
}
if err := b.Install(u, "p-", ""); err != nil {
t.Fatal(err)
}
path := filepath.Join(initDir, "unitdore-p-nginx")
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0111 == 0 {
t.Errorf("init script must be executable, mode %v", fi.Mode())
}
if !b.IsInstalled(u, "p-", "") {
t.Error("should be installed")
}
if err := b.Uninstall(u, "p-", ""); err != nil {
t.Fatal(err)
}
if b.IsInstalled(u, "p-", "") {
t.Error("should be uninstalled")
}
if got := strings.Join(*calls, "|"); got != "rc-update del unitdore-p-nginx default" {
t.Errorf("calls = %q", got)
}
}
func TestControlCommands(t *testing.T) {
calls := setup(t)
b := Backend{}
u := config.Unit{Name: "nginx", Runtime: "podman"}
_ = b.Start(u, "", "")
_ = b.Stop(u, "", "")
_ = b.Enable(u, "", "")
_ = b.Disable(u, "", "")
want := []string{
"rc-service unitdore-nginx start",
"rc-service unitdore-nginx stop",
"rc-update add unitdore-nginx default",
"rc-service unitdore-nginx start",
"rc-service unitdore-nginx stop",
"rc-update del unitdore-nginx default",
}
if got := strings.Join(*calls, "|"); got != strings.Join(want, "|") {
t.Errorf("calls =\n%q\nwant\n%q", *calls, want)
}
}
func TestRequiresRoot(t *testing.T) {
setup(t)
service.IsRoot = func() bool { return false }
u := config.Unit{Name: "nginx", Runtime: "podman"}
b := Backend{}
for name, err := range map[string]error{
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
} {
if err == nil {
t.Errorf("%s should require root", name)
}
}
}
func TestParseStatus(t *testing.T) {
tests := map[string]string{
" * status: started": "active",
" * status: stopped": "inactive",
" * status: crashed": "failed",
" * status: starting": "activating",
" * status: stopping": "deactivating",
"": "unknown",
"rc-service: not found": "unknown",
}
for in, want := range tests {
if got := parseStatus(in); got != want {
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
}
}
}
+364
View File
@@ -0,0 +1,364 @@
// Package runit generates and manages runit service directories.
//
// Each unit is a directory /etc/sv/<name> (run, finish) linked into the
// supervised service dir. Services run at system level (root only). Units with
// a user run as that user through chpst, with HOME and XDG_RUNTIME_DIR set.
//
// Install links the service with a `down` file: it is supervised but does not
// start until started or enabled. Enable removes `down`, Disable restores it.
package runit
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// Overridable for tests.
var (
svDir = "/etc/sv"
// serviceDirCandidates are probed in order when no service_dir is set.
serviceDirCandidates = []string{"/var/service", "/etc/service"}
// superviseWait is how long Start waits for runsvdir to pick up a new link.
superviseWait = 10 * time.Second
runCmd = func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
outCmd = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
)
// Backend is the runit implementation of the init interface.
type Backend struct {
ServiceDir string // supervised dir; empty = autodetect
}
func (Backend) Name() string { return "runit" }
// SupportsLinger is false: runit services need no linger.
func (Backend) SupportsLinger() bool { return false }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return service.BaseName(u, prefix, suffix)
}
// UnitPath is the service definition directory under /etc/sv.
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(svDir, b.ServiceName(u, prefix, suffix)), nil
}
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
dir, _ := b.UnitPath(u, prefix, suffix)
_, err := os.Stat(filepath.Join(dir, "run"))
return err == nil
}
// serviceDir returns the supervised dir that gets the symlinks.
func (b Backend) serviceDir() string {
if b.ServiceDir != "" {
return b.ServiceDir
}
for _, d := range serviceDirCandidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return d
}
}
return serviceDirCandidates[0]
}
func (b Backend) link(u config.Unit, prefix, suffix string) string {
return filepath.Join(b.serviceDir(), b.ServiceName(u, prefix, suffix))
}
type file struct {
name string
content string
}
// Generate produces the run and finish scripts, separated by marker lines.
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
files, err := b.files(u, prefix, suffix)
if err != nil {
return "", err
}
dir, _ := b.UnitPath(u, prefix, suffix)
var w strings.Builder
for _, f := range files {
fmt.Fprintf(&w, "# ===== %s =====\n%s\n", filepath.Join(dir, f.name), f.content)
}
return w.String(), nil
}
func (b Backend) files(u config.Unit, prefix, suffix string) ([]file, error) {
if err := service.Validate(u); err != nil {
return nil, err
}
user, err := service.LookupUser(u.User)
if err != nil {
return nil, err
}
spec := service.Resolve(u)
start, err := service.ShellCommand(spec.Start)
if err != nil {
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
}
stop := ""
if spec.Stop != "" {
if stop, err = service.ShellCommand(spec.Stop); err != nil {
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
}
}
header := "#!/bin/sh\n# " + b.ServiceName(u, prefix, suffix) + " — generated by unitdore, do not edit manually\n"
run := header + "exec 2>&1\n" + preamble(user, spec.WorkingDir)
if spec.Type == service.TypeOneshot {
// Run once, then stay up (like RemainAfterExit) until stopped
run += asUser(user, start) + " || exit 1\nexec tail -f /dev/null\n"
} else {
run += "exec " + asUser(user, start) + "\n"
}
files := []file{{"run", run}}
if fin := finishScript(header, u, user, spec.WorkingDir, stop); fin != "" {
files = append(files, file{"finish", fin})
}
return files, nil
}
// preamble prepares the environment: working dir and rootless user runtime.
func preamble(user *service.UserEnv, workDir string) string {
var s string
if workDir != "" {
s += "cd " + service.ShellQuote(workDir) + " || exit 1\n"
}
if user != nil {
s += fmt.Sprintf("[ -d %[1]s ] || install -d -m 0700 -o %[2]s %[1]s\n",
service.ShellQuote(user.RuntimeDir), service.ShellQuote(user.Name))
s += "export HOME=" + service.ShellQuote(user.Home) + "\n"
s += "export XDG_RUNTIME_DIR=" + service.ShellQuote(user.RuntimeDir) + "\n"
}
return s
}
func asUser(user *service.UserEnv, cmd string) string {
if user == nil {
return cmd
}
return "chpst -u " + service.ShellQuote(user.Name) + " " + cmd
}
// finishScript runs after the service exits: best-effort stop command, then
// the restart policy. runit restarts by default, so no-restart stops it.
func finishScript(header string, u config.Unit, user *service.UserEnv, workDir, stop string) string {
var body string
if workDir != "" {
body += "cd " + service.ShellQuote(workDir) + " 2>/dev/null\n"
}
if stop != "" {
body += asUser(user, stop) + " || true\n"
}
switch {
case !u.Restart:
body += "exec sv down .\n"
default:
// Restart=on-failure: a clean exit stays down
body += "[ \"$1\" = 0 ] && exec sv down .\n"
if u.RestartSec > 0 {
body += fmt.Sprintf("sleep %d\n", u.RestartSec)
}
}
return header + body
}
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
files, err := b.files(u, prefix, suffix)
if err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
// A re-install keeps an already enabled service enabled
wasEnabled := isLinked(link, dir) && !exists(filepath.Join(dir, "down"))
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating service dir: %w", err)
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.content), 0755); err != nil {
return fmt.Errorf("writing %s: %w", f.name, err)
}
}
if !hasFile(files, "finish") {
_ = os.Remove(filepath.Join(dir, "finish"))
}
if !wasEnabled {
if err := setDown(dir, true); err != nil {
return err
}
}
return ensureLink(dir, link)
}
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
_ = runCmd("sv", "stop", link) // not running is fine
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing service link: %w", err)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("removing service dir: %w", err)
}
return nil
}
// Enable makes the service start with runsvdir and starts it now.
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
if err := setDown(dir, false); err != nil {
return err
}
if err := ensureLink(dir, link); err != nil {
return err
}
return startLink(link)
}
// Disable stops the service and keeps it from starting with runsvdir.
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
if err := setDown(dir, true); err != nil {
return err
}
_ = runCmd("sv", "stop", b.link(u, prefix, suffix)) // not running is fine
return nil
}
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
if err := ensureLink(dir, link); err != nil {
return err
}
return startLink(link)
}
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
return runCmd("sv", "stop", b.link(u, prefix, suffix))
}
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
out, _ := outCmd("sv", "status", b.link(u, prefix, suffix))
return parseStatus(string(out))
}
// parseStatus maps `sv status` output to a systemd-like state.
func parseStatus(out string) string {
out = strings.TrimSpace(out)
switch {
case strings.HasPrefix(out, "run:"):
return "active"
case strings.HasPrefix(out, "down:"):
return "inactive"
case strings.HasPrefix(out, "finish:"):
return "deactivating"
case strings.Contains(out, "runsv not running"):
return "inactive"
default:
return "unknown"
}
}
// startLink waits until runsvdir supervises the link, then starts it.
func startLink(link string) error {
deadline := time.Now().Add(superviseWait)
for !exists(filepath.Join(link, "supervise", "ok")) {
if time.Now().After(deadline) {
return fmt.Errorf("%s is not supervised yet (is runsvdir running?)", link)
}
time.Sleep(200 * time.Millisecond)
}
return runCmd("sv", "start", link)
}
func ensureLink(dir, link string) error {
if isLinked(link, dir) {
return nil
}
if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil {
return fmt.Errorf("creating service dir: %w", err)
}
if err := os.Symlink(dir, link); err != nil {
return fmt.Errorf("linking service: %w", err)
}
return nil
}
func isLinked(link, dir string) bool {
target, err := os.Readlink(link)
return err == nil && target == dir
}
func setDown(dir string, down bool) error {
path := filepath.Join(dir, "down")
if !down {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing down file: %w", err)
}
return nil
}
if err := os.WriteFile(path, nil, 0644); err != nil {
return fmt.Errorf("writing down file: %w", err)
}
return nil
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func hasFile(files []file, name string) bool {
for _, f := range files {
if f.name == name {
return true
}
}
return false
}
+295
View File
@@ -0,0 +1,295 @@
package runit
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// setup points the backend at temp dirs with recorded commands.
func setup(t *testing.T) (b Backend, calls *[]string) {
t.Helper()
oldSv, oldRun, oldOut, oldRoot, oldWait := svDir, runCmd, outCmd, service.IsRoot, superviseWait
t.Cleanup(func() { svDir, runCmd, outCmd, service.IsRoot, superviseWait = oldSv, oldRun, oldOut, oldRoot, oldWait })
svDir = t.TempDir()
superviseWait = 300 * time.Millisecond
service.IsRoot = func() bool { return true }
var recorded []string
runCmd = func(name string, args ...string) error {
recorded = append(recorded, name+" "+strings.Join(args, " "))
return nil
}
return Backend{ServiceDir: t.TempDir()}, &recorded
}
func mustContain(t *testing.T, content string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(content, w) {
t.Errorf("missing %q in:\n%s", w, content)
}
}
}
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestGenerate_Container(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"===== "+filepath.Join(svDir, "unitdore-nginx", "run")+" =====",
"exec 2>&1",
"exec /usr/bin/podman start -a nginx",
"===== "+filepath.Join(svDir, "unitdore-nginx", "finish")+" =====",
"/usr/bin/podman stop nginx || true",
"exec sv down .",
)
}
func TestGenerate_RestartPolicy(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "app", Runtime: "podman", Restart: true, RestartSec: 7}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, `[ "$1" = 0 ] && exec sv down .`, "sleep 7")
if strings.Contains(got, "\nexec sv down .\n") {
t.Errorf("restarting unit must not always go down:\n%s", got)
}
}
func TestGenerate_ScriptExecAndWorkingDir(t *testing.T) {
b, _ := setup(t)
u := config.Unit{
Name: "agent", Runtime: config.RuntimeExec, Executable: "/opt/agent",
Args: []string{"--config", "/etc/my agent.toml"}, WorkingDir: "/opt",
}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "cd /opt || exit 1", `exec /opt/agent --config '/etc/my agent.toml'`)
}
func TestGenerate_Oneshot(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"/usr/bin/podman pod start pod_shop || exit 1\nexec tail -f /dev/null",
"/usr/bin/podman pod stop pod_shop || true",
)
}
func TestGenerate_RootlessUser(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"[ -d /run/user/0 ] || install -d -m 0700 -o root /run/user/0",
"export HOME=/root",
"export XDG_RUNTIME_DIR=/run/user/0",
"exec chpst -u root /usr/bin/podman start -a app",
"chpst -u root /usr/bin/podman stop app || true",
)
}
func TestGenerate_Invalid(t *testing.T) {
b, _ := setup(t)
if _, err := b.Generate(config.Unit{Name: "x", Runtime: config.RuntimeScript}, "", ""); err == nil {
t.Error("expected validation error")
}
}
func TestInstall_WritesDirLinkAndDown(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
if b.IsInstalled(u, "", "") {
t.Fatal("should not be installed yet")
}
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
for _, name := range []string{"run", "finish"} {
fi, err := os.Stat(filepath.Join(dir, name))
if err != nil || fi.Mode().Perm()&0111 == 0 {
t.Errorf("%s missing or not executable: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
t.Error("fresh install must write a down file")
}
if target, err := os.Readlink(link); err != nil || target != dir {
t.Errorf("link = %q, %v", target, err)
}
if !b.IsInstalled(u, "", "") {
t.Error("should be installed")
}
}
func TestInstall_ReinstallKeepsEnabled(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
if err := b.Enable(u, "", ""); err == nil {
t.Log("enable ok") // may time out waiting for supervise; down file still removed
}
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
t.Fatal("enable must remove the down file")
}
u.Restart = true
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
t.Error("re-install must keep an enabled service enabled")
}
if !strings.Contains(read(t, filepath.Join(dir, "finish")), `"$1" = 0`) {
t.Error("re-install must rewrite scripts")
}
}
func TestDisable_WritesDownAndStops(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Disable(u, "", ""); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
t.Error("disable must write a down file")
}
if got := strings.Join(*calls, "|"); got != "sv stop "+filepath.Join(b.ServiceDir, "unitdore-nginx") {
t.Errorf("calls = %q", got)
}
}
func TestStartStop_UseSv(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
// Not supervised yet: Start must give up with a clear error
if err := b.Start(u, "", ""); err == nil || !strings.Contains(err.Error(), "not supervised") {
t.Errorf("Start() err = %v", err)
}
if err := os.MkdirAll(filepath.Join(link, "supervise"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(link, "supervise", "ok"), nil, 0644); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Start(u, "", ""); err != nil {
t.Fatal(err)
}
if err := b.Stop(u, "", ""); err != nil {
t.Fatal(err)
}
if got := strings.Join(*calls, "|"); got != "sv start "+link+"|sv stop "+link {
t.Errorf("calls = %q", got)
}
}
func TestUninstall_RemovesDirAndLink(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Uninstall(u, "", ""); err != nil {
t.Fatal(err)
}
if b.IsInstalled(u, "", "") {
t.Error("should be uninstalled")
}
if _, err := os.Lstat(filepath.Join(b.ServiceDir, "unitdore-nginx")); !os.IsNotExist(err) {
t.Error("link should be gone")
}
if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "sv stop ") {
t.Errorf("calls = %q", *calls)
}
}
func TestServiceDirAutodetect(t *testing.T) {
old := serviceDirCandidates
t.Cleanup(func() { serviceDirCandidates = old })
existing := t.TempDir()
serviceDirCandidates = []string{filepath.Join(existing, "nope"), existing}
if got := (Backend{}).serviceDir(); got != existing {
t.Errorf("serviceDir() = %q, want %q", got, existing)
}
if got := (Backend{ServiceDir: "/custom"}).serviceDir(); got != "/custom" {
t.Errorf("explicit serviceDir() = %q", got)
}
}
func TestRequiresRoot(t *testing.T) {
b, _ := setup(t)
service.IsRoot = func() bool { return false }
u := config.Unit{Name: "nginx", Runtime: "podman"}
for name, err := range map[string]error{
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
} {
if err == nil {
t.Errorf("%s should require root", name)
}
}
}
func TestParseStatus(t *testing.T) {
tests := map[string]string{
"run: /var/service/x: (pid 123) 5s": "active",
"down: /var/service/x: 3s, normally up": "inactive",
"finish: /var/service/x: (pid 9) 1s": "deactivating",
"fail: /var/service/x: runsv not running": "inactive",
"warning: weird": "unknown",
}
for in, want := range tests {
if got := parseStatus(in); got != want {
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
}
}
}
+294
View File
@@ -0,0 +1,294 @@
// Package service resolves a config.Unit into init-system-neutral run
// instructions (commands, working dir, type) shared by every init backend.
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
// Service types.
const (
TypeSimple = "simple"
TypeOneshot = "oneshot"
)
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
const (
StackModeCompose = "compose"
StackModePod = "pod"
)
// Spec is what an init backend needs to run a unit. Start and Stop are
// command lines in systemd quoting; use SplitCommand to get argv.
type Spec struct {
Start string
Stop string // empty = none
Label string
WorkingDir string
Type string // TypeSimple | TypeOneshot
}
// BaseName returns the init-neutral service name: unitdore-<prefix><name><suffix>.
func BaseName(u config.Unit, prefix, suffix string) string {
return fmt.Sprintf("unitdore-%s%s%s", prefix, u.Name, suffix)
}
// Validate reports unit configs that cannot be turned into a service.
func Validate(u config.Unit) error {
if u.Command != "" {
return nil
}
if u.IsScript() && u.Script == "" {
return fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name)
}
if u.IsExec() && u.Executable == "" {
return fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name)
}
return nil
}
// Resolve builds the run instructions for a unit. Call Validate first.
func Resolve(u config.Unit) Spec {
spec := Spec{Label: u.Runtime, Type: TypeSimple}
switch {
case u.IsScript() && u.Command == "":
spec.Start, spec.Stop = buildScriptCommands(u)
spec.Label = filepath.Base(u.ShellPath()) + " script"
spec.WorkingDir = u.WorkingDir
spec.Type = serviceType(u)
case u.IsExec() && u.Command == "":
spec.Start, spec.Stop = buildExecutableCommands(u)
spec.Label = "executable"
spec.WorkingDir = u.WorkingDir
spec.Type = serviceType(u)
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
spec.Start, spec.Stop = buildComposeCommands(u)
spec.Label = "podman-compose stack"
spec.WorkingDir = u.WorkingDir
case u.IsStack() && u.Command == "":
spec.Start, spec.Stop = buildPodCommands(u)
spec.Label = "podman pod"
spec.Type = TypeOneshot
default:
spec.Start, spec.Stop = buildExecCommands(u)
}
return spec
}
func serviceType(u config.Unit) string {
if u.ServiceType == TypeOneshot {
return TypeOneshot
}
return TypeSimple
}
func buildScriptCommands(u config.Unit) (start, stop string) {
shell := QuoteArg(u.ShellPath())
start = shell + " " + QuoteArg(u.Script)
if u.StopScript != "" {
stop = shell + " " + QuoteArg(u.StopScript)
}
return start, stop
}
func buildExecutableCommands(u config.Unit) (start, stop string) {
parts := []string{QuoteArg(u.Executable)}
for _, a := range u.Args {
parts = append(parts, QuoteArg(a))
}
if u.StopExecutable != "" {
stop = QuoteArg(u.StopExecutable)
}
return strings.Join(parts, " "), stop
}
func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly
if u.Command != "" {
return u.Command, ""
}
switch u.Runtime {
case "podman":
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker":
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
}
return start, stop
}
// StackMode picks how a stack unit is started: compose when the compose binary,
// working dir and compose files all exist; otherwise pod start/stop.
func StackMode(u config.Unit) string {
if ComposeAvailable(u) {
return StackModeCompose
}
return StackModePod
}
// ComposeAvailable reports whether the stack can be driven by podman-compose.
func ComposeAvailable(u config.Unit) bool {
if u.ComposeBin == "" || u.WorkingDir == "" {
return false
}
if !exists(u.ComposeBin) || !exists(u.WorkingDir) {
return false
}
for _, f := range u.ComposeFiles {
if !filepath.IsAbs(f) {
f = filepath.Join(u.WorkingDir, f)
}
if !exists(f) {
return false
}
}
return true
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func buildComposeCommands(u config.Unit) (start, stop string) {
args := []string{QuoteArg(u.ComposeBin), "-p", QuoteArg(u.Project)}
for _, f := range u.ComposeFiles {
args = append(args, "-f", QuoteArg(f))
}
base := strings.Join(args, " ")
return base + " up", base + " stop"
}
func buildPodCommands(u config.Unit) (start, stop string) {
pod := QuoteArg("pod_" + u.Project)
return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod
}
// QuoteArg double-quotes an argument for systemd command lines if it
// contains whitespace or quotes.
func QuoteArg(s string) string {
if !strings.ContainsAny(s, " \t\"'\\") {
return s
}
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return `"` + r.Replace(s) + `"`
}
// SplitCommand splits a systemd-style command line into argv. It understands
// double quotes, single quotes and backslash escapes.
func SplitCommand(s string) ([]string, error) {
var (
args []string
cur strings.Builder
inArg bool
quote rune
escape bool
)
for _, r := range s {
switch {
case escape:
cur.WriteRune(r)
escape = false
case r == '\\' && quote != '\'':
escape = true
inArg = true
case quote != 0:
if r == quote {
quote = 0
} else {
cur.WriteRune(r)
}
case r == '"' || r == '\'':
quote = r
inArg = true
case r == ' ' || r == '\t' || r == '\n':
if inArg {
args = append(args, cur.String())
cur.Reset()
inArg = false
}
default:
cur.WriteRune(r)
inArg = true
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quote in command: %s", s)
}
if escape {
return nil, fmt.Errorf("trailing backslash in command: %s", s)
}
if inArg {
args = append(args, cur.String())
}
return args, nil
}
// ShellQuote quotes s for a POSIX shell, only when needed.
func ShellQuote(s string) string {
if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") == "" {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// ShellCommand converts a systemd-style command line into a POSIX shell line.
func ShellCommand(s string) (string, error) {
argv, err := SplitCommand(s)
if err != nil {
return "", err
}
for i, a := range argv {
argv[i] = ShellQuote(a)
}
return strings.Join(argv, " "), nil
}
// UserEnv holds what a system-level service needs to run a command as a
// rootless user (podman needs HOME and XDG_RUNTIME_DIR).
type UserEnv struct {
Name string
UID string
Home string
RuntimeDir string
}
// LookupUser resolves the account behind a unit's user. Empty user returns nil.
func LookupUser(user string) (*UserEnv, error) {
if user == "" {
return nil, nil
}
uid, err := sysuser.UID(user)
if err != nil {
return nil, err
}
home, err := sysuser.HomeDir(user)
if err != nil {
return nil, err
}
return &UserEnv{Name: user, UID: uid, Home: home, RuntimeDir: "/run/user/" + uid}, nil
}
// IsRoot is sysuser.IsRoot; a variable so tests can override it.
var IsRoot = sysuser.IsRoot
// RequireRoot errors unless running as root. Non-systemd backends write to
// system directories and manage system-level services only.
func RequireRoot(backend string) error {
if !IsRoot() {
return fmt.Errorf("%s backend requires root", backend)
}
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/warkanum/unitdore/config"
)
func TestBuildExecCommands(t *testing.T) {
tests := []struct {
name string
unit config.Unit
wantStart string
wantStop string
}{
{
name: "podman",
unit: config.Unit{Name: "app", Runtime: "podman"},
wantStart: "/usr/bin/podman start -a app",
wantStop: "/usr/bin/podman stop app",
},
{
name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app",
},
{
name: "custom command",
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
wantStart: "/bin/custom",
wantStop: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, stop := buildExecCommands(tt.unit)
if start != tt.wantStart {
t.Errorf("start = %q, want %q", start, tt.wantStart)
}
if stop != tt.wantStop {
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
}
})
}
}
func TestStackMode(t *testing.T) {
u := newComposeStack(t)
if StackMode(u) != StackModeCompose {
t.Errorf("StackMode() = %q, want compose", StackMode(u))
}
missingFile := u
missingFile.ComposeFiles = []string{"gone.yaml"}
if StackMode(missingFile) != StackModePod {
t.Error("missing compose file should fall back to pod mode")
}
missingBin := u
missingBin.ComposeBin = ""
if StackMode(missingBin) != StackModePod {
t.Error("missing compose binary should fall back to pod mode")
}
missingDir := u
missingDir.WorkingDir = "/nonexistent/unitdore-test"
if StackMode(missingDir) != StackModePod {
t.Error("missing working dir should fall back to pod mode")
}
}
func TestBuildComposeCommands_Quoting(t *testing.T) {
u := config.Unit{
Project: "shop",
ComposeBin: "/usr/bin/podman-compose",
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
}
start, stop := buildComposeCommands(u)
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
if start != wantBase+" up" {
t.Errorf("start = %q", start)
}
if stop != wantBase+" stop" {
t.Errorf("stop = %q", stop)
}
}
// newComposeStack creates a stack unit whose compose binary, working dir and
// compose file exist on disk (compose mode).
func newComposeStack(t *testing.T) config.Unit {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "podman-compose")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil {
t.Fatal(err)
}
return config.Unit{
Name: "shop",
Runtime: "podman",
Kind: config.KindStack,
Project: "shop",
WorkingDir: dir,
ComposeFiles: []string{"compose.yaml"},
ComposeBin: bin,
Enabled: true,
}
}
func TestSplitCommand(t *testing.T) {
tests := []struct {
in string
want []string
wantErr bool
}{
{in: "/usr/bin/podman start -a app", want: []string{"/usr/bin/podman", "start", "-a", "app"}},
{in: `/bin/x "my file.txt" 'a b'`, want: []string{"/bin/x", "my file.txt", "a b"}},
{in: `/bin/x "say \"hi\""`, want: []string{"/bin/x", `say "hi"`}},
{in: `/bin/x ""`, want: []string{"/bin/x", ""}},
{in: " ", want: nil},
{in: `/bin/x "open`, wantErr: true},
{in: `/bin/x \`, wantErr: true},
}
for _, tt := range tests {
got, err := SplitCommand(tt.in)
if (err != nil) != tt.wantErr {
t.Errorf("SplitCommand(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
continue
}
if len(got) != len(tt.want) {
t.Errorf("SplitCommand(%q) = %q, want %q", tt.in, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("SplitCommand(%q)[%d] = %q, want %q", tt.in, i, got[i], tt.want[i])
}
}
}
}
func TestQuoteArg_SplitCommand_RoundTrip(t *testing.T) {
for _, arg := range []string{"plain", "with space", `a"b`, `back\slash`, "it's"} {
got, err := SplitCommand("x " + QuoteArg(arg))
if err != nil || len(got) != 2 || got[1] != arg {
t.Errorf("round trip %q = %q, %v", arg, got, err)
}
}
}
func TestShellQuote(t *testing.T) {
tests := map[string]string{
"plain-1.0/x": "plain-1.0/x",
"": "''",
"a b": "'a b'",
"it's": `'it'\''s'`,
"$HOME": "'$HOME'",
}
for in, want := range tests {
if got := ShellQuote(in); got != want {
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
}
}
}
func TestShellCommand(t *testing.T) {
got, err := ShellCommand(`/opt/agent --config "/etc/my agent.toml"`)
if err != nil {
t.Fatal(err)
}
if want := `/opt/agent --config '/etc/my agent.toml'`; got != want {
t.Errorf("ShellCommand() = %q, want %q", got, want)
}
}
func TestValidate(t *testing.T) {
bad := []config.Unit{
{Name: "a", Runtime: config.RuntimeScript},
{Name: "b", Runtime: config.RuntimeExec},
}
for _, u := range bad {
if Validate(u) == nil {
t.Errorf("Validate(%s) should fail", u.Name)
}
}
ok := []config.Unit{
{Name: "a", Runtime: config.RuntimeScript, Command: "/x"},
{Name: "b", Runtime: "podman"},
}
for _, u := range ok {
if err := Validate(u); err != nil {
t.Errorf("Validate(%s) = %v", u.Name, err)
}
}
}
func TestBaseName(t *testing.T) {
if got := BaseName(config.Unit{Name: "x"}, "p-", "-s"); got != "unitdore-p-x-s" {
t.Errorf("BaseName() = %q", got)
}
}
func TestResolve_PodFallbackIsOneshot(t *testing.T) {
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
spec := Resolve(u)
if spec.Type != TypeOneshot || spec.Start != "/usr/bin/podman pod start pod_shop" {
t.Errorf("Resolve() = %+v", spec)
}
}
+37
View File
@@ -0,0 +1,37 @@
package systemd
import "github.com/warkanum/unitdore/config"
// Backend is the systemd implementation of the init interface.
type Backend struct{}
func (Backend) Name() string { return "systemd" }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return ServiceName(u, prefix, suffix)
}
func (Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return UnitPath(u, prefix, suffix)
}
func (Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
return Generate(u, prefix, suffix)
}
func (Backend) Install(u config.Unit, prefix, suffix string) error { return Install(u, prefix, suffix) }
func (Backend) Uninstall(u config.Unit, prefix, suffix string) error {
return Uninstall(u, prefix, suffix)
}
func (Backend) Enable(u config.Unit, prefix, suffix string) error { return Enable(u, prefix, suffix) }
func (Backend) Disable(u config.Unit, prefix, suffix string) error { return Disable(u, prefix, suffix) }
func (Backend) Start(u config.Unit, prefix, suffix string) error { return Start(u, prefix, suffix) }
func (Backend) Stop(u config.Unit, prefix, suffix string) error { return Stop(u, prefix, suffix) }
func (Backend) Status(u config.Unit, prefix, suffix string) string { return Status(u, prefix, suffix) }
func (Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
return IsInstalled(u, prefix, suffix)
}
// SupportsLinger reports that user units need loginctl linger to start at boot.
func (Backend) SupportsLinger() bool { return true }
+10 -145
View File
@@ -2,12 +2,11 @@ package systemd
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
@@ -82,49 +81,25 @@ type templateData struct {
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
func ServiceName(u config.Unit, prefix, suffix string) string {
return fmt.Sprintf("unitdore-%s%s%s.service", prefix, u.Name, suffix)
return service.BaseName(u, prefix, suffix) + ".service"
}
// Generate produces the .service file content for a unit.
func Generate(u config.Unit, prefix, suffix string) (string, error) {
if u.Command == "" {
if u.IsScript() && u.Script == "" {
return "", fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name)
}
if u.IsExec() && u.Executable == "" {
return "", fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name)
}
if err := service.Validate(u); err != nil {
return "", err
}
spec := service.Resolve(u)
data := templateData{
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: spec.Start,
ExecStop: spec.Stop,
RuntimeService: runtimeService(u.Runtime),
Label: u.Runtime,
ServiceType: "simple",
}
switch {
case u.IsScript() && u.Command == "":
data.ExecStart, data.ExecStop = buildScriptCommands(u)
data.Label = filepath.Base(u.ShellPath()) + " script"
data.WorkingDir = u.WorkingDir
data.ServiceType = serviceType(u)
case u.IsExec() && u.Command == "":
data.ExecStart, data.ExecStop = buildExecutableCommands(u)
data.Label = "executable"
data.WorkingDir = u.WorkingDir
data.ServiceType = serviceType(u)
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
data.ExecStart, data.ExecStop = buildComposeCommands(u)
data.Label = "podman-compose stack"
data.WorkingDir = u.WorkingDir
case u.IsStack() && u.Command == "":
data.ExecStart, data.ExecStop = buildPodCommands(u)
data.Label = "podman pod"
data.ServiceType = "oneshot"
default:
data.ExecStart, data.ExecStop = buildExecCommands(u)
Label: spec.Label,
ServiceType: spec.Type,
WorkingDir: spec.WorkingDir,
}
tmplStr := systemUnitTemplate
@@ -152,113 +127,3 @@ func runtimeService(runtime string) string {
}
return ""
}
func serviceType(u config.Unit) string {
if u.ServiceType == "oneshot" {
return "oneshot"
}
return "simple"
}
func buildScriptCommands(u config.Unit) (start, stop string) {
shell := quoteArg(u.ShellPath())
start = shell + " " + quoteArg(u.Script)
if u.StopScript != "" {
stop = shell + " " + quoteArg(u.StopScript)
}
return start, stop
}
func buildExecutableCommands(u config.Unit) (start, stop string) {
parts := []string{quoteArg(u.Executable)}
for _, a := range u.Args {
parts = append(parts, quoteArg(a))
}
if u.StopExecutable != "" {
stop = quoteArg(u.StopExecutable)
}
return strings.Join(parts, " "), stop
}
func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly
if u.Command != "" {
return u.Command, ""
}
switch u.Runtime {
case "podman":
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker":
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
}
return start, stop
}
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
const (
StackModeCompose = "compose"
StackModePod = "pod"
)
// StackMode picks how a stack unit is started: compose when the compose binary,
// working dir and compose files all exist; otherwise pod start/stop.
func StackMode(u config.Unit) string {
if ComposeAvailable(u) {
return StackModeCompose
}
return StackModePod
}
// ComposeAvailable reports whether the stack can be driven by podman-compose.
func ComposeAvailable(u config.Unit) bool {
if u.ComposeBin == "" || u.WorkingDir == "" {
return false
}
if !exists(u.ComposeBin) || !exists(u.WorkingDir) {
return false
}
for _, f := range u.ComposeFiles {
if !filepath.IsAbs(f) {
f = filepath.Join(u.WorkingDir, f)
}
if !exists(f) {
return false
}
}
return true
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func buildComposeCommands(u config.Unit) (start, stop string) {
args := []string{quoteArg(u.ComposeBin), "-p", quoteArg(u.Project)}
for _, f := range u.ComposeFiles {
args = append(args, "-f", quoteArg(f))
}
base := strings.Join(args, " ")
return base + " up", base + " stop"
}
func buildPodCommands(u config.Unit) (start, stop string) {
pod := quoteArg("pod_" + u.Project)
return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod
}
// quoteArg double-quotes an argument for systemd command lines if it
// contains whitespace or quotes.
func quoteArg(s string) string {
if !strings.ContainsAny(s, " \t\"'\\") {
return s
}
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return `"` + r.Replace(s) + `"`
}
-81
View File
@@ -226,46 +226,6 @@ func TestGenerate_WithPrefixSuffix(t *testing.T) {
}
}
func TestBuildExecCommands(t *testing.T) {
tests := []struct {
name string
unit config.Unit
wantStart string
wantStop string
}{
{
name: "podman",
unit: config.Unit{Name: "app", Runtime: "podman"},
wantStart: "/usr/bin/podman start -a app",
wantStop: "/usr/bin/podman stop app",
},
{
name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app",
},
{
name: "custom command",
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
wantStart: "/bin/custom",
wantStop: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, stop := buildExecCommands(tt.unit)
if start != tt.wantStart {
t.Errorf("start = %q, want %q", start, tt.wantStart)
}
if stop != tt.wantStop {
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
}
})
}
}
// newComposeStack creates a stack unit whose compose binary, working dir and
// compose file exist on disk (compose mode).
func newComposeStack(t *testing.T) config.Unit {
@@ -290,31 +250,6 @@ func newComposeStack(t *testing.T) config.Unit {
}
}
func TestStackMode(t *testing.T) {
u := newComposeStack(t)
if StackMode(u) != StackModeCompose {
t.Errorf("StackMode() = %q, want compose", StackMode(u))
}
missingFile := u
missingFile.ComposeFiles = []string{"gone.yaml"}
if StackMode(missingFile) != StackModePod {
t.Error("missing compose file should fall back to pod mode")
}
missingBin := u
missingBin.ComposeBin = ""
if StackMode(missingBin) != StackModePod {
t.Error("missing compose binary should fall back to pod mode")
}
missingDir := u
missingDir.WorkingDir = "/nonexistent/unitdore-test"
if StackMode(missingDir) != StackModePod {
t.Error("missing working dir should fall back to pod mode")
}
}
func TestGenerate_StackCompose(t *testing.T) {
u := newComposeStack(t)
content, err := Generate(u, "", "")
@@ -376,22 +311,6 @@ func TestGenerate_StackCustomCommand(t *testing.T) {
}
}
func TestBuildComposeCommands_Quoting(t *testing.T) {
u := config.Unit{
Project: "shop",
ComposeBin: "/usr/bin/podman-compose",
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
}
start, stop := buildComposeCommands(u)
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
if start != wantBase+" up" {
t.Errorf("start = %q", start)
}
if stop != wantBase+" stop" {
t.Errorf("stop = %q", stop)
}
}
func TestGenerate_Script(t *testing.T) {
u := config.Unit{
Name: "backup",
-25
View File
@@ -2,36 +2,11 @@ package systemd
import (
"sort"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
)
// TestStart_BuildsCorrectExecCommand verifies that the start command builds the right exec string.
func TestStart_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStop_BuildsCorrectExecCommand verifies that the stop command builds the right exec string.
func TestStop_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
View File