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.
280 lines
8.3 KiB
Go
280 lines
8.3 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"
|
|
|
|
"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")
|
|
}
|
|
w.WriteString("}\n")
|
|
|
|
if spec.Type != service.TypeOneshot {
|
|
if user != nil {
|
|
fmt.Fprintf(&w, "\nstart_pre() {\n\tcheckpath -d -m 0700 -o %s %s\n}\n",
|
|
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir))
|
|
}
|
|
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) + `"`
|
|
}
|