// Package runit generates and manages runit service directories. // // Each unit is a directory /etc/sv/ (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" + b.waitFor(u, prefix, suffix) + 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 } // predecessorWait is how long run waits for each unit of the previous group. const predecessorWait = 30 // waitFor holds the service until the previous order group is up, then sleeps // the unit's delay. `sv check` never starts a service, and returns at once for // one that is down or not installed. func (b Backend) waitFor(u config.Unit, prefix, suffix string) string { var s string for _, n := range u.After { link := b.link(config.Unit{Name: n}, prefix, suffix) s += fmt.Sprintf("sv -w %d check %s >/dev/null 2>&1 || true\n", predecessorWait, service.ShellQuote(link)) } if secs := int((u.StartDelay + time.Second - 1) / time.Second); secs > 0 { s += fmt.Sprintf("sleep %d\n", secs) } return s } // 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 }