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