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