package systemd import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/warkanum/unitdore/config" ) const systemUnitDir = "/etc/systemd/system" // UnitPath returns the filesystem path where the .service file should be written. func UnitPath(u config.Unit, prefix, suffix string) (string, error) { svcName := ServiceName(u, prefix, suffix) if u.User == "" { return filepath.Join(systemUnitDir, svcName), nil } // Rootless: write to the user's systemd config dir home, err := userHomeDir(u.User) if err != nil { return "", err } return filepath.Join(home, ".config", "systemd", "user", svcName), nil } // Install writes the .service file for a unit and reloads systemd. func Install(u config.Unit, prefix, suffix string) error { content, err := Generate(u, prefix, suffix) if err != nil { return err } path, err := UnitPath(u, prefix, suffix) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("creating unit dir: %w", err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("writing unit file: %w", err) } return daemonReload(u.User) } // Uninstall removes the .service file for a unit and reloads systemd. func Uninstall(u config.Unit, prefix, suffix string) error { path, err := UnitPath(u, prefix, suffix) if err != nil { return err } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing unit file: %w", err) } return daemonReload(u.User) } // Enable enables and starts a unit. func Enable(u config.Unit, prefix, suffix string) error { return systemctl(u.User, "enable", "--now", ServiceName(u, prefix, suffix)) } // Disable disables and stops a unit. func Disable(u config.Unit, prefix, suffix string) error { return systemctl(u.User, "disable", "--now", ServiceName(u, prefix, suffix)) } // Start starts a unit without enabling it. func Start(u config.Unit, prefix, suffix string) error { return systemctl(u.User, "start", ServiceName(u, prefix, suffix)) } // Stop stops a unit without disabling it. func Stop(u config.Unit, prefix, suffix string) error { return systemctl(u.User, "stop", ServiceName(u, prefix, suffix)) } // Status returns the ActiveState of a unit ("active", "inactive", "failed", "unknown"). func Status(u config.Unit, prefix, suffix string) string { args := []string{"show", "-p", "ActiveState", "--value", ServiceName(u, prefix, suffix)} var out []byte var err error if u.User == "" { out, err = exec.Command("systemctl", args...).Output() } else { out, err = runAsUser(u.User, "systemctl", append([]string{"--user"}, args...)...) } if err != nil { return "unknown" } return strings.TrimSpace(string(out)) } // IsInstalled returns true if the unit file exists on disk. func IsInstalled(u config.Unit, prefix, suffix string) bool { path, err := UnitPath(u, prefix, suffix) if err != nil { return false } _, err = os.Stat(path) return err == nil } // --- helpers --- func daemonReload(user string) error { if user == "" { return systemctl("", "daemon-reload") } return systemctl(user, "daemon-reload") } func systemctl(user string, args ...string) error { if user == "" { cmd := exec.Command("systemctl", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } return runAsUserCmd(user, "systemctl", append([]string{"--user"}, args...)...) } func runAsUser(user string, bin string, args ...string) ([]byte, error) { full := append([]string{"-u", user, "--", bin}, args...) return exec.Command("runuser", full...).Output() } func runAsUserCmd(user string, bin string, args ...string) error { full := append([]string{"-u", user, "--", bin}, args...) cmd := exec.Command("runuser", full...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func userHomeDir(user string) (string, error) { out, err := exec.Command("getent", "passwd", user).Output() if err != nil { return "", fmt.Errorf("looking up user %s: %w", user, err) } // passwd format: name:pass:uid:gid:gecos:home:shell parts := strings.Split(strings.TrimSpace(string(out)), ":") if len(parts) < 6 { return "", fmt.Errorf("unexpected passwd entry for %s", user) } return parts[5], nil }