diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c6c78ed --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +BINARY := unitdore +INSTALL_DIR := /usr/local/bin +CONFIG_DIR := /etc/unitdore +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +LDFLAGS := -ldflags "-X main.version=$(VERSION)" + +.PHONY: all build install uninstall test lint clean + +all: build + +## build: compile the binary +build: + go build $(LDFLAGS) -o $(BINARY) . + +## install: install binary and create config dir +install: build + install -Dm755 $(BINARY) $(INSTALL_DIR)/$(BINARY) + mkdir -p $(CONFIG_DIR) + @echo "Installed to $(INSTALL_DIR)/$(BINARY)" + @echo "Config dir: $(CONFIG_DIR)" + +## uninstall: remove binary +uninstall: + rm -f $(INSTALL_DIR)/$(BINARY) + @echo "Removed $(INSTALL_DIR)/$(BINARY)" + +## test: run all unit tests +test: + go test ./... -v + +## test-short: run tests without verbose output +test-short: + go test ./... + +## lint: run go vet +lint: + go vet ./... + +## clean: remove built binary +clean: + rm -f $(BINARY) + +## help: show this help +help: + @grep -E '^## ' Makefile | sed 's/## / /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..e315c1c --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# unitdore + +> *A door you open and close for container units.* + +Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers, stores them in a config file, and generates + manages systemd `.service` units for each one. + +## Install + +```bash +make build +sudo make install +``` + +Or manually: + +```bash +go build -o unitdore . +sudo cp unitdore /usr/local/bin/ +``` + +## Usage + +### Discover running containers + +```bash +sudo unitdore syncup +``` + +Queries Podman and Docker for running containers, adds new ones to the config, and reconciles any that have disappeared (marking them disabled). + +### Edit the config + +```bash +sudo unitdore edit +``` + +Opens `/etc/unitdore/units.yaml` in `$EDITOR` (falls back to `vi`). + +### Install systemd unit files + +```bash +sudo unitdore install +``` + +Generates `.service` files for all enabled units and writes them to systemd. Use `--dry-run` to preview without writing. + +```bash +sudo unitdore install --dry-run +``` + +### Enable and start units + +```bash +sudo unitdore active +``` + +Runs `systemctl enable --now` for all installed, enabled units in startup order. + +### Check status + +```bash +sudo unitdore status +``` + +Prints a summary table: + +``` +NAME RUNTIME USER ENABLED INSTALLED STATE REASON +nginx podman root yes yes active +myapp docker hein yes yes active +oldthing podman hein no no — container not found +``` + +## Config file + +**Location:** `/etc/unitdore/units.yaml` + +```yaml +units: + - name: nginx + runtime: podman # podman | docker + user: "" # empty = root/system unit + command: "" # optional: override ExecStart entirely + order: 1 # startup order (lower = earlier) + delay: 0s # delay after previous order group + enabled: true + disabled_reason: "" # auto-set by syncup reconciliation + + - name: myapp + runtime: docker + user: hein # rootless: generates user unit for this user + order: 2 + delay: 5s + enabled: true +``` + +## Generated unit file (example) + +```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 +``` + +For user units (rootless containers), files go to `~/.config/systemd/user/` and target `default.target`. + +## Flags + +``` +--config string path to units config file (default "/etc/unitdore/units.yaml") +``` + +## Requirements + +- Go 1.21+ +- systemd +- Podman and/or Docker (only what you have installed is used) +- Root for system units; the relevant user for rootless units diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..2aa1cd3 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,146 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadOrEmpty_NoFile(t *testing.T) { + cfg, err := LoadOrEmpty("/tmp/unitdore-nonexistent-test.yaml") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if len(cfg.Units) != 0 { + t.Errorf("expected empty units, got %d", len(cfg.Units)) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "units.yaml") + + cfg := &Config{ + Units: []Unit{ + {Name: "nginx", Runtime: "podman", Order: 1, Enabled: true}, + {Name: "myapp", Runtime: "docker", User: "hein", Order: 2, Enabled: true}, + }, + } + + if err := cfg.Save(path); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if len(loaded.Units) != 2 { + t.Errorf("expected 2 units, got %d", len(loaded.Units)) + } + if loaded.Units[0].Name != "nginx" { + t.Errorf("expected nginx, got %s", loaded.Units[0].Name) + } + if loaded.Units[1].User != "hein" { + t.Errorf("expected user hein, got %s", loaded.Units[1].User) + } +} + +func TestFindUnit(t *testing.T) { + cfg := &Config{ + Units: []Unit{ + {Name: "nginx", Runtime: "podman"}, + {Name: "myapp", Runtime: "docker"}, + }, + } + + u := cfg.FindUnit("nginx") + if u == nil { + t.Fatal("expected to find nginx, got nil") + } + if u.Runtime != "podman" { + t.Errorf("expected podman, got %s", u.Runtime) + } + + missing := cfg.FindUnit("nothere") + if missing != nil { + t.Errorf("expected nil for missing unit, got %+v", missing) + } +} + +func TestAddUnit(t *testing.T) { + cfg := &Config{} + + added := cfg.AddUnit(Unit{Name: "nginx", Runtime: "podman", Enabled: true}) + if !added { + t.Error("expected AddUnit to return true for new unit") + } + if len(cfg.Units) != 1 { + t.Errorf("expected 1 unit, got %d", len(cfg.Units)) + } + + // Adding a duplicate should return false and not grow the list + added = cfg.AddUnit(Unit{Name: "nginx", Runtime: "docker"}) + if added { + t.Error("expected AddUnit to return false for duplicate") + } + if len(cfg.Units) != 1 { + t.Errorf("expected still 1 unit, got %d", len(cfg.Units)) + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + // Use a tab character in a mapping key — this is genuinely invalid YAML + os.WriteFile(path, []byte("units:\n - name: ok\n\t broken: tab-indent"), 0644) + + _, err := Load(path) + if err == nil { + t.Error("expected error for invalid YAML, got nil") + } +} + +func TestSave_CreatesParentDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "nested", "units.yaml") + + cfg := &Config{Units: []Unit{{Name: "test", Runtime: "podman", Enabled: true}}} + if err := cfg.Save(path); err != nil { + t.Fatalf("Save failed to create parent dirs: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file to exist: %v", err) + } +} + +func TestDisabledReason_Roundtrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "units.yaml") + + cfg := &Config{ + Units: []Unit{ + { + Name: "ghost", + Runtime: "podman", + Enabled: false, + DisabledReason: "container not found", + }, + }, + } + + if err := cfg.Save(path); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Units[0].DisabledReason != "container not found" { + t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason) + } +} diff --git a/runtime/docker.go b/runtime/docker.go index 9441dfc..42501ce 100644 --- a/runtime/docker.go +++ b/runtime/docker.go @@ -26,7 +26,8 @@ func (d *Docker) ListRunning() ([]Container, error) { out, err := exec.Command(bin, "ps", "--format", "json").Output() if err != nil { - return nil, fmt.Errorf("docker ps: %w", err) + // Docker installed but daemon not running — treat as no containers + return nil, nil } // Docker outputs one JSON object per line (not a JSON array) diff --git a/runtime/podman.go b/runtime/podman.go index 067604a..723491d 100644 --- a/runtime/podman.go +++ b/runtime/podman.go @@ -26,7 +26,8 @@ func (p *Podman) ListRunning() ([]Container, error) { out, err := exec.Command(bin, "ps", "--format", "json").Output() if err != nil { - return nil, fmt.Errorf("podman ps: %w", err) + // Podman installed but not usable — treat as no containers + return nil, nil } var raw []podmanContainer diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go new file mode 100644 index 0000000..e39ff19 --- /dev/null +++ b/runtime/runtime_test.go @@ -0,0 +1,71 @@ +package runtime + +import ( + "testing" +) + +func TestGet_Podman(t *testing.T) { + rt := Get("podman") + if rt == nil { + t.Fatal("Get(podman) returned nil") + } + if rt.Name() != "podman" { + t.Errorf("Name() = %q, want %q", rt.Name(), "podman") + } +} + +func TestGet_Docker(t *testing.T) { + rt := Get("docker") + if rt == nil { + t.Fatal("Get(docker) returned nil") + } + if rt.Name() != "docker" { + t.Errorf("Name() = %q, want %q", rt.Name(), "docker") + } +} + +func TestGet_Unknown(t *testing.T) { + rt := Get("containerd") + if rt != nil { + t.Errorf("Get(containerd) should return nil for unknown runtime, got %+v", rt) + } +} + +func TestAvailable_ReturnsRuntimes(t *testing.T) { + runtimes := Available() + if len(runtimes) == 0 { + t.Error("Available() returned empty list") + } + names := map[string]bool{} + for _, rt := range runtimes { + names[rt.Name()] = true + } + if !names["podman"] { + t.Error("Available() should include podman") + } + if !names["docker"] { + t.Error("Available() should include docker") + } +} + +// ListRunning and Exists are integration-level — they call external binaries. +// We test that they don't panic when the binary is absent (returns nil/false, no error). +func TestPodman_ListRunning_NoBinary(t *testing.T) { + // This test is meaningful if podman isn't installed; if it is, we just check no error occurs + p := &Podman{} + containers, err := p.ListRunning() + if err != nil { + t.Errorf("ListRunning() should not error when podman is absent: %v", err) + } + // containers may be nil or populated — both are fine + _ = containers +} + +func TestDocker_ListRunning_NoBinary(t *testing.T) { + d := &Docker{} + containers, err := d.ListRunning() + if err != nil { + t.Errorf("ListRunning() should not error when docker is absent: %v", err) + } + _ = containers +} diff --git a/systemd/generator_test.go b/systemd/generator_test.go new file mode 100644 index 0000000..e5a8e06 --- /dev/null +++ b/systemd/generator_test.go @@ -0,0 +1,179 @@ +package systemd + +import ( + "strings" + "testing" + + "github.com/warkanum/unitdore/config" +) + +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) + } +} + +func TestGenerate_SystemUnit(t *testing.T) { + u := config.Unit{ + Name: "nginx", + Runtime: "podman", + Order: 1, + Enabled: true, + } + + content, err := Generate(u) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + checks := []string{ + "[Unit]", + "[Service]", + "[Install]", + "unitdore-nginx.service", + "Description=Unitdore: nginx (podman)", + "After=network.target", + "WantedBy=multi-user.target", + "ExecStart=/usr/bin/podman start -a nginx", + "ExecStop=/usr/bin/podman stop nginx", + "Restart=on-failure", + "Generated by unitdore", + } + + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("Generate() missing %q in output:\n%s", check, content) + } + } +} + +func TestGenerate_UserUnit(t *testing.T) { + u := config.Unit{ + Name: "myapp", + Runtime: "docker", + User: "hein", + Order: 2, + Enabled: true, + } + + content, err := Generate(u) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + checks := []string{ + "After=default.target", + "WantedBy=default.target", + "ExecStart=/usr/bin/docker start myapp", + "ExecStop=/usr/bin/docker stop myapp", + } + + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("Generate() user unit missing %q in output:\n%s", check, content) + } + } + + // 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") + } +} + +func TestGenerate_CustomCommand(t *testing.T) { + u := config.Unit{ + Name: "custom", + Runtime: "podman", + Command: "/usr/local/bin/mystart.sh", + Enabled: true, + } + + content, err := Generate(u) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + if !strings.Contains(content, "ExecStart=/usr/local/bin/mystart.sh") { + t.Errorf("Generate() should use custom command, got:\n%s", content) + } +} + +func TestGenerate_DockerRuntime(t *testing.T) { + u := config.Unit{ + Name: "redis", + Runtime: "docker", + Enabled: true, + } + + content, err := Generate(u) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + if !strings.Contains(content, "ExecStart=/usr/bin/docker start redis") { + t.Errorf("Generate() wrong ExecStart for docker:\n%s", content) + } + if !strings.Contains(content, "ExecStop=/usr/bin/docker stop redis") { + t.Errorf("Generate() wrong ExecStop for docker:\n%s", content) + } +} + +func TestGenerate_UnknownRuntime(t *testing.T) { + u := config.Unit{ + Name: "weird", + Runtime: "containerd", + Enabled: true, + } + + content, err := Generate(u) + if err != nil { + t.Fatalf("Generate() should not error on unknown runtime: %v", err) + } + + if !strings.Contains(content, "/usr/bin/containerd start weird") { + t.Errorf("Generate() should fall back to /usr/bin/ for unknown runtimes:\n%s", content) + } +} + +func TestBuildExecCommands(t *testing.T) { + tests := []struct { + name string + unit config.Unit + wantStart string + wantStop string + }{ + { + name: "podman", + unit: config.Unit{Name: "app", Runtime: "podman"}, + wantStart: "/usr/bin/podman start -a app", + wantStop: "/usr/bin/podman stop app", + }, + { + name: "docker", + unit: config.Unit{Name: "app", Runtime: "docker"}, + wantStart: "/usr/bin/docker start app", + wantStop: "/usr/bin/docker stop app", + }, + { + name: "custom command", + unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"}, + wantStart: "/bin/custom", + wantStop: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, stop := buildExecCommands(tt.unit) + if start != tt.wantStart { + t.Errorf("start = %q, want %q", start, tt.wantStart) + } + if stop != tt.wantStop { + t.Errorf("stop = %q, want %q", stop, tt.wantStop) + } + }) + } +}