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
This commit is contained in:
2026-04-03 14:39:09 +02:00
commit aa7d85822c
12 changed files with 633 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
unitdore
*.exe

182
PLAN.md Normal file
View File

@@ -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-<name>.service`
then runs `systemctl daemon-reload`
- For units with `user: hein` → writes to `/home/hein/.config/systemd/user/unitdore-<name>.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-<name>.service` for system units
- Runs `systemctl --user enable --now unitdore-<name>.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?

34
cmd/edit.go Normal file
View File

@@ -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
}

31
cmd/root.go Normal file
View File

@@ -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")
}

91
cmd/syncup.go Normal file
View File

@@ -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
}

85
config/config.go Normal file
View File

@@ -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
}

10
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "github.com/warkanum/unitdore/cmd"
func main() {
cmd.Execute()
}

73
runtime/docker.go Normal file
View File

@@ -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
}

73
runtime/podman.go Normal file
View File

@@ -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
}

33
runtime/runtime.go Normal file
View File

@@ -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
}
}