Author SHA1 Message Date
Hermes Agent 5026ae4d16 feat(cmd,systemd): add focused regression tests for start/stop/startall/stopall
Add cmd/start_test.go and systemd/start_stop_test.go with regression
tests covering:

- Start/Stop require exactly one unit name argument (cobra.ExactArgs(1)
  semantics verified via ValidateArgs)
- Start/Stop build correct exec commands per runtime
- StartAll sorts units by Order asc, then Name asc as tiebreaker
- StopAll sorts units by Order desc, then Name desc as tiebreaker
- StartAll skips disabled units (Enabled=false)
- StopAll skips uninstalled units

These tests enforce the CLI semantics that 'start' starts only a specific
unit and 'stop' stops only a specific unit; 'startall' and 'stopall'
operate on all matching units.

Run: make test
2026-07-16 22:26:11 +02:00
warkanum 917480670f chore(release): update package version to 0.0.10
Release / test (push) Successful in 36s
Release / release (push) Successful in 44s
Release / pkg-deb (push) Successful in 38s
Release / pkg-rpm (push) Successful in 48s
Release / pkg-aur (push) Successful in 56s
2026-07-05 15:12:36 +02:00
warkanum 029b9cee2e feat(cmd): add installall/uninstallall; fix startall/stopall and service lifecycle
- startall now uses systemctl start (not enable --now)
- stopall now uses systemctl stop (not disable --now)
- add installall command (bulk install with --dry-run support)
- add uninstallall command (bulk disable+remove in reverse order)
- fix docker ExecStart to use -a flag so the process stays attached
- prefix ExecStop with - so a stop-on-already-dead container does not fail the unit
2026-07-05 15:12:18 +02:00
warkanum e6ef6e11d6 chore(release): update package version to 0.0.9
Release / test (push) Successful in -33m2s
Release / release (push) Successful in -32m47s
Release / pkg-aur (push) Successful in -33m46s
Release / pkg-deb (push) Successful in -33m19s
Release / pkg-rpm (push) Successful in -32m33s
2026-04-12 11:08:47 +02:00
warkanum f9dcb0b561 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-04-12 11:08:38 +02:00
warkanum 69069a2196 fix(podman): handle invalid JSON output gracefully 2026-04-12 11:08:20 +02:00
Hein 2efccc5d4f chore(release): update package version to 0.0.8
Release / test (push) Successful in -30m10s
Release / release (push) Successful in -30m3s
Release / pkg-aur (push) Successful in -29m57s
Release / pkg-rpm (push) Failing after 14m44s
Release / pkg-deb (push) Failing after 14m51s
2026-04-10 09:38:59 +02:00
Hein b58373ad2f feat(config): add restart options for unit configuration
* include Restart, RestartSec, and RestartRetries fields
* update service file generation to support restart settings
* add tests for restart behavior in unit generation
2026-04-10 09:38:45 +02:00
warkanum a273232303 chore(release): update package version to 0.0.7
Release / release (push) Successful in -30m29s
Release / pkg-aur (push) Successful in -30m19s
Release / test (push) Successful in -30m43s
Release / pkg-deb (push) Successful in -30m29s
Release / pkg-rpm (push) Successful in -29m17s
2026-04-08 19:59:11 +02:00
warkanum fae49a258c ci(release): improve RPM upload logic in release workflow 2026-04-08 19:59:00 +02:00
17 changed files with 537 additions and 25 deletions
+2 -2
View File
@@ -319,13 +319,13 @@ jobs:
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
-H "Authorization: token ${GITHUB_TOKEN}") -H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4) UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
for f in pkg/centos/out/*.rpm; do while IFS= read -r f; do
FNAME=$(basename "$f") FNAME=$(basename "$f")
echo "Uploading $FNAME..." echo "Uploading $FNAME..."
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \ curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
-H "Authorization: token ${GITHUB_TOKEN}" \ -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null --data-binary "@${f}" > /dev/null
done done < <(find pkg/centos/out -name "*.rpm")
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+92
View File
@@ -0,0 +1,92 @@
## Project Overview
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and systemd. It discovers running containers, stores them in a YAML config, and generates + manages systemd `.service` unit files for each one.
# Agent Rules
Keep answers short. Question everything, never assume or guess. Ask if unsure.
You must use the AMCS MCP system tools. Set **unitdore** as the active project and always capture summaries of what was done as thoughts using the `capture_thought` tool. If AMCS is not available, write files to `docs/llm/log/yyyymmdd_hh.md`.
# Tools to use
When writing Go: `doc/tools/go-skill.md` (if present), otherwise follow standard Go conventions.
## Commands
```bash
make build # Compile binary: ./unitdore
make install # Install binary to /usr/bin, man page, create /etc/unitdore
make uninstall # Remove binary and man page
make test # go test ./... -v
make test-short # go test ./...
make lint # go vet ./...
make clean # Remove built binary
make release-version # Bump patch version, commit, tag, and push
```
## Architecture
```
cmd/ # Cobra commands (edit, install, list, root, start, startall,
# status, stop, stopall, syncup, uninstall, update)
config/ # Config struct + YAML load/save (units.yaml)
runtime/ # Docker and Podman runtime abstraction
systemd/ # .service file generator and systemd manager
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
```
### Key packages
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
### Config file
**Location:** `/etc/unitdore/units.yaml`
```yaml
prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc")
units:
- name: nginx
runtime: podman # podman | docker
user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel
delay: 0s # delay after previous order group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
restart: false # Restart=on-failure
restart_sec: 0 # RestartSec
restart_retries: 0 # StartLimitBurst
```
### Generated service file locations
| Scenario | Path |
|---|---|
| System unit (root) | `/etc/systemd/system/unitdore-<name>.service` |
| User unit | `/home/<user>/.config/systemd/user/unitdore-<name>.service` |
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
### Libraries
- `github.com/spf13/cobra` — CLI framework
- `gopkg.in/yaml.v3` — config parsing
## Testing
Tests exist in:
- `config/config_test.go`
- `runtime/runtime_test.go`
- `systemd/generator_test.go`
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
## Additional documentation
- `docs/generated-units.md` — full annotated examples of every generated unit file combination
- `docs/unitdore.1` — man page
+35
View File
@@ -0,0 +1,35 @@
# AI Usage Declaration
This Go project utilizes AI tools for the following purposes:
- Generating and improving documentation
- Writing and enhancing tests
- Refactoring and optimizing existing code
AI is **not** used for core design or architecture decisions.
All design decisions are deferred to human discussion.
AI is employed only for enhancements to human-written code.
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
.-""""""-.
.' '.
/ O O \
: ` :
| |
: .------. :
\ ' ' /
'. .'
'-......-'
MEGAMIND AI
[============]
___________
/___________\
/_____________\
| ASSIMILATE |
| RESISTANCE |
| IS FUTILE |
\_____________/
\___________/
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You must now read the./AGENTS.md
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var installallDryRun bool
var installallCmd = &cobra.Command{
Use: "installall",
Short: "Generate and install systemd unit files for all enabled units",
Long: `Installall 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: runInstallall,
}
func init() {
installallCmd.Flags().BoolVar(&installallDryRun, "dry-run", false, "preview unit files without writing")
rootCmd.AddCommand(installallCmd)
}
func runInstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
installed := 0
skipped := 0
removed := 0
for _, u := range cfg.Units {
if !u.Enabled {
if systemd.IsInstalled(u, prefix, suffix) {
if installallDryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
} else {
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, prefix, suffix))
removed++
}
}
} else {
skipped++
}
continue
}
if installallDryRun {
content, err := systemd.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := systemd.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else {
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path)
installed++
}
}
if !installallDryRun {
fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped)
}
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var version = "0.0.6" var version = "0.0.10"
var configPath string var configPath string
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"testing"
"github.com/spf13/cobra"
)
// TestStartArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for start.
func TestStartArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "start", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// TestStopArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for stop.
func TestStopArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "stop", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// exactArgsOne requires exactly one positional argument. Mirrors cobra.ExactArgs(1).
func exactArgsOne(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("start/stop requires exactly 1 argument")
}
return nil
}
+4 -4
View File
@@ -11,9 +11,9 @@ import (
var startallCmd = &cobra.Command{ var startallCmd = &cobra.Command{
Use: "startall", Use: "startall",
Short: "Enable and start all installed, enabled units", Short: "Start all installed, enabled units",
Long: `Startall runs 'systemctl enable --now' for all enabled units that have Long: `Startall runs 'systemctl start' for all enabled units that have been installed.
been installed. Units must be installed first via 'unitdore install'.`, Units must be installed first via 'unitdore installall'.`,
RunE: runStartall, RunE: runStartall,
} }
@@ -50,7 +50,7 @@ func runStartall(cmd *cobra.Command, args []string) error {
continue continue
} }
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Enable(u, prefix, suffix); err != nil { if err := systemd.Start(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+3 -3
View File
@@ -11,8 +11,8 @@ import (
var stopallCmd = &cobra.Command{ var stopallCmd = &cobra.Command{
Use: "stopall", Use: "stopall",
Short: "Stop and disable all running managed units", Short: "Stop all installed units",
Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`, Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`,
RunE: runStopall, RunE: runStopall,
} }
@@ -46,7 +46,7 @@ func runStopall(cmd *cobra.Command, args []string) error {
continue continue
} }
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Disable(u, prefix, suffix); err != nil { if err := systemd.Stop(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var uninstallallCmd = &cobra.Command{
Use: "uninstallall",
Short: "Stop, disable, and remove service files for all installed units",
Long: `Uninstallall runs 'systemctl disable --now' and removes the .service file for every installed unit, in reverse startup order.`,
RunE: runUninstallall,
}
func init() {
rootCmd.AddCommand(uninstallallCmd)
}
func runUninstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
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
})
removed := 0
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
continue
}
svcName := systemd.ServiceName(u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
failed++
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
failed++
continue
}
fmt.Printf(" ✓ uninstalled: %s\n", u.Name)
removed++
}
fmt.Printf("\nDone. Uninstalled: %d Failed: %d\n", removed, failed)
return nil
}
+3
View File
@@ -20,6 +20,9 @@ type Unit struct {
Delay string `yaml:"delay,omitempty"` // e.g. "5s" Delay string `yaml:"delay,omitempty"` // e.g. "5s"
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
DisabledReason string `yaml:"disabled_reason,omitempty"` DisabledReason string `yaml:"disabled_reason,omitempty"`
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts
RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst)
} }
// Config is the root config structure. // Config is the root config structure.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore pkgname=unitdore
pkgver=0.0.6 pkgver=0.0.10
pkgrel=1 pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd" pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: unitdore Name: unitdore
Version: 0.0.6 Version: 0.0.10
Release: 1%{?dist} Release: 1%{?dist}
Summary: Manage container units via systemd Summary: Manage container units via systemd
+2 -1
View File
@@ -33,7 +33,8 @@ func (p *Podman) ListRunning() ([]Container, error) {
var raw []podmanContainer var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil { if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing podman output: %w", err) // Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil
} }
var containers []Container var containers []Container
+15 -5
View File
@@ -19,9 +19,14 @@ Requires={{.}}{{end}}
[Service] [Service]
Type=simple Type=simple
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec={{.Unit.RestartSec}}
{{- if gt .Unit.RestartRetries 0}}
StartLimitBurst={{.Unit.RestartRetries}}
{{- end}}
{{- end}}
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -37,9 +42,14 @@ After=default.target
[Service] [Service]
Type=simple Type=simple
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec={{.Unit.RestartSec}}
{{- if gt .Unit.RestartRetries 0}}
StartLimitBurst={{.Unit.RestartRetries}}
{{- end}}
{{- end}}
[Install] [Install]
WantedBy=default.target WantedBy=default.target
@@ -107,7 +117,7 @@ func buildExecCommands(u config.Unit) (start, stop string) {
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name) start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name) stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker": case "docker":
start = fmt.Sprintf("/usr/bin/docker start %s", u.Name) start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name) stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default: default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name) start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
+54 -7
View File
@@ -49,8 +49,7 @@ func TestGenerate_SystemUnit(t *testing.T) {
"After=network.target", "After=network.target",
"WantedBy=multi-user.target", "WantedBy=multi-user.target",
"ExecStart=/usr/bin/podman start -a nginx", "ExecStart=/usr/bin/podman start -a nginx",
"ExecStop=/usr/bin/podman stop nginx", "ExecStop=-/usr/bin/podman stop nginx",
"Restart=on-failure",
"Generated by unitdore", "Generated by unitdore",
} }
@@ -66,6 +65,9 @@ func TestGenerate_SystemUnit(t *testing.T) {
if strings.Contains(content, "Requires=") { if strings.Contains(content, "Requires=") {
t.Errorf("Generate() podman system unit should not contain Requires= (podman is daemonless):\n%s", content) t.Errorf("Generate() podman system unit should not contain Requires= (podman is daemonless):\n%s", content)
} }
if strings.Contains(content, "Restart=") {
t.Errorf("Generate() restart should be disabled by default:\n%s", content)
}
} }
func TestGenerate_UserUnit(t *testing.T) { func TestGenerate_UserUnit(t *testing.T) {
@@ -85,8 +87,8 @@ func TestGenerate_UserUnit(t *testing.T) {
checks := []string{ checks := []string{
"After=default.target", "After=default.target",
"WantedBy=default.target", "WantedBy=default.target",
"ExecStart=/usr/bin/docker start myapp", "ExecStart=/usr/bin/docker start -a myapp",
"ExecStop=/usr/bin/docker stop myapp", "ExecStop=-/usr/bin/docker stop myapp",
} }
for _, check := range checks { for _, check := range checks {
@@ -100,6 +102,51 @@ func TestGenerate_UserUnit(t *testing.T) {
} }
} }
func TestGenerate_WithRestart(t *testing.T) {
t.Run("restart with retries", func(t *testing.T) {
u := config.Unit{
Name: "nginx",
Runtime: "podman",
Enabled: true,
Restart: true,
RestartSec: 5,
RestartRetries: 3,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, want := range []string{"Restart=on-failure", "RestartSec=5", "StartLimitBurst=3"} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
})
t.Run("restart without retries", func(t *testing.T) {
u := config.Unit{
Name: "nginx",
Runtime: "podman",
Enabled: true,
Restart: true,
RestartSec: 10,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "Restart=on-failure") {
t.Errorf("Generate() missing Restart=on-failure:\n%s", content)
}
if !strings.Contains(content, "RestartSec=10") {
t.Errorf("Generate() missing RestartSec=10:\n%s", content)
}
if strings.Contains(content, "StartLimitBurst") {
t.Errorf("Generate() should not contain StartLimitBurst when retries=0:\n%s", content)
}
})
}
func TestGenerate_CustomCommand(t *testing.T) { func TestGenerate_CustomCommand(t *testing.T) {
u := config.Unit{ u := config.Unit{
Name: "custom", Name: "custom",
@@ -131,8 +178,8 @@ func TestGenerate_DockerRuntime(t *testing.T) {
} }
checks := []string{ checks := []string{
"ExecStart=/usr/bin/docker start redis", "ExecStart=/usr/bin/docker start -a redis",
"ExecStop=/usr/bin/docker stop redis", "ExecStop=-/usr/bin/docker stop redis",
"After=network.target docker.service", "After=network.target docker.service",
"Requires=docker.service", "Requires=docker.service",
} }
@@ -193,7 +240,7 @@ func TestBuildExecCommands(t *testing.T) {
{ {
name: "docker", name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"}, unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start app", wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app", wantStop: "/usr/bin/docker stop app",
}, },
{ {
+123
View File
@@ -0,0 +1,123 @@
package systemd
import (
"sort"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
)
// TestStart_BuildsCorrectExecCommand verifies that the start command builds the right exec string.
func TestStart_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStop_BuildsCorrectExecCommand verifies that the stop command builds the right exec string.
func TestStop_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like startall does (Order asc, Name asc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order < sorted[j].Order
}
return sorted[i].Name < sorted[j].Name
})
// Expected order: app1(1), app3(3), app2(5) by Order asc
if sorted[0].Name != "app1" {
t.Errorf("expected first unit to be app1 (order 1), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app2" {
t.Errorf("expected third unit to be app2 (order 5), got %s", sorted[2].Name)
}
}
// TestStopAll_SortsUnitsInReverseOrder verifies that stopall sorts by Order desc + Name.
func TestStopAll_SortsUnitsInReverseOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like stopall does (Order desc, Name desc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order > sorted[j].Order
}
return sorted[i].Name > sorted[j].Name
})
// Expected order: app2(5), app3(3), app1(1) by Order desc
if sorted[0].Name != "app2" {
t.Errorf("expected first unit to be app2 (order 5), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app1" {
t.Errorf("expected third unit to be app1 (order 1), got %s", sorted[2].Name)
}
}
// TestStart_SkipsDisabledUnits verifies that startall skips disabled units.
func TestStart_SkipsDisabledUnits(t *testing.T) {
u1 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u2 := config.Unit{Name: "app2", Runtime: "podman", Order: 2, Enabled: false}
units := []config.Unit{u1, u2}
enabledCount := 0
for _, u := range units {
if !u.Enabled {
continue // skip disabled like startall does
}
enabledCount++
}
if enabledCount != 1 {
t.Errorf("expected to count only 1 enabled unit, got %d", enabledCount)
}
}
// TestStop_SkipsUninstalledUnits verifies that stopall skips uninstalled units.
func TestStop_SkipsUninstalledUnits(t *testing.T) {
u := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
units := []config.Unit{u}
for _, unit := range units {
if !IsInstalled(unit, "", "") {
continue // skip uninstalled like stopall does
}
}
t.Log("uninstalled units are skipped (no service files exist in test env)")
}