Files
unitdore/openrc/openrc.go
T
warkanum 91811b4fa1 feat: order/delay in service files, enable cmd, --all on unit commands
- install/uninstall/start/stop/enable take <name> or --all; *all kept as shortcuts
- add enable (enable at boot + start); startall no longer enables
- order/delay resolved by config.ResolveOrder and written into systemd,
  OpenRC and runit service files (After=/sleep), so they apply at boot
- install --all stops and disables units before removing their files
- update regenerates all installed, enabled units and removes disabled ones
- edit creates the config dir; docs and man page updated
- tests: fake Init for cmd, ResolveOrder, backend ordering output
2026-10-12 21:31:41 +02:00

301 lines
8.9 KiB
Go

// 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"
"time"
"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")
}
if len(u.After) > 0 {
names := make([]string, len(u.After))
for i, n := range u.After {
names[i] = service.BaseName(config.Unit{Name: n}, prefix, suffix)
}
fmt.Fprintf(&w, "\tafter %s\n", strings.Join(names, " "))
}
w.WriteString("}\n")
var pre []string
if spec.Type != service.TypeOneshot && user != nil {
pre = append(pre, fmt.Sprintf("checkpath -d -m 0700 -o %s %s",
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir)))
}
if secs := delaySeconds(u); secs > 0 {
pre = append(pre, fmt.Sprintf("sleep %d", secs))
}
if len(pre) > 0 {
fmt.Fprintf(&w, "\nstart_pre() {\n\t%s\n}\n", strings.Join(pre, "\n\t"))
}
if spec.Type != service.TypeOneshot {
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) + `"`
}
// delaySeconds rounds the unit's start delay up to whole seconds.
func delaySeconds(u config.Unit) int {
return int((u.StartDelay + time.Second - 1) / time.Second)
}