package systemd import ( "fmt" "os" "path/filepath" "strings" "text/template" "github.com/warkanum/unitdore/config" ) const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}} # Generated by unitdore — do not edit manually [Unit] Description=Unitdore: {{.Unit.Name}} ({{.Label}}) After=network.target{{with .RuntimeService}} {{.}} Requires={{.}}{{end}} [Service] Type={{.ServiceType}} {{- if eq .ServiceType "oneshot"}} RemainAfterExit=yes {{- end}} {{- with .WorkingDir}} WorkingDirectory={{.}} {{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} Restart=on-failure RestartSec={{.Unit.RestartSec}} {{- if gt .Unit.RestartRetries 0}} StartLimitBurst={{.Unit.RestartRetries}} {{- end}} {{- end}} [Install] WantedBy=multi-user.target ` const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}} # Generated by unitdore — do not edit manually [Unit] Description=Unitdore: {{.Unit.Name}} ({{.Label}}) After=default.target [Service] Type={{.ServiceType}} {{- if eq .ServiceType "oneshot"}} RemainAfterExit=yes {{- end}} {{- with .WorkingDir}} WorkingDirectory={{.}} {{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} Restart=on-failure RestartSec={{.Unit.RestartSec}} {{- if gt .Unit.RestartRetries 0}} StartLimitBurst={{.Unit.RestartRetries}} {{- end}} {{- end}} [Install] WantedBy=default.target ` type templateData struct { ServiceName string Unit config.Unit ExecStart string ExecStop string RuntimeService string Label string // shown in Description ServiceType string // simple | oneshot WorkingDir string } // ServiceName returns the systemd service name for a unit, with optional prefix/suffix. func ServiceName(u config.Unit, prefix, suffix string) string { return fmt.Sprintf("unitdore-%s%s%s.service", prefix, u.Name, suffix) } // Generate produces the .service file content for a unit. func Generate(u config.Unit, prefix, suffix string) (string, error) { data := templateData{ ServiceName: ServiceName(u, prefix, suffix), Unit: u, RuntimeService: runtimeService(u.Runtime), Label: u.Runtime, ServiceType: "simple", } switch { case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose: data.ExecStart, data.ExecStop = buildComposeCommands(u) data.Label = "podman-compose stack" data.WorkingDir = u.WorkingDir case u.IsStack() && u.Command == "": data.ExecStart, data.ExecStop = buildPodCommands(u) data.Label = "podman pod" data.ServiceType = "oneshot" default: data.ExecStart, data.ExecStop = buildExecCommands(u) } tmplStr := systemUnitTemplate if u.User != "" { tmplStr = userUnitTemplate } tmpl, err := template.New("unit").Parse(tmplStr) if err != nil { return "", fmt.Errorf("parsing template: %w", err) } var buf strings.Builder if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("rendering template: %w", err) } return buf.String(), nil } // runtimeService returns the systemd service name that must be running for the // given container runtime, or "" if none is required (e.g. daemonless podman). func runtimeService(runtime string) string { if runtime == "docker" { return "docker.service" } return "" } 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 } // Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback. const ( StackModeCompose = "compose" StackModePod = "pod" ) // 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) + `"` }