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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
unitdore
|
||||||
|
*.exe
|
||||||
182
PLAN.md
Normal file
182
PLAN.md
Normal 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
34
cmd/edit.go
Normal 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
31
cmd/root.go
Normal 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
91
cmd/syncup.go
Normal 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
85
config/config.go
Normal 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
10
go.mod
Normal 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
12
go.sum
Normal 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
7
main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/warkanum/unitdore/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
73
runtime/docker.go
Normal file
73
runtime/docker.go
Normal 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
73
runtime/podman.go
Normal 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
33
runtime/runtime.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user