commit aa7d85822c873d9f2c5040da219846262831d14f Author: sgcommand Date: Fri Apr 3 14:39:09 2026 +0200 Initial commit: unitdore scaffold + syncup/edit commands - config: load/save/add unit, Unit struct - runtime: podman + docker discovery and exists check - cmd: syncup (discover + reconcile), edit, cobra root - PLAN.md: full project plan diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf06479 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +unitdore +*.exe diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..bb07ec9 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,182 @@ +# Unitdore — Project Plan + +> *A door you open and close for container units.* +> *Unitdore bridges your container runtime and systemd.* + +--- + +## What It Is + +A Go CLI tool that bridges your container runtime (Podman, Docker, or custom) and systemd. +It discovers running containers, stores them in a config file, and generates + manages systemd +`.service` units for each one. + +Think of it as: **config → systemd units → running services.** + +--- + +## Goals + +- Unified config for containers across runtimes and users +- Auto-generate systemd service files (no manual unit writing) +- Reconcile: disable units whose containers no longer exist +- Simple CLI — no daemon, no background process, no magic + +--- + +## Non-Goals + +- Not a container runtime (doesn't replace Podman/Docker) +- Not Kubernetes / Compose / Quadlet +- Not a daemon — runs on demand + +--- + +## Config File + +**Location:** `/etc/unitdore/units.yaml` +**Owned by:** root +**Modified by:** `unitdore edit` (opens in $EDITOR) or automatically by `syncup` + +```yaml +units: + - name: nginx + runtime: podman # podman | docker | exec + user: hein # empty = root/system unit; set = user unit for this user + command: "" # optional: override the ExecStart command entirely + order: 1 # startup order (lower = earlier) + delay: 0s # delay after previous order group finishes + enabled: true # false = unit exists but is not installed/started + disabled_reason: "" # auto-set by reconcile: "container not found", etc. +``` + +--- + +## Commands + +### `unitdore syncup` + +Discovers all currently running containers across configured runtimes and adds +any new ones to the config. Does **not** remove existing entries. + +- Runs `podman ps --format json` and/or `docker ps --format json` +- Compares against existing config entries by name +- Appends new units with `enabled: true` +- Writes updated config back to disk +- Also reconciles: checks if containers for existing units still exist. + If not → sets `enabled: false` + `disabled_reason` + +### `unitdore edit` + +Opens `/etc/unitdore/units.yaml` in `$EDITOR` (fallback: `vi`). + +### `unitdore install` + +Generates and installs systemd `.service` files for all **enabled** units. + +- For units with `user: ""` → writes to `/etc/systemd/system/unitdore-.service` + then runs `systemctl daemon-reload` +- For units with `user: hein` → writes to `/home/hein/.config/systemd/user/unitdore-.service` + then runs `systemctl --user daemon-reload` as that user +- Does **not** enable or start — just installs the unit files +- Removes unit files for disabled units and reloads + +### `unitdore active` + +Enables and starts all installed, enabled units. + +- Runs `systemctl enable --now unitdore-.service` for system units +- Runs `systemctl --user enable --now unitdore-.service` for user units +- Reports success/failure per unit + +### `unitdore status` + +Prints a summary table of all managed units. + +``` +NAME RUNTIME USER ENABLED SYSTEMD STATUS REASON +nginx podman — yes active (running) +myapp docker hein yes active (running) +oldthing podman hein no inactive container not found +``` + +--- + +## Generated Service 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, `User=` and `Group=` are set and it targets `default.target`. + +--- + +## Project Structure + +``` +unitdore/ +├── main.go +├── go.mod +├── cmd/ +│ ├── root.go # cobra root, version, config path flag +│ ├── syncup.go # discover + reconcile +│ ├── edit.go # open $EDITOR +│ ├── install.go # generate + write unit files +│ ├── active.go # enable + start units +│ └── status.go # status table +├── config/ +│ └── config.go # load, save, Unit struct, YAML marshal/unmarshal +├── runtime/ +│ ├── runtime.go # interface: ListContainers() []Container +│ ├── podman.go # podman ps --format json +│ └── docker.go # docker ps --format json +└── systemd/ + ├── generator.go # build .service file content from a Unit + └── manager.go # install, reload, enable, start, status via systemctl +``` + +--- + +## Dependencies + +- `github.com/spf13/cobra` — CLI framework +- `gopkg.in/yaml.v3` — config file +- Standard library for everything else (exec, os, text/template) + +--- + +## Phased Build + +| Phase | Deliverable | +|---|---| +| 1 | Scaffold: project, go.mod, cobra root, config load/save | +| 2 | `syncup` — discover containers, update config | +| 3 | `install` — generate + write service files | +| 4 | `active` + `status` | +| 5 | Reconcile (auto-disable missing units) in syncup | +| 6 | User unit support (rootless containers) | +| 7 | Polish: `edit`, error handling, --dry-run flag | + +--- + +## Open Questions + +- [ ] Should `syncup` also accept a `--runtime` flag to limit discovery to one runtime? +- [ ] Should `install` imply a `syncup --reconcile` first, or be kept separate? +- [ ] Packaging: just a binary, or also a Makefile / install script? diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..2dea59b --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit", + Short: "Open the units config in $EDITOR", + RunE: runEdit, +} + +func init() { + rootCmd.AddCommand(editCmd) +} + +func runEdit(cmd *cobra.Command, args []string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + c := exec.Command(editor, configPath) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("editor exited with error: %w", err) + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..90288f6 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var configPath string + +var rootCmd = &cobra.Command{ + Use: "unitdore", + Short: "A door you open and close for container units", + Long: `Unitdore manages container units via systemd. + +It discovers running containers, stores them in a config file, +and generates + manages systemd .service units for each one.`, + Version: "0.1.0", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&configPath, "config", "/etc/unitdore/units.yaml", "path to units config file") +} diff --git a/cmd/syncup.go b/cmd/syncup.go new file mode 100644 index 0000000..6e7f02f --- /dev/null +++ b/cmd/syncup.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/runtime" +) + +var syncupCmd = &cobra.Command{ + Use: "syncup", + Short: "Discover running containers and sync them into the config", + Long: `Syncup queries all available runtimes for running containers and adds +any new ones to the config file. Existing entries are never removed. + +It also reconciles: if a configured unit's container no longer exists, +it is marked disabled with a reason.`, + RunE: runSyncup, +} + +func init() { + rootCmd.AddCommand(syncupCmd) +} + +func runSyncup(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadOrEmpty(configPath) + if err != nil { + return err + } + + added := 0 + discovered := map[string]bool{} + + // Discover running containers across all runtimes + for _, rt := range runtime.Available() { + containers, err := rt.ListRunning() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s discovery failed: %v\n", rt.Name(), err) + continue + } + for _, c := range containers { + discovered[c.Name] = true + unit := config.Unit{ + Name: c.Name, + Runtime: c.Runtime, + Order: len(cfg.Units) + 1, + Enabled: true, + } + if cfg.AddUnit(unit) { + fmt.Printf(" + added: %s (%s)\n", c.Name, c.Runtime) + added++ + } + } + } + + // Reconcile: check if existing units' containers still exist + disabled := 0 + reenabled := 0 + for i := range cfg.Units { + u := &cfg.Units[i] + rt := runtime.Get(u.Runtime) + if rt == nil { + continue + } + exists, err := rt.Exists(u.Name) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err) + continue + } + if !exists && u.Enabled { + u.Enabled = false + u.DisabledReason = "container not found" + fmt.Printf(" ! disabled: %s (container not found)\n", u.Name) + disabled++ + } else if exists && !u.Enabled && u.DisabledReason == "container not found" { + u.Enabled = true + u.DisabledReason = "" + fmt.Printf(" ✓ re-enabled: %s (container found)\n", u.Name) + reenabled++ + } + } + + if err := cfg.Save(configPath); err != nil { + return err + } + + fmt.Printf("\nDone. Added: %d Disabled: %d Re-enabled: %d\n", added, disabled, reenabled) + return nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..fc15f25 --- /dev/null +++ b/config/config.go @@ -0,0 +1,85 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const DefaultConfigPath = "/etc/unitdore/units.yaml" + +// Unit represents a single managed container unit. +type Unit struct { + Name string `yaml:"name"` + Runtime string `yaml:"runtime"` // podman | docker | exec + User string `yaml:"user,omitempty"` // empty = root/system unit + Command string `yaml:"command,omitempty"` // override ExecStart + Order int `yaml:"order"` + Delay string `yaml:"delay,omitempty"` // e.g. "5s" + Enabled bool `yaml:"enabled"` + DisabledReason string `yaml:"disabled_reason,omitempty"` +} + +// Config is the root config structure. +type Config struct { + Units []Unit `yaml:"units"` +} + +// Load reads and parses the config file at path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + return &cfg, nil +} + +// LoadOrEmpty loads the config file, or returns an empty config if it doesn't exist. +func LoadOrEmpty(path string) (*Config, error) { + _, err := os.Stat(path) + if os.IsNotExist(err) { + return &Config{}, nil + } + return Load(path) +} + +// Save writes the config back to disk, creating parent directories if needed. +func (c *Config) Save(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("marshalling config: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + return nil +} + +// FindUnit returns a pointer to the unit with the given name, or nil. +func (c *Config) FindUnit(name string) *Unit { + for i := range c.Units { + if c.Units[i].Name == name { + return &c.Units[i] + } + } + return nil +} + +// AddUnit appends a unit if one with that name doesn't already exist. +// Returns true if it was added. +func (c *Config) AddUnit(u Unit) bool { + if c.FindUnit(u.Name) != nil { + return false + } + c.Units = append(c.Units, u) + return true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b9e10c --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/warkanum/unitdore + +go 1.26.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff4d6ec --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b6b4d1b --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/warkanum/unitdore/cmd" + +func main() { + cmd.Execute() +} diff --git a/runtime/docker.go b/runtime/docker.go new file mode 100644 index 0000000..9441dfc --- /dev/null +++ b/runtime/docker.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +type Docker struct{} + +func (d *Docker) Name() string { return "docker" } + +type dockerContainer struct { + Names string `json:"Names"` + Image string `json:"Image"` + Command string `json:"Command"` + State string `json:"State"` +} + +func (d *Docker) ListRunning() ([]Container, error) { + bin, err := exec.LookPath("docker") + if err != nil { + return nil, nil // docker not installed — not an error + } + + out, err := exec.Command(bin, "ps", "--format", "json").Output() + if err != nil { + return nil, fmt.Errorf("docker ps: %w", err) + } + + // Docker outputs one JSON object per line (not a JSON array) + var containers []Container + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + var c dockerContainer + if err := json.Unmarshal([]byte(line), &c); err != nil { + continue + } + name := strings.TrimPrefix(c.Names, "/") + if name == "" { + continue + } + containers = append(containers, Container{ + Name: name, + Image: c.Image, + Command: c.Command, + Runtime: "docker", + }) + } + return containers, nil +} + +func (d *Docker) Exists(name string) (bool, error) { + bin, err := exec.LookPath("docker") + if err != nil { + return false, nil + } + + out, err := exec.Command(bin, "ps", "-a", "--format", "{{.Names}}").Output() + if err != nil { + return false, fmt.Errorf("docker ps -a: %w", err) + } + + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == name { + return true, nil + } + } + return false, nil +} diff --git a/runtime/podman.go b/runtime/podman.go new file mode 100644 index 0000000..067604a --- /dev/null +++ b/runtime/podman.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +type Podman struct{} + +func (p *Podman) Name() string { return "podman" } + +type podmanContainer struct { + Names []string `json:"Names"` + Image string `json:"Image"` + Command string `json:"Command"` + State string `json:"State"` +} + +func (p *Podman) ListRunning() ([]Container, error) { + bin, err := exec.LookPath("podman") + if err != nil { + return nil, nil // podman not installed — not an error + } + + out, err := exec.Command(bin, "ps", "--format", "json").Output() + if err != nil { + return nil, fmt.Errorf("podman ps: %w", err) + } + + var raw []podmanContainer + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("parsing podman output: %w", err) + } + + var containers []Container + for _, c := range raw { + name := "" + if len(c.Names) > 0 { + name = c.Names[0] + } + if name == "" { + continue + } + containers = append(containers, Container{ + Name: name, + Image: c.Image, + Command: c.Command, + Runtime: "podman", + }) + } + return containers, nil +} + +func (p *Podman) Exists(name string) (bool, error) { + bin, err := exec.LookPath("podman") + if err != nil { + return false, nil + } + + out, err := exec.Command(bin, "ps", "-a", "--format", "{{.Names}}").Output() + if err != nil { + return false, fmt.Errorf("podman ps -a: %w", err) + } + + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == name { + return true, nil + } + } + return false, nil +} diff --git a/runtime/runtime.go b/runtime/runtime.go new file mode 100644 index 0000000..884f822 --- /dev/null +++ b/runtime/runtime.go @@ -0,0 +1,33 @@ +package runtime + +// Container represents a discovered running container. +type Container struct { + Name string + Image string + Command string + Runtime string // "podman" or "docker" +} + +// Runtime is the interface all container runtimes must implement. +type Runtime interface { + Name() string + ListRunning() ([]Container, error) + Exists(name string) (bool, error) +} + +// Available returns all supported runtimes (callers skip those with no binary). +func Available() []Runtime { + return []Runtime{&Podman{}, &Docker{}} +} + +// Get returns a runtime by name ("podman" or "docker"). +func Get(name string) Runtime { + switch name { + case "podman": + return &Podman{} + case "docker": + return &Docker{} + default: + return nil + } +}