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