diff --git a/README.md b/README.md index e315c1c..c93c30d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ oldthing podman hein no no — container **Location:** `/etc/unitdore/units.yaml` ```yaml +prefix: "" # optional: prepended to all service names, e.g. "prod-" +suffix: "" # optional: appended to all service names, e.g. "-svc" + units: - name: nginx runtime: podman # podman | docker @@ -94,7 +97,35 @@ units: enabled: true ``` -## Generated unit file (example) +### Prefix and suffix + +Set `prefix` and/or `suffix` to namespace your service files: + +```yaml +prefix: prod- +suffix: "" +``` + +Generates: `unitdore-prod-nginx.service`, `unitdore-prod-myapp.service`, etc. +Prefix/suffix apply to the **service file name only** — container names are unchanged. + +### Unnamed containers + +If a container has no name, `syncup` uses its short container ID (first 12 chars) as the unit name. +You can rename it later with `unitdore edit`. + +## Generated unit files + +See **[docs/generated-units.md](docs/generated-units.md)** for full examples covering: +- System units (root) +- User units (rootless) +- Docker vs Podman +- Custom commands +- Prefix/suffix naming +- Startup ordering +- Unnamed containers (short ID fallback) + +Quick example: ```ini # /etc/systemd/system/unitdore-nginx.service @@ -115,8 +146,6 @@ RestartSec=5 WantedBy=multi-user.target ``` -For user units (rootless containers), files go to `~/.config/systemd/user/` and target `default.target`. - ## Flags ``` diff --git a/cmd/active.go b/cmd/active.go index 26f91a9..d2946f3 100644 --- a/cmd/active.go +++ b/cmd/active.go @@ -27,6 +27,8 @@ func runActive(cmd *cobra.Command, args []string) error { return err } + prefix, suffix := cfg.Prefix, cfg.Suffix + // Sort by order then name for deterministic startup sequence units := make([]config.Unit, len(cfg.Units)) copy(units, cfg.Units) @@ -44,12 +46,12 @@ func runActive(cmd *cobra.Command, args []string) error { if !u.Enabled { continue } - if !systemd.IsInstalled(u) { + if !systemd.IsInstalled(u, prefix, suffix) { 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(" ▶ enabling: %s...\n", systemd.ServiceName(u, prefix, suffix)) + if err := systemd.Enable(u, prefix, suffix); err != nil { fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) failed++ } else { diff --git a/cmd/install.go b/cmd/install.go index a70ae49..729248e 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -31,6 +31,7 @@ func runInstall(cmd *cobra.Command, args []string) error { return err } + prefix, suffix := cfg.Prefix, cfg.Suffix installed := 0 skipped := 0 removed := 0 @@ -38,14 +39,14 @@ func runInstall(cmd *cobra.Command, args []string) error { for _, u := range cfg.Units { if !u.Enabled { // Remove unit file if it exists for disabled units - if systemd.IsInstalled(u) { + if systemd.IsInstalled(u, prefix, suffix) { if dryRun { - fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u)) + fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix)) } else { - if err := systemd.Uninstall(u); err != nil { + if err := systemd.Uninstall(u, prefix, suffix); err != nil { fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) } else { - fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u)) + fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix)) removed++ } } @@ -56,21 +57,21 @@ func runInstall(cmd *cobra.Command, args []string) error { } if dryRun { - content, err := systemd.Generate(u) + content, err := systemd.Generate(u, prefix, suffix) if err != nil { fmt.Printf(" ✗ %s: %v\n", u.Name, err) continue } - path, _ := systemd.UnitPath(u) + path, _ := systemd.UnitPath(u, prefix, suffix) fmt.Printf("\n--- %s ---\n%s\n", path, content) installed++ continue } - if err := systemd.Install(u); err != nil { + if err := systemd.Install(u, prefix, suffix); err != nil { fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) } else { - path, _ := systemd.UnitPath(u) + path, _ := systemd.UnitPath(u, prefix, suffix) fmt.Printf(" ✓ installed: %s\n", path) installed++ } diff --git a/cmd/status.go b/cmd/status.go index dcfd06b..a11d024 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -36,6 +36,8 @@ func runStatus(cmd *cobra.Command, args []string) error { return err } + prefix, suffix := cfg.Prefix, cfg.Suffix + if len(cfg.Units) == 0 { fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.") return nil @@ -63,13 +65,13 @@ func runStatus(cmd *cobra.Command, args []string) error { } installed := "yes" - if !systemd.IsInstalled(u) { + if !systemd.IsInstalled(u, prefix, suffix) { installed = "no" } state := "—" - if systemd.IsInstalled(u) { - state = systemd.Status(u) + if systemd.IsInstalled(u, prefix, suffix) { + state = systemd.Status(u, prefix, suffix) } rows = append(rows, statusRow{ diff --git a/config/config.go b/config/config.go index fc15f25..6a19d92 100644 --- a/config/config.go +++ b/config/config.go @@ -24,7 +24,9 @@ type Unit struct { // Config is the root config structure. type Config struct { - Units []Unit `yaml:"units"` + Units []Unit `yaml:"units"` + Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-" + Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc" } // Load reads and parses the config file at path. diff --git a/docs/generated-units.md b/docs/generated-units.md new file mode 100644 index 0000000..b6f4408 --- /dev/null +++ b/docs/generated-units.md @@ -0,0 +1,288 @@ +# Generated Systemd Unit Files + +Unitdore generates `.service` files from your `units.yaml` config. +This document shows every combination with annotated examples. + +--- + +## System Unit (root, no user) + +**Config:** +```yaml +units: + - name: nginx + runtime: podman + order: 1 + enabled: true +``` + +**Written to:** `/etc/systemd/system/unitdore-nginx.service` + +```ini +# /etc/systemd/system/unitdore-nginx.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: nginx (podman) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/podman start -a nginx +ExecStop=/usr/bin/podman stop nginx +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**Notes:** +- `After=network.target` ensures the network is up before starting +- `WantedBy=multi-user.target` means it starts in normal boot runlevel +- `Restart=on-failure` restarts the container if it exits unexpectedly + +--- + +## System Unit (Docker) + +**Config:** +```yaml +units: + - name: redis + runtime: docker + order: 1 + enabled: true +``` + +**Written to:** `/etc/systemd/system/unitdore-redis.service` + +```ini +# /etc/systemd/system/unitdore-redis.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: redis (docker) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/docker start redis +ExecStop=/usr/bin/docker stop redis +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**Notes:** +- Docker uses `docker start ` (no `-a` flag needed) + +--- + +## User Unit (rootless container) + +**Config:** +```yaml +units: + - name: myapp + runtime: podman + user: hein + order: 2 + enabled: true +``` + +**Written to:** `/home/hein/.config/systemd/user/unitdore-myapp.service` + +```ini +# ~/.config/systemd/user/unitdore-myapp.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: myapp (podman) +After=default.target + +[Service] +Type=simple +ExecStart=/usr/bin/podman start -a myapp +ExecStop=/usr/bin/podman stop myapp +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +**Notes:** +- User units use `After=default.target` and `WantedBy=default.target` +- The file is written to the user's own systemd directory +- Unitdore calls `runuser -u hein -- systemctl --user` for enable/start/status + +--- + +## Custom Command + +**Config:** +```yaml +units: + - name: myscript + runtime: podman + command: /opt/myapp/start.sh + order: 3 + enabled: true +``` + +**Written to:** `/etc/systemd/system/unitdore-myscript.service` + +```ini +# /etc/systemd/system/unitdore-myscript.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: myscript (podman) +After=network.target + +[Service] +Type=simple +ExecStart=/opt/myapp/start.sh +ExecStop= +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**Notes:** +- When `command` is set, it is used verbatim as `ExecStart` +- `ExecStop` is left empty — systemd will send SIGTERM to the process group + +--- + +## With Prefix and Suffix + +**Config:** +```yaml +prefix: prod- +suffix: -svc + +units: + - name: nginx + runtime: podman + order: 1 + enabled: true + - name: api + runtime: docker + order: 2 + enabled: true +``` + +**Written to:** +- `/etc/systemd/system/unitdore-prod-nginx-svc.service` +- `/etc/systemd/system/unitdore-prod-api-svc.service` + +```ini +# /etc/systemd/system/unitdore-prod-nginx-svc.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: nginx (podman) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/podman start -a nginx +ExecStop=/usr/bin/podman stop nginx +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**Notes:** +- `prefix` and `suffix` apply to the **service file name only** — the container name stays unchanged +- Useful for distinguishing environments (`prod-`, `staging-`) or roles (`-web`, `-worker`) + +--- + +## Container Named by Short ID + +If a container has no name (e.g. started with `docker run` without `--name`), unitdore uses its short container ID (first 12 characters) as the unit name. + +**Example:** Container ID `a3f2c1b9e4d7` with no name → + +**Config entry added by `syncup`:** +```yaml +units: + - name: a3f2c1b9e4d7 + runtime: docker + order: 1 + enabled: true +``` + +**Written to:** `/etc/systemd/system/unitdore-a3f2c1b9e4d7.service` + +**Tip:** After `syncup`, use `unitdore edit` to give it a proper name before running `install`. + +--- + +## Startup Ordering Example + +**Config:** +```yaml +units: + - name: postgres + runtime: podman + order: 1 + enabled: true + + - name: redis + runtime: podman + order: 1 # same order = starts in parallel + enabled: true + + - name: api + runtime: podman + order: 2 # waits until order 1 group is started + enabled: true + + - name: nginx + runtime: podman + order: 3 + enabled: true +``` + +`unitdore active` starts units in ascending `order` value, lowest first. +Units with the **same order** are started together (parallel). + +--- + +## Disabled Unit + +**Config:** +```yaml +units: + - name: oldthing + runtime: podman + order: 1 + enabled: false + disabled_reason: container not found +``` + +- `unitdore install` will **remove** the `.service` file if it exists +- `unitdore active` will **skip** disabled units +- `unitdore status` shows the reason in the REASON column + +--- + +## Quick Reference: Service File Locations + +| Scenario | Path | +|---|---| +| System unit (root) | `/etc/systemd/system/unitdore-.service` | +| User unit | `/home//.config/systemd/user/unitdore-.service` | +| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-.service` | +| With suffix `-svc` | `/etc/systemd/system/unitdore--svc.service` | +| With both | `/etc/systemd/system/unitdore-prod--svc.service` | diff --git a/runtime/docker.go b/runtime/docker.go index 42501ce..a29fbe7 100644 --- a/runtime/docker.go +++ b/runtime/docker.go @@ -12,6 +12,7 @@ type Docker struct{} func (d *Docker) Name() string { return "docker" } type dockerContainer struct { + ID string `json:"ID"` Names string `json:"Names"` Image string `json:"Image"` Command string `json:"Command"` @@ -41,8 +42,15 @@ func (d *Docker) ListRunning() ([]Container, error) { continue } name := strings.TrimPrefix(c.Names, "/") + // Fall back to short container ID if no name assigned if name == "" { - continue + if len(c.ID) >= 12 { + name = c.ID[:12] + } else if c.ID != "" { + name = c.ID + } else { + continue + } } containers = append(containers, Container{ Name: name, diff --git a/runtime/podman.go b/runtime/podman.go index 723491d..d49c950 100644 --- a/runtime/podman.go +++ b/runtime/podman.go @@ -12,6 +12,7 @@ type Podman struct{} func (p *Podman) Name() string { return "podman" } type podmanContainer struct { + ID string `json:"Id"` Names []string `json:"Names"` Image string `json:"Image"` Command string `json:"Command"` @@ -41,8 +42,15 @@ func (p *Podman) ListRunning() ([]Container, error) { if len(c.Names) > 0 { name = c.Names[0] } + // Fall back to short container ID if no name assigned if name == "" { - continue + if len(c.ID) >= 12 { + name = c.ID[:12] + } else if c.ID != "" { + name = c.ID + } else { + continue + } } containers = append(containers, Container{ Name: name, diff --git a/systemd/generator.go b/systemd/generator.go index 1dec544..10214af 100644 --- a/systemd/generator.go +++ b/systemd/generator.go @@ -51,17 +51,17 @@ type templateData struct { 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) +// 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) (string, error) { +func Generate(u config.Unit, prefix, suffix string) (string, error) { execStart, execStop := buildExecCommands(u) data := templateData{ - ServiceName: ServiceName(u), + ServiceName: ServiceName(u, prefix, suffix), Unit: u, ExecStart: execStart, ExecStop: execStop, diff --git a/systemd/generator_test.go b/systemd/generator_test.go index e5a8e06..05d1ec4 100644 --- a/systemd/generator_test.go +++ b/systemd/generator_test.go @@ -9,10 +9,21 @@ import ( func TestServiceName(t *testing.T) { u := config.Unit{Name: "nginx"} - got := ServiceName(u) - want := "unitdore-nginx.service" - if got != want { - t.Errorf("ServiceName() = %q, want %q", got, want) + + tests := []struct { + prefix, suffix, want string + }{ + {"", "", "unitdore-nginx.service"}, + {"prod-", "", "unitdore-prod-nginx.service"}, + {"", "-web", "unitdore-nginx-web.service"}, + {"prod-", "-web", "unitdore-prod-nginx-web.service"}, + } + + for _, tt := range tests { + got := ServiceName(u, tt.prefix, tt.suffix) + if got != tt.want { + t.Errorf("ServiceName(%q, %q) = %q, want %q", tt.prefix, tt.suffix, got, tt.want) + } } } @@ -24,7 +35,7 @@ func TestGenerate_SystemUnit(t *testing.T) { Enabled: true, } - content, err := Generate(u) + content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } @@ -59,7 +70,7 @@ func TestGenerate_UserUnit(t *testing.T) { Enabled: true, } - content, err := Generate(u) + content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } @@ -77,7 +88,6 @@ func TestGenerate_UserUnit(t *testing.T) { } } - // System unit markers must NOT appear if strings.Contains(content, "WantedBy=multi-user.target") { t.Error("Generate() user unit should not contain multi-user.target") } @@ -91,7 +101,7 @@ func TestGenerate_CustomCommand(t *testing.T) { Enabled: true, } - content, err := Generate(u) + content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } @@ -108,7 +118,7 @@ func TestGenerate_DockerRuntime(t *testing.T) { Enabled: true, } - content, err := Generate(u) + content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } @@ -128,7 +138,7 @@ func TestGenerate_UnknownRuntime(t *testing.T) { Enabled: true, } - content, err := Generate(u) + content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() should not error on unknown runtime: %v", err) } @@ -138,6 +148,23 @@ func TestGenerate_UnknownRuntime(t *testing.T) { } } +func TestGenerate_WithPrefixSuffix(t *testing.T) { + u := config.Unit{ + Name: "nginx", + Runtime: "podman", + Enabled: true, + } + + content, err := Generate(u, "prod-", "-web") + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + if !strings.Contains(content, "unitdore-prod-nginx-web.service") { + t.Errorf("Generate() prefix+suffix not reflected in service name:\n%s", content) + } +} + func TestBuildExecCommands(t *testing.T) { tests := []struct { name string diff --git a/systemd/manager.go b/systemd/manager.go index 8f2e26c..4dccdfb 100644 --- a/systemd/manager.go +++ b/systemd/manager.go @@ -13,26 +13,27 @@ import ( const systemUnitDir = "/etc/systemd/system" // UnitPath returns the filesystem path where the .service file should be written. -func UnitPath(u config.Unit) (string, error) { +func UnitPath(u config.Unit, prefix, suffix string) (string, error) { + svcName := ServiceName(u, prefix, suffix) if u.User == "" { - return filepath.Join(systemUnitDir, ServiceName(u)), nil + 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", ServiceName(u)), nil + 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) error { - content, err := Generate(u) +func Install(u config.Unit, prefix, suffix string) error { + content, err := Generate(u, prefix, suffix) if err != nil { return err } - path, err := UnitPath(u) + path, err := UnitPath(u, prefix, suffix) if err != nil { return err } @@ -49,8 +50,8 @@ func Install(u config.Unit) error { } // Uninstall removes the .service file for a unit and reloads systemd. -func Uninstall(u config.Unit) error { - path, err := UnitPath(u) +func Uninstall(u config.Unit, prefix, suffix string) error { + path, err := UnitPath(u, prefix, suffix) if err != nil { return err } @@ -63,18 +64,18 @@ func Uninstall(u config.Unit) error { } // Enable enables and starts a unit. -func Enable(u config.Unit) error { - return systemctl(u.User, "enable", "--now", ServiceName(u)) +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) error { - return systemctl(u.User, "disable", "--now", ServiceName(u)) +func Disable(u config.Unit, prefix, suffix string) error { + return systemctl(u.User, "disable", "--now", ServiceName(u, prefix, suffix)) } // 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)} +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 @@ -90,8 +91,8 @@ func Status(u config.Unit) string { } // IsInstalled returns true if the unit file exists on disk. -func IsInstalled(u config.Unit) bool { - path, err := UnitPath(u) +func IsInstalled(u config.Unit, prefix, suffix string) bool { + path, err := UnitPath(u, prefix, suffix) if err != nil { return false }