diff --git a/cmd/active.go b/cmd/active.go new file mode 100644 index 0000000..26f91a9 --- /dev/null +++ b/cmd/active.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "sort" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/systemd" +) + +var activeCmd = &cobra.Command{ + Use: "active", + Short: "Enable and start all installed, enabled units", + Long: `Active runs 'systemctl enable --now' for all enabled units that have +been installed. Units must be installed first via 'unitdore install'.`, + RunE: runActive, +} + +func init() { + rootCmd.AddCommand(activeCmd) +} + +func runActive(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + // Sort by order then name for deterministic startup sequence + units := make([]config.Unit, len(cfg.Units)) + copy(units, cfg.Units) + sort.Slice(units, func(i, j int) bool { + if units[i].Order != units[j].Order { + return units[i].Order < units[j].Order + } + return units[i].Name < units[j].Name + }) + + started := 0 + failed := 0 + + for _, u := range units { + if !u.Enabled { + continue + } + if !systemd.IsInstalled(u) { + fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name) + continue + } + fmt.Printf(" ▶ enabling: %s...\n", systemd.ServiceName(u)) + if err := systemd.Enable(u); err != nil { + fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) + failed++ + } else { + fmt.Printf(" ✓ started: %s\n", u.Name) + started++ + } + } + + fmt.Printf("\nDone. Started: %d Failed: %d\n", started, failed) + return nil +} diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 0000000..a70ae49 --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/systemd" +) + +var dryRun bool + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Generate and install systemd unit files for all enabled units", + Long: `Install generates .service files for all enabled units and writes them +to the appropriate systemd directory. It does not enable or start them. + +Use --dry-run to preview the generated unit files without writing anything.`, + RunE: runInstall, +} + +func init() { + installCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview unit files without writing") + rootCmd.AddCommand(installCmd) +} + +func runInstall(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + installed := 0 + skipped := 0 + removed := 0 + + for _, u := range cfg.Units { + if !u.Enabled { + // Remove unit file if it exists for disabled units + if systemd.IsInstalled(u) { + if dryRun { + fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u)) + } else { + if err := systemd.Uninstall(u); err != nil { + fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) + } else { + fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u)) + removed++ + } + } + } else { + skipped++ + } + continue + } + + if dryRun { + content, err := systemd.Generate(u) + if err != nil { + fmt.Printf(" ✗ %s: %v\n", u.Name, err) + continue + } + path, _ := systemd.UnitPath(u) + fmt.Printf("\n--- %s ---\n%s\n", path, content) + installed++ + continue + } + + if err := systemd.Install(u); err != nil { + fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) + } else { + path, _ := systemd.UnitPath(u) + fmt.Printf(" ✓ installed: %s\n", path) + installed++ + } + } + + if !dryRun { + fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped) + } + return nil +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..dcfd06b --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/systemd" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show status summary of all managed units", + RunE: runStatus, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} + +type statusRow struct { + Name string + Runtime string + User string + Enabled string + Installed string + State string + Reason string +} + +func runStatus(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + if len(cfg.Units) == 0 { + fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.") + return nil + } + + units := make([]config.Unit, len(cfg.Units)) + copy(units, cfg.Units) + sort.Slice(units, func(i, j int) bool { + if units[i].Order != units[j].Order { + return units[i].Order < units[j].Order + } + return units[i].Name < units[j].Name + }) + + rows := make([]statusRow, 0, len(units)) + for _, u := range units { + user := u.User + if user == "" { + user = "root" + } + + enabled := "yes" + if !u.Enabled { + enabled = "no" + } + + installed := "yes" + if !systemd.IsInstalled(u) { + installed = "no" + } + + state := "—" + if systemd.IsInstalled(u) { + state = systemd.Status(u) + } + + rows = append(rows, statusRow{ + Name: u.Name, + Runtime: u.Runtime, + User: user, + Enabled: enabled, + Installed: installed, + State: state, + Reason: u.DisabledReason, + }) + } + + // Calculate column widths + cols := []string{"NAME", "RUNTIME", "USER", "ENABLED", "INSTALLED", "STATE", "REASON"} + widths := []int{4, 7, 4, 7, 9, 5, 6} + for _, r := range rows { + vals := []string{r.Name, r.Runtime, r.User, r.Enabled, r.Installed, r.State, r.Reason} + for i, v := range vals { + if len(v) > widths[i] { + widths[i] = len(v) + } + } + } + + // Print header + fmt.Println() + printRow(cols, widths) + fmt.Println(strings.Repeat("─", totalWidth(widths))) + + // Print rows + for _, r := range rows { + vals := []string{r.Name, r.Runtime, r.User, r.Enabled, r.Installed, r.State, r.Reason} + printRow(vals, widths) + } + fmt.Println() + + return nil +} + +func printRow(vals []string, widths []int) { + for i, v := range vals { + fmt.Printf("%-*s", widths[i]+2, v) + } + fmt.Println() +} + +func totalWidth(widths []int) int { + total := 0 + for _, w := range widths { + total += w + 2 + } + return total +} diff --git a/systemd/generator.go b/systemd/generator.go new file mode 100644 index 0000000..1dec544 --- /dev/null +++ b/systemd/generator.go @@ -0,0 +1,105 @@ +package systemd + +import ( + "fmt" + "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}} ({{.Unit.Runtime}}) +After=network.target + +[Service] +Type=simple +ExecStart={{.ExecStart}} +ExecStop={{.ExecStop}} +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +` + +const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}} +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) +After=default.target + +[Service] +Type=simple +ExecStart={{.ExecStart}} +ExecStop={{.ExecStop}} +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +` + +type templateData struct { + ServiceName string + Unit config.Unit + ExecStart string + ExecStop string +} + +// ServiceName returns the systemd service name for a unit. +func ServiceName(u config.Unit) string { + return fmt.Sprintf("unitdore-%s.service", u.Name) +} + +// Generate produces the .service file content for a unit. +func Generate(u config.Unit) (string, error) { + execStart, execStop := buildExecCommands(u) + + data := templateData{ + ServiceName: ServiceName(u), + Unit: u, + ExecStart: execStart, + ExecStop: execStop, + } + + 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 +} + +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 %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 +} diff --git a/systemd/manager.go b/systemd/manager.go new file mode 100644 index 0000000..8f2e26c --- /dev/null +++ b/systemd/manager.go @@ -0,0 +1,145 @@ +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) (string, error) { + if u.User == "" { + return filepath.Join(systemUnitDir, ServiceName(u)), 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", ServiceName(u)), nil +} + +// Install writes the .service file for a unit and reloads systemd. +func Install(u config.Unit) error { + content, err := Generate(u) + if err != nil { + return err + } + + path, err := UnitPath(u) + 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) error { + path, err := UnitPath(u) + 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) error { + return systemctl(u.User, "enable", "--now", ServiceName(u)) +} + +// Disable disables and stops a unit. +func Disable(u config.Unit) error { + return systemctl(u.User, "disable", "--now", ServiceName(u)) +} + +// Status returns the ActiveState of a unit ("active", "inactive", "failed", "unknown"). +func Status(u config.Unit) string { + args := []string{"show", "-p", "ActiveState", "--value", ServiceName(u)} + 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) bool { + path, err := UnitPath(u) + 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 +}