Files
unitdore/runit/runit.go
T
warkanum df9b226c7e 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.
2026-10-12 20:19:58 +02:00

365 lines
10 KiB
Go

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