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:
+294
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user