diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c58428a --- /dev/null +++ b/AGENTS.md @@ -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-.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` | + +### 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 diff --git a/AI_USE.md b/AI_USE.md new file mode 100644 index 0000000..397857c --- /dev/null +++ b/AI_USE.md @@ -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 | + \_____________/ + \___________/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3435010 --- /dev/null +++ b/CLAUDE.md @@ -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 + diff --git a/cmd/installall.go b/cmd/installall.go new file mode 100644 index 0000000..7fb1720 --- /dev/null +++ b/cmd/installall.go @@ -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 +} diff --git a/cmd/startall.go b/cmd/startall.go index c3fc830..fffbda8 100644 --- a/cmd/startall.go +++ b/cmd/startall.go @@ -11,9 +11,9 @@ import ( var startallCmd = &cobra.Command{ Use: "startall", - Short: "Enable and start all installed, enabled units", - Long: `Startall runs 'systemctl enable --now' for all enabled units that have -been installed. Units must be installed first via 'unitdore install'.`, + Short: "Start all installed, enabled units", + Long: `Startall runs 'systemctl start' for all enabled units that have been installed. +Units must be installed first via 'unitdore installall'.`, RunE: runStartall, } @@ -50,7 +50,7 @@ func runStartall(cmd *cobra.Command, args []string) error { continue } 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) failed++ } else { diff --git a/cmd/stopall.go b/cmd/stopall.go index 7642c45..f3515bb 100644 --- a/cmd/stopall.go +++ b/cmd/stopall.go @@ -11,8 +11,8 @@ import ( var stopallCmd = &cobra.Command{ Use: "stopall", - Short: "Stop and disable all running managed units", - Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`, + Short: "Stop all installed units", + Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`, RunE: runStopall, } @@ -46,7 +46,7 @@ func runStopall(cmd *cobra.Command, args []string) error { continue } 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) failed++ } else { diff --git a/cmd/uninstallall.go b/cmd/uninstallall.go new file mode 100644 index 0000000..0989224 --- /dev/null +++ b/cmd/uninstallall.go @@ -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 +} diff --git a/systemd/generator.go b/systemd/generator.go index 0650191..6cde0c7 100644 --- a/systemd/generator.go +++ b/systemd/generator.go @@ -19,7 +19,7 @@ Requires={{.}}{{end}} [Service] Type=simple ExecStart={{.ExecStart}} -ExecStop={{.ExecStop}} +ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} Restart=on-failure RestartSec={{.Unit.RestartSec}} @@ -42,7 +42,7 @@ After=default.target [Service] Type=simple ExecStart={{.ExecStart}} -ExecStop={{.ExecStop}} +ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} Restart=on-failure 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) stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name) 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) default: start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name) diff --git a/systemd/generator_test.go b/systemd/generator_test.go index 493d7aa..3f8ce94 100644 --- a/systemd/generator_test.go +++ b/systemd/generator_test.go @@ -49,7 +49,7 @@ func TestGenerate_SystemUnit(t *testing.T) { "After=network.target", "WantedBy=multi-user.target", "ExecStart=/usr/bin/podman start -a nginx", - "ExecStop=/usr/bin/podman stop nginx", + "ExecStop=-/usr/bin/podman stop nginx", "Generated by unitdore", } @@ -87,8 +87,8 @@ func TestGenerate_UserUnit(t *testing.T) { checks := []string{ "After=default.target", "WantedBy=default.target", - "ExecStart=/usr/bin/docker start myapp", - "ExecStop=/usr/bin/docker stop myapp", + "ExecStart=/usr/bin/docker start -a myapp", + "ExecStop=-/usr/bin/docker stop myapp", } for _, check := range checks { @@ -178,8 +178,8 @@ func TestGenerate_DockerRuntime(t *testing.T) { } checks := []string{ - "ExecStart=/usr/bin/docker start redis", - "ExecStop=/usr/bin/docker stop redis", + "ExecStart=/usr/bin/docker start -a redis", + "ExecStop=-/usr/bin/docker stop redis", "After=network.target docker.service", "Requires=docker.service", } @@ -240,7 +240,7 @@ func TestBuildExecCommands(t *testing.T) { { name: "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", }, {