// Package service resolves a config.Unit into init-system-neutral run // instructions (commands, working dir, type) shared by every init backend. package service import ( "fmt" "os" "path/filepath" "strings" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/sysuser" ) // Service types. const ( TypeSimple = "simple" TypeOneshot = "oneshot" ) // Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback. const ( StackModeCompose = "compose" StackModePod = "pod" ) // Spec is what an init backend needs to run a unit. Start and Stop are // command lines in systemd quoting; use SplitCommand to get argv. type Spec struct { Start string Stop string // empty = none Label string WorkingDir string Type string // TypeSimple | TypeOneshot } // BaseName returns the init-neutral service name: unitdore-. func BaseName(u config.Unit, prefix, suffix string) string { return fmt.Sprintf("unitdore-%s%s%s", prefix, u.Name, suffix) } // Validate reports unit configs that cannot be turned into a service. func Validate(u config.Unit) error { if u.Command != "" { return nil } if u.IsScript() && u.Script == "" { return fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name) } if u.IsExec() && u.Executable == "" { return fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name) } return nil } // Resolve builds the run instructions for a unit. Call Validate first. func Resolve(u config.Unit) Spec { spec := Spec{Label: u.Runtime, Type: TypeSimple} switch { case u.IsScript() && u.Command == "": spec.Start, spec.Stop = buildScriptCommands(u) spec.Label = filepath.Base(u.ShellPath()) + " script" spec.WorkingDir = u.WorkingDir spec.Type = serviceType(u) case u.IsExec() && u.Command == "": spec.Start, spec.Stop = buildExecutableCommands(u) spec.Label = "executable" spec.WorkingDir = u.WorkingDir spec.Type = serviceType(u) case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose: spec.Start, spec.Stop = buildComposeCommands(u) spec.Label = "podman-compose stack" spec.WorkingDir = u.WorkingDir case u.IsStack() && u.Command == "": spec.Start, spec.Stop = buildPodCommands(u) spec.Label = "podman pod" spec.Type = TypeOneshot default: spec.Start, spec.Stop = buildExecCommands(u) } return spec } func serviceType(u config.Unit) string { if u.ServiceType == TypeOneshot { return TypeOneshot } return TypeSimple } func buildScriptCommands(u config.Unit) (start, stop string) { shell := QuoteArg(u.ShellPath()) start = shell + " " + QuoteArg(u.Script) if u.StopScript != "" { stop = shell + " " + QuoteArg(u.StopScript) } return start, stop } func buildExecutableCommands(u config.Unit) (start, stop string) { parts := []string{QuoteArg(u.Executable)} for _, a := range u.Args { parts = append(parts, QuoteArg(a)) } if u.StopExecutable != "" { stop = QuoteArg(u.StopExecutable) } return strings.Join(parts, " "), stop } func buildExecCommands(u config.Unit) (start, stop string) { // If a custom command is provided, use it directly if u.Command != "" { return u.Command, "" } switch u.Runtime { case "podman": start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name) stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name) case "docker": start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name) stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name) default: start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name) stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name) } return start, stop } // StackMode picks how a stack unit is started: compose when the compose binary, // working dir and compose files all exist; otherwise pod start/stop. func StackMode(u config.Unit) string { if ComposeAvailable(u) { return StackModeCompose } return StackModePod } // ComposeAvailable reports whether the stack can be driven by podman-compose. func ComposeAvailable(u config.Unit) bool { if u.ComposeBin == "" || u.WorkingDir == "" { return false } if !exists(u.ComposeBin) || !exists(u.WorkingDir) { return false } for _, f := range u.ComposeFiles { if !filepath.IsAbs(f) { f = filepath.Join(u.WorkingDir, f) } if !exists(f) { return false } } return true } func exists(path string) bool { _, err := os.Stat(path) return err == nil } func buildComposeCommands(u config.Unit) (start, stop string) { args := []string{QuoteArg(u.ComposeBin), "-p", QuoteArg(u.Project)} for _, f := range u.ComposeFiles { args = append(args, "-f", QuoteArg(f)) } base := strings.Join(args, " ") return base + " up", base + " stop" } func buildPodCommands(u config.Unit) (start, stop string) { pod := QuoteArg("pod_" + u.Project) return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod } // QuoteArg double-quotes an argument for systemd command lines if it // contains whitespace or quotes. func QuoteArg(s string) string { if !strings.ContainsAny(s, " \t\"'\\") { return s } r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) return `"` + r.Replace(s) + `"` } // SplitCommand splits a systemd-style command line into argv. It understands // double quotes, single quotes and backslash escapes. func SplitCommand(s string) ([]string, error) { var ( args []string cur strings.Builder inArg bool quote rune escape bool ) for _, r := range s { switch { case escape: cur.WriteRune(r) escape = false case r == '\\' && quote != '\'': escape = true inArg = true case quote != 0: if r == quote { quote = 0 } else { cur.WriteRune(r) } case r == '"' || r == '\'': quote = r inArg = true case r == ' ' || r == '\t' || r == '\n': if inArg { args = append(args, cur.String()) cur.Reset() inArg = false } default: cur.WriteRune(r) inArg = true } } if quote != 0 { return nil, fmt.Errorf("unterminated quote in command: %s", s) } if escape { return nil, fmt.Errorf("trailing backslash in command: %s", s) } if inArg { args = append(args, cur.String()) } return args, nil } // ShellQuote quotes s for a POSIX shell, only when needed. func ShellQuote(s string) string { if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") == "" { return s } return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // ShellCommand converts a systemd-style command line into a POSIX shell line. func ShellCommand(s string) (string, error) { argv, err := SplitCommand(s) if err != nil { return "", err } for i, a := range argv { argv[i] = ShellQuote(a) } return strings.Join(argv, " "), nil } // UserEnv holds what a system-level service needs to run a command as a // rootless user (podman needs HOME and XDG_RUNTIME_DIR). type UserEnv struct { Name string UID string Home string RuntimeDir string } // LookupUser resolves the account behind a unit's user. Empty user returns nil. func LookupUser(user string) (*UserEnv, error) { if user == "" { return nil, nil } uid, err := sysuser.UID(user) if err != nil { return nil, err } home, err := sysuser.HomeDir(user) if err != nil { return nil, err } return &UserEnv{Name: user, UID: uid, Home: home, RuntimeDir: "/run/user/" + uid}, nil } // IsRoot is sysuser.IsRoot; a variable so tests can override it. var IsRoot = sysuser.IsRoot // RequireRoot errors unless running as root. Non-systemd backends write to // system directories and manage system-level services only. func RequireRoot(backend string) error { if !IsRoot() { return fmt.Errorf("%s backend requires root", backend) } return nil }