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