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
This commit is contained in:
2026-07-05 15:12:18 +02:00
parent e6ef6e11d6
commit 029b9cee2e
9 changed files with 298 additions and 16 deletions
+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
}
+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 -3
View File
@@ -19,7 +19,7 @@ Requires={{.}}{{end}}
[Service] [Service]
Type=simple Type=simple
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec={{.Unit.RestartSec}} RestartSec={{.Unit.RestartSec}}
@@ -42,7 +42,7 @@ After=default.target
[Service] [Service]
Type=simple Type=simple
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec={{.Unit.RestartSec}} RestartSec={{.Unit.RestartSec}}
@@ -117,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)
+6 -6
View File
@@ -49,7 +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",
"Generated by unitdore", "Generated by unitdore",
} }
@@ -87,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 {
@@ -178,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",
} }
@@ -240,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",
}, },
{ {