9 Commits
Author SHA1 Message Date
warkanum 173b4b2523 chore(release): update package version to 0.0.11
Release / test (push) Successful in 30s
Release / release (push) Successful in 47s
Release / pkg-aur (push) Successful in 37s
Release / pkg-rpm (push) Successful in 45s
Release / pkg-deb (push) Failing after 21s
2026-10-12 19:08:06 +02:00
warkanum cb4413b840 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-10-12 19:07:38 +02:00
warkanum f0827fcdd7 feat: podman-compose stack units and root/user execution scopes
- syncup: podman first, docker only as fallback (podman wins on name clash;
  existing docker units found in podman switch to podman)
- syncup: group podman-compose containers into one `kind: stack` unit per
  project (project, working_dir, compose_files, compose_bin from labels);
  disable existing member units ("part of stack <project>")
- generator: compose mode (podman-compose up/stop, WorkingDirectory) with
  pod start/stop fallback when compose binary/dir/files are missing
- reconcile: stacks disabled with "stack not found" when neither compose
  files nor pod exist
- root: scan root + rootless users listed in `users:` (podman only)
- non-root: config ~/.config/unitdore/units.yaml, own user units only
- sysuser package: runuser with XDG_RUNTIME_DIR/DBUS env, home/linger lookup
- install: warn when linger is not enabled for user units
- testdata/stack: podman-compose test stack and captured podman ps fixture
- docs: README, generated-units, man page, AGENTS, PLAN
2026-10-12 19:05:51 +02:00
warkanum cc4829046d ci(release): attach source archives to release and use them for AUR package
Gitea's /archive/ endpoint is disabled, which broke the AUR source download.
Build source tar.gz/zip with git archive, upload as release assets, and point
the PKGBUILD and checksum step at the release asset.
2026-10-12 18:27:53 +02:00
warkanum 13817f8d34 Merge pull request 'issue-5: regression tests for start/stop/startall/stopall CLI semantics' (#6) from issue-5-targeted-start-stop into main
Reviewed-on: #6
2026-08-20 08:39:13 +00:00
Hermes Agent 5026ae4d16 feat(cmd,systemd): add focused regression tests for start/stop/startall/stopall
Add cmd/start_test.go and systemd/start_stop_test.go with regression
tests covering:

- Start/Stop require exactly one unit name argument (cobra.ExactArgs(1)
  semantics verified via ValidateArgs)
- Start/Stop build correct exec commands per runtime
- StartAll sorts units by Order asc, then Name asc as tiebreaker
- StopAll sorts units by Order desc, then Name desc as tiebreaker
- StartAll skips disabled units (Enabled=false)
- StopAll skips uninstalled units

These tests enforce the CLI semantics that 'start' starts only a specific
unit and 'stop' stops only a specific unit; 'startall' and 'stopall'
operate on all matching units.

Run: make test
2026-07-16 22:26:11 +02:00
warkanum 917480670f chore(release): update package version to 0.0.10
Release / test (push) Successful in 36s
Release / release (push) Successful in 44s
Release / pkg-deb (push) Successful in 38s
Release / pkg-rpm (push) Successful in 48s
Release / pkg-aur (push) Successful in 56s
2026-07-05 15:12:36 +02:00
warkanum 029b9cee2e feat(cmd): add installall/uninstallall; fix startall/stopall and service lifecycle
- startall now uses systemctl start (not enable --now)
- stopall now uses systemctl stop (not disable --now)
- add installall command (bulk install with --dry-run support)
- add uninstallall command (bulk disable+remove in reverse order)
- fix docker ExecStart to use -a flag so the process stays attached
- prefix ExecStop with - so a stop-on-already-dead container does not fail the unit
2026-07-05 15:12:18 +02:00
warkanum e6ef6e11d6 chore(release): update package version to 0.0.9
Release / test (push) Successful in -33m2s
Release / release (push) Successful in -32m47s
Release / pkg-aur (push) Successful in -33m46s
Release / pkg-deb (push) Successful in -33m19s
Release / pkg-rpm (push) Successful in -32m33s
2026-04-12 11:08:47 +02:00
36 changed files with 1878 additions and 145 deletions
+8 -1
View File
@@ -53,6 +53,13 @@ jobs:
echo "Built $NAME" echo "Built $NAME"
done done
- name: Build source archives
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
PREFIX="unitdore-${TAG#v}"
git archive --format=tar.gz --prefix="${PREFIX}/" -o "unitdore-${TAG}-source.tar.gz" "$TAG"
git archive --format=zip --prefix="${PREFIX}/" -o "unitdore-${TAG}-source.zip" "$TAG"
- name: Create release and upload assets - name: Create release and upload assets
run: | run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}" TAG="${{ github.event.inputs.tag || github.ref_name }}"
@@ -166,7 +173,7 @@ jobs:
echo "Publishing AUR package version ${PKGVER}-${PKGREL}" echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
# Compute SHA256 of the source archive from the same URL the PKGBUILD will download. # Compute SHA256 of the source archive from the same URL the PKGBUILD will download.
SHA=$(curl -fsSL "https://git.warky.dev/wdevs/unitdore/archive/v${PKGVER}.zip" | sha256sum | cut -d' ' -f1) SHA=$(curl -fsSL "https://git.warky.dev/wdevs/unitdore/releases/download/v${PKGVER}/unitdore-v${PKGVER}-source.zip" | sha256sum | cut -d' ' -f1)
# Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds. # Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds.
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \ sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
+104
View File
@@ -0,0 +1,104 @@
## Project Overview
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and systemd. It discovers running containers, stores them in a YAML config, and generates + manages systemd `.service` unit files for each one.
# Agent Rules
Keep answers short. Question everything, never assume or guess. Ask if unsure.
You must use the AMCS MCP system tools. Set **unitdore** as the active project and always capture summaries of what was done as thoughts using the `capture_thought` tool. If AMCS is not available, write files to `docs/llm/log/yyyymmdd_hh.md`.
# Tools to use
When writing Go: `doc/tools/go-skill.md` (if present), otherwise follow standard Go conventions.
## Commands
```bash
make build # Compile binary: ./unitdore
make install # Install binary to /usr/bin, man page, create /etc/unitdore
make uninstall # Remove binary and man page
make test # go test ./... -v
make test-short # go test ./...
make lint # go vet ./...
make clean # Remove built binary
make release-version # Bump patch version, commit, tag, and push
```
## Architecture
```
cmd/ # Cobra commands (edit, install, list, root, start, startall,
# status, stop, stopall, syncup, uninstall, update)
config/ # Config struct + YAML load/save (units.yaml)
runtime/ # Docker and Podman runtime abstraction, compose labels
sysuser/ # run commands as root/user (runuser), home/linger lookup
testdata/stack # podman-compose test stack + captured `podman ps` fixture
systemd/ # .service file generator and systemd manager
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
```
### Key packages
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
### Config file
**Location:** `/etc/unitdore/units.yaml`
```yaml
prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc")
users: [] # root only: rootless users scanned by syncup
units:
- name: nginx
runtime: podman # podman | docker
user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel
delay: 0s # delay after previous order group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
restart: false # Restart=on-failure
restart_sec: 0 # RestartSec
restart_retries: 0 # StartLimitBurst
kind: container # container | stack (podman-compose project)
project: "" # stack: compose project
working_dir: "" # stack: compose dir (from labels)
compose_files: [] # stack: relative to working_dir
compose_bin: "" # stack: podman-compose path
```
Non-root runs use `~/.config/unitdore/units.yaml` and manage only own user units.
### Generated service file locations
| Scenario | Path |
|---|---|
| System unit (root) | `/etc/systemd/system/unitdore-<name>.service` |
| User unit | `/home/<user>/.config/systemd/user/unitdore-<name>.service` |
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
### Libraries
- `github.com/spf13/cobra` — CLI framework
- `gopkg.in/yaml.v3` — config parsing
## Testing
Tests exist in:
- `config/config_test.go`
- `runtime/runtime_test.go`
- `cmd/syncup_test.go`
- `sysuser/sysuser_test.go`
- `systemd/generator_test.go`
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
## Additional documentation
- `docs/generated-units.md` — full annotated examples of every generated unit file combination
- `docs/unitdore.1` — man page
+35
View File
@@ -0,0 +1,35 @@
# AI Usage Declaration
This Go project utilizes AI tools for the following purposes:
- Generating and improving documentation
- Writing and enhancing tests
- Refactoring and optimizing existing code
AI is **not** used for core design or architecture decisions.
All design decisions are deferred to human discussion.
AI is employed only for enhancements to human-written code.
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
.-""""""-.
.' '.
/ O O \
: ` :
| |
: .------. :
\ ' ' /
'. .'
'-......-'
MEGAMIND AI
[============]
___________
/___________\
/_____________\
| ASSIMILATE |
| RESISTANCE |
| IS FUTILE |
\_____________/
\___________/
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You must now read the./AGENTS.md
+4 -1
View File
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
## Non-Goals ## Non-Goals
- Not a container runtime (doesn't replace Podman/Docker) - Not a container runtime (doesn't replace Podman/Docker)
- Not Kubernetes / Compose / Quadlet - Not Kubernetes / Quadlet
- Compose: only wraps existing podman-compose stacks (one unit per project); docker compose planned
- Not a daemon — runs on demand - Not a daemon — runs on demand
--- ---
@@ -172,6 +173,8 @@ unitdore/
| 5 | Reconcile (auto-disable missing units) in syncup | | 5 | Reconcile (auto-disable missing units) in syncup |
| 6 | User unit support (rootless containers) | | 6 | User unit support (rootless containers) |
| 7 | Polish: `edit`, error handling, --dry-run flag | | 7 | Polish: `edit`, error handling, --dry-run flag |
| 8 | podman-compose stacks (compose + pod fallback), root/user scopes |
| 9 | docker compose stacks (`com.docker.compose.*` labels) |
--- ---
+38 -2
View File
@@ -2,7 +2,7 @@
> *A door you open and close for container units.* > *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. Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages systemd `.service` units for each one.
## Install ## Install
@@ -54,6 +54,11 @@ 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). Queries Podman and Docker for running containers, adds new ones to the config, and reconciles any that have disappeared (marking them disabled).
- Podman first; Docker only for names Podman doesn't have
- podman-compose containers → one `kind: stack` unit per project; existing member units disabled (`part of stack <project>`)
- Root: root's containers + rootless Podman containers of each user in `users:`
- Normal user: own Podman containers, config `~/.config/unitdore/units.yaml`, user units only
### Edit the config ### Edit the config
```bash ```bash
@@ -104,6 +109,7 @@ oldthing podman hein no no — container
```yaml ```yaml
prefix: "" # optional: prepended to all service names, e.g. "prod-" prefix: "" # optional: prepended to all service names, e.g. "prod-"
suffix: "" # optional: appended to all service names, e.g. "-svc" suffix: "" # optional: appended to all service names, e.g. "-svc"
users: [hein] # root only: rootless users scanned by syncup
units: units:
- name: nginx - name: nginx
@@ -121,8 +127,36 @@ units:
order: 2 order: 2
delay: 5s delay: 5s
enabled: true enabled: true
- name: shop # = compose project name
kind: stack # container (default) | stack
runtime: podman
user: hein
project: shop
working_dir: /srv/shop # from compose labels (refreshed by syncup)
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
order: 3
enabled: true
``` ```
### Stacks (podman-compose)
| Condition | Unit |
|---|---|
| `compose_bin`, `working_dir`, `compose_files` exist | `podman-compose -p <proj> -f <files> up` / `stop`, `Type=simple` |
| any missing | `podman pod start/stop pod_<proj>`, `Type=oneshot` |
| compose missing and no pod | `syncup` disables: `stack not found` |
### Users
| Run as | Config | Discovers | Units |
|---|---|---|---|
| root | `/etc/unitdore/units.yaml` | root + `users:` (Podman only for users) | system + user units |
| user | `~/.config/unitdore/units.yaml` | own Podman | own user units |
User units need linger to start at boot: `sudo loginctl enable-linger <user>` (`install` warns).
### Prefix and suffix ### Prefix and suffix
Set `prefix` and/or `suffix` to namespace your service files: Set `prefix` and/or `suffix` to namespace your service files:
@@ -147,6 +181,7 @@ See **[docs/generated-units.md](docs/generated-units.md)** for full examples cov
- User units (rootless) - User units (rootless)
- Docker vs Podman - Docker vs Podman
- Custom commands - Custom commands
- Compose stacks (compose + pod fallback)
- Prefix/suffix naming - Prefix/suffix naming
- Startup ordering - Startup ordering
- Unnamed containers (short ID fallback) - Unnamed containers (short ID fallback)
@@ -175,7 +210,7 @@ WantedBy=multi-user.target
## Flags ## Flags
``` ```
--config string path to units config file (default "/etc/unitdore/units.yaml") --config string path to units config file (default "/etc/unitdore/units.yaml"; non-root: ~/.config/unitdore/units.yaml)
``` ```
## Requirements ## Requirements
@@ -183,4 +218,5 @@ WantedBy=multi-user.target
- Go 1.21+ - Go 1.21+
- systemd - systemd
- Podman and/or Docker (only what you have installed is used) - Podman and/or Docker (only what you have installed is used)
- podman-compose for compose-mode stacks (pod fallback without it)
- Root for system units; the relevant user for rootless units - Root for system units; the relevant user for rootless units
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"fmt"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
// runtimeLabel is the RUNTIME column value for list/status.
func runtimeLabel(u config.Unit) string {
if u.IsStack() {
return u.Runtime + " (stack)"
}
return u.Runtime
}
// lingerHint prints, once per user, how to make user units start at boot.
func lingerHint(seen map[string]bool, u config.Unit) {
if u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) {
return
}
seen[u.User] = true
fmt.Printf(" ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User)
}
+2
View File
@@ -35,6 +35,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
installed := 0 installed := 0
skipped := 0 skipped := 0
removed := 0 removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units { for _, u := range cfg.Units {
if !u.Enabled { if !u.Enabled {
@@ -73,6 +74,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
} else { } else {
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path) fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
installed++ installed++
} }
} }
+85
View File
@@ -0,0 +1,85 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var installallDryRun bool
var installallCmd = &cobra.Command{
Use: "installall",
Short: "Generate and install systemd unit files for all enabled units",
Long: `Installall generates .service files for all enabled units and writes them
to the appropriate systemd directory. It does not enable or start them.
Use --dry-run to preview the generated unit files without writing anything.`,
RunE: runInstallall,
}
func init() {
installallCmd.Flags().BoolVar(&installallDryRun, "dry-run", false, "preview unit files without writing")
rootCmd.AddCommand(installallCmd)
}
func runInstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
installed := 0
skipped := 0
removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units {
if !u.Enabled {
if systemd.IsInstalled(u, prefix, suffix) {
if installallDryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
} else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
} else {
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix))
removed++
}
}
} else {
skipped++
}
continue
}
if installallDryRun {
content, err := systemd.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := systemd.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else {
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
installed++
}
}
if !installallDryRun {
fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped)
}
return nil
}
+1 -1
View File
@@ -60,7 +60,7 @@ func runList(cmd *cobra.Command, args []string) error {
} }
} }
rows = append(rows, row{u.Name, u.Runtime, enabled, installed, unitPath}) rows = append(rows, row{u.Name, runtimeLabel(u), enabled, installed, unitPath})
} }
cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"} cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"}
+20 -4
View File
@@ -5,9 +5,11 @@ import (
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
) )
var version = "0.0.8" var version = "0.0.11"
var configPath string var configPath string
@@ -16,9 +18,23 @@ var rootCmd = &cobra.Command{
Short: "A door you open and close for container units", Short: "A door you open and close for container units",
Long: `Unitdore manages container units via systemd. Long: `Unitdore manages container units via systemd.
It discovers running containers, stores them in a config file, It discovers running containers and podman-compose stacks, stores them in a
and generates + manages systemd .service units for each one.`, config file, and generates + manages systemd .service units for each one.
As root: system units, plus user units for users listed in 'users:'.
As a normal user: own config and user units only.`,
Version: version, Version: version,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Normal users get their own config and manage only their user units
if !cmd.Flags().Changed("config") && !sysuser.IsRoot() {
home, err := sysuser.HomeDir(sysuser.Current())
if err != nil {
return err
}
configPath = config.UserConfigPath(home)
}
return nil
},
} }
func Execute() { func Execute() {
@@ -29,5 +45,5 @@ func Execute() {
} }
func init() { func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "/etc/unitdore/units.yaml", "path to units config file") rootCmd.PersistentFlags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to units config file (default for non-root: ~/.config/unitdore/units.yaml)")
} }
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"testing"
"github.com/spf13/cobra"
)
// TestStartArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for start.
func TestStartArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "start", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// TestStopArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for stop.
func TestStopArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "stop", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// exactArgsOne requires exactly one positional argument. Mirrors cobra.ExactArgs(1).
func exactArgsOne(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("start/stop requires exactly 1 argument")
}
return nil
}
+4 -4
View File
@@ -11,9 +11,9 @@ import (
var startallCmd = &cobra.Command{ var startallCmd = &cobra.Command{
Use: "startall", Use: "startall",
Short: "Enable and start all installed, enabled units", Short: "Start all installed, enabled units",
Long: `Startall runs 'systemctl enable --now' for all enabled units that have Long: `Startall runs 'systemctl start' for all enabled units that have been installed.
been installed. Units must be installed first via 'unitdore install'.`, Units must be installed first via 'unitdore installall'.`,
RunE: runStartall, RunE: runStartall,
} }
@@ -50,7 +50,7 @@ func runStartall(cmd *cobra.Command, args []string) error {
continue continue
} }
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Enable(u, prefix, suffix); err != nil { if err := systemd.Start(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+1 -1
View File
@@ -76,7 +76,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
rows = append(rows, statusRow{ rows = append(rows, statusRow{
Name: u.Name, Name: u.Name,
Runtime: u.Runtime, Runtime: runtimeLabel(u),
User: user, User: user,
Enabled: enabled, Enabled: enabled,
Installed: installed, Installed: installed,
+3 -3
View File
@@ -11,8 +11,8 @@ import (
var stopallCmd = &cobra.Command{ var stopallCmd = &cobra.Command{
Use: "stopall", Use: "stopall",
Short: "Stop and disable all running managed units", Short: "Stop all installed units",
Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`, Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`,
RunE: runStopall, RunE: runStopall,
} }
@@ -46,7 +46,7 @@ func runStopall(cmd *cobra.Command, args []string) error {
continue continue
} }
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Disable(u, prefix, suffix); err != nil { if err := systemd.Stop(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+233 -56
View File
@@ -2,11 +2,15 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime" "github.com/warkanum/unitdore/runtime"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/sysuser"
) )
var syncupCmd = &cobra.Command{ var syncupCmd = &cobra.Command{
@@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{
Long: `Syncup queries all available runtimes for running containers and adds Long: `Syncup queries all available runtimes for running containers and adds
any new ones to the config file. Existing entries are never removed. any new ones to the config file. Existing entries are never removed.
It also reconciles: if a configured unit's container no longer exists, Podman is queried first. Docker is only a fallback: a container name found
it is marked disabled with a reason.`, in podman is never registered as docker.
Containers created by podman-compose are grouped into one stack unit per
project. Existing per-container units of stack members are disabled.
Scopes: as root, root's containers plus rootless containers of each user in
'users:' (podman only). As a normal user, the user's own podman containers.
It also reconciles: if a configured unit's container or stack no longer
exists, it is marked disabled with a reason.`,
RunE: runSyncup, RunE: runSyncup,
} }
@@ -24,71 +37,235 @@ func init() {
rootCmd.AddCommand(syncupCmd) rootCmd.AddCommand(syncupCmd)
} }
const (
reasonNotFound = "container not found"
reasonStackNotFound = "stack not found"
reasonMemberPrefix = "part of stack "
)
type syncStats struct {
added, unchanged, disabled, reenabled int
}
func runSyncup(cmd *cobra.Command, args []string) error { func runSyncup(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadOrEmpty(configPath) cfg, err := config.LoadOrEmpty(configPath)
if err != nil { if err != nil {
return err return err
} }
added := 0 containers := discover(syncScopes(cfg))
unchanged := 0
discovered := map[string]bool{}
// Discover running containers across all runtimes var st syncStats
for _, rt := range runtime.Available() { running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st)
containers, err := rt.ListRunning() reconcile(cfg, running, unitExists, os.Stdout, &st)
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++
} else {
unchanged++
}
}
}
// 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 { if err := cfg.Save(configPath); err != nil {
return err return err
} }
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", added, unchanged, disabled, reenabled) fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", st.added, st.unchanged, st.disabled, st.reenabled)
return nil return nil
} }
// syncScopes returns the user scopes to scan ("" = root/system).
func syncScopes(cfg *config.Config) []string {
if sysuser.IsRoot() {
return append([]string{""}, cfg.Users...)
}
return []string{sysuser.Current()}
}
// discover lists running containers in every scope, podman first.
func discover(scopes []string) []runtime.Container {
var all []runtime.Container
for _, user := range scopes {
for _, rt := range runtime.Available() {
// Docker is a system daemon; user scopes only see rootless podman
if user != "" && rt.Name() == "docker" {
continue
}
containers, err := rt.ListRunning(user)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s discovery failed (%s): %v\n", rt.Name(), scopeName(user), err)
continue
}
all = append(all, containers...)
}
}
return all
}
func composeBinFor(user string) string {
return sysuser.LookPath(user, "podman-compose")
}
func scopeName(user string) string {
if user == "" {
return "root"
}
return user
}
func scopeKey(user, name string) string { return user + "/" + name }
// mergeDiscovered adds discovered containers and stacks to cfg. Returns the
// set of running scopeKey(user, name) for containers and stack projects.
func mergeDiscovered(cfg *config.Config, containers []runtime.Container, composeBin func(user string) string, w io.Writer, st *syncStats) map[string]bool {
running := map[string]bool{}
podmanNames := map[string]bool{}
members := map[string]string{} // scopeKey(user, container) -> project
for _, c := range containers {
if c.Runtime == "podman" {
podmanNames[scopeKey(c.User, c.Name)] = true
}
}
// Group podman-compose containers into one stack unit per (user, project)
for _, c := range containers {
project := c.Project()
if c.Runtime != "podman" || project == "" {
continue
}
members[scopeKey(c.User, c.Name)] = project
key := scopeKey(c.User, project)
if running[key] {
continue
}
running[key] = true
stack := config.Unit{
Name: project,
Runtime: "podman",
User: c.User,
Kind: config.KindStack,
Project: project,
WorkingDir: c.WorkingDir(),
ComposeFiles: c.ComposeFiles(),
ComposeBin: composeBin(c.User),
Order: len(cfg.Units) + 1,
Enabled: true,
}
existing := cfg.FindUnit(project)
switch {
case existing == nil:
cfg.AddUnit(stack)
fmt.Fprintf(w, " + added stack: %s (%s, %s)\n", project, scopeName(c.User), strings.Join(stack.ComposeFiles, ","))
st.added++
case existing.IsStack() && existing.Project == project && existing.User == c.User:
// Labels are the source of truth for where the stack lives
existing.WorkingDir = stack.WorkingDir
existing.ComposeFiles = stack.ComposeFiles
if stack.ComposeBin != "" {
existing.ComposeBin = stack.ComposeBin
}
st.unchanged++
default:
fmt.Fprintf(os.Stderr, "warning: stack %s (%s) skipped: unit name already used\n", project, scopeName(c.User))
}
}
for _, c := range containers {
key := scopeKey(c.User, c.Name)
// Podman is queried first and wins on name clash (e.g. podman-docker shim)
if c.Runtime != "podman" && podmanNames[key] {
continue
}
running[key] = true
if _, ok := members[key]; ok {
continue
}
unit := config.Unit{
Name: c.Name,
Runtime: c.Runtime,
User: c.User,
Order: len(cfg.Units) + 1,
Enabled: true,
}
if cfg.AddUnit(unit) {
fmt.Fprintf(w, " + added: %s (%s, %s)\n", c.Name, c.Runtime, scopeName(c.User))
st.added++
continue
}
if existing := cfg.FindUnit(c.Name); existing.User != c.User {
fmt.Fprintf(os.Stderr, "warning: %s (%s) skipped: unit name already used by %s\n", c.Name, scopeName(c.User), scopeName(existing.User))
continue
}
st.unchanged++
}
for i := range cfg.Units {
u := &cfg.Units[i]
if u.IsStack() {
continue
}
key := scopeKey(u.User, u.Name)
if u.Runtime == "docker" && podmanNames[key] {
u.Runtime = "podman"
fmt.Fprintf(w, " ~ runtime: %s docker -> podman\n", u.Name)
}
if project, ok := members[key]; ok && u.DisabledReason != reasonMemberPrefix+project {
wasEnabled := u.Enabled
u.Enabled = false
u.DisabledReason = reasonMemberPrefix + project
if wasEnabled {
fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, u.DisabledReason)
st.disabled++
}
}
}
return running
}
// unitExists checks whether a unit's container or stack still exists.
func unitExists(u config.Unit) (bool, error) {
if u.IsStack() {
if systemd.ComposeAvailable(u) {
return true, nil
}
return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project))
}
rt := runtime.Get(u.Runtime)
if rt == nil {
return true, nil // unknown runtime — leave alone
}
return rt.Exists(u.User, u.Name)
}
// reconcile disables units whose container/stack is gone and re-enables
// units that were auto-disabled once it is back.
func reconcile(cfg *config.Config, running map[string]bool, exists func(config.Unit) (bool, error), w io.Writer, st *syncStats) {
for i := range cfg.Units {
u := &cfg.Units[i]
if strings.HasPrefix(u.DisabledReason, reasonMemberPrefix) {
continue
}
reason := reasonNotFound
if u.IsStack() {
reason = reasonStackNotFound
}
found := running[scopeKey(u.User, u.Name)]
if !found {
var err error
found, err = exists(*u)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err)
continue
}
}
if !found && u.Enabled {
u.Enabled = false
u.DisabledReason = reason
fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, reason)
st.disabled++
} else if found && !u.Enabled && u.DisabledReason == reason {
u.Enabled = true
u.DisabledReason = ""
fmt.Fprintf(w, " ✓ re-enabled: %s (found)\n", u.Name)
st.reenabled++
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package cmd
import (
"io"
"testing"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime"
)
func stackContainer(user, name, project string) runtime.Container {
return runtime.Container{
Name: name,
Runtime: "podman",
User: user,
Labels: map[string]string{
runtime.LabelPodmanComposeProject: project,
runtime.LabelComposeWorkingDir: "/srv/" + project,
runtime.LabelComposeConfigFiles: "compose.yaml",
},
}
}
func noBin(string) string { return "" }
func merge(cfg *config.Config, cs []runtime.Container) (map[string]bool, syncStats) {
var st syncStats
running := mergeDiscovered(cfg, cs, noBin, io.Discard, &st)
return running, st
}
func TestMerge_GroupsStack(t *testing.T) {
cfg := &config.Config{}
_, st := merge(cfg, []runtime.Container{
stackContainer("", "shop_web_1", "shop"),
stackContainer("", "shop_db_1", "shop"),
{Name: "redis", Runtime: "podman"},
})
if len(cfg.Units) != 2 || st.added != 2 {
t.Fatalf("expected 2 units (1 stack + 1 container), got %d: %+v", len(cfg.Units), cfg.Units)
}
s := cfg.FindUnit("shop")
if s == nil || !s.IsStack() {
t.Fatalf("stack unit shop missing: %+v", cfg.Units)
}
if s.Project != "shop" || s.WorkingDir != "/srv/shop" || len(s.ComposeFiles) != 1 || s.ComposeFiles[0] != "compose.yaml" {
t.Errorf("stack fields wrong: %+v", s)
}
if cfg.FindUnit("shop_web_1") != nil || cfg.FindUnit("shop_db_1") != nil {
t.Error("stack members must not get their own units")
}
if cfg.FindUnit("redis") == nil {
t.Error("plain container should be added")
}
}
func TestMerge_DisablesExistingMemberUnits(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "shop_web_1", Runtime: "podman", Enabled: true},
{Name: "shop_db_1", Runtime: "podman", Enabled: false, DisabledReason: reasonNotFound},
}}
running, st := merge(cfg, []runtime.Container{
stackContainer("", "shop_web_1", "shop"),
stackContainer("", "shop_db_1", "shop"),
})
for _, n := range []string{"shop_web_1", "shop_db_1"} {
u := cfg.FindUnit(n)
if u.Enabled || u.DisabledReason != "part of stack shop" {
t.Errorf("%s: enabled=%v reason=%q, want disabled as stack member", n, u.Enabled, u.DisabledReason)
}
}
if st.disabled != 1 {
t.Errorf("disabled = %d, want 1 (only the previously enabled unit)", st.disabled)
}
// Reconcile must not re-enable member units
reconcile(cfg, running, func(config.Unit) (bool, error) { return true, nil }, io.Discard, &st)
if cfg.FindUnit("shop_db_1").Enabled {
t.Error("reconcile re-enabled a stack member unit")
}
}
func TestMerge_PodmanWinsOverDocker(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "old", Runtime: "docker", Enabled: true},
}}
merge(cfg, []runtime.Container{
{Name: "web", Runtime: "podman"},
{Name: "web", Runtime: "docker"},
{Name: "old", Runtime: "podman"},
{Name: "only-docker", Runtime: "docker"},
})
if u := cfg.FindUnit("web"); u == nil || u.Runtime != "podman" {
t.Errorf("web should be podman: %+v", u)
}
if u := cfg.FindUnit("old"); u.Runtime != "podman" {
t.Errorf("existing docker unit found in podman should switch to podman, got %s", u.Runtime)
}
if u := cfg.FindUnit("only-docker"); u == nil || u.Runtime != "docker" {
t.Errorf("docker-only container should be added as docker: %+v", u)
}
}
func TestMerge_UserScope(t *testing.T) {
cfg := &config.Config{}
merge(cfg, []runtime.Container{
{Name: "app", Runtime: "podman", User: "hein"},
stackContainer("hein", "blog_web_1", "blog"),
})
if u := cfg.FindUnit("app"); u == nil || u.User != "hein" {
t.Errorf("user container should keep its user: %+v", u)
}
if u := cfg.FindUnit("blog"); u == nil || u.User != "hein" || !u.IsStack() {
t.Errorf("user stack should keep its user: %+v", u)
}
}
func TestMerge_NameClashAcrossUsers(t *testing.T) {
cfg := &config.Config{}
_, st := merge(cfg, []runtime.Container{
{Name: "app", Runtime: "podman"},
{Name: "app", Runtime: "podman", User: "hein"},
})
if len(cfg.Units) != 1 || cfg.Units[0].User != "" {
t.Errorf("first unit should win on name clash: %+v", cfg.Units)
}
if st.unchanged != 0 {
t.Errorf("clash should not count as unchanged, got %d", st.unchanged)
}
}
func TestMerge_RefreshesExistingStack(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop", WorkingDir: "/old", Enabled: true},
}}
_, st := merge(cfg, []runtime.Container{stackContainer("", "shop_web_1", "shop")})
if u := cfg.FindUnit("shop"); u.WorkingDir != "/srv/shop" {
t.Errorf("working_dir not refreshed from labels: %q", u.WorkingDir)
}
if st.added != 0 || st.unchanged != 1 {
t.Errorf("added=%d unchanged=%d, want 0/1", st.added, st.unchanged)
}
}
func TestReconcile_Stack(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "gone", Kind: config.KindStack, Project: "gone", Runtime: "podman", Enabled: true},
{Name: "back", Kind: config.KindStack, Project: "back", Runtime: "podman", Enabled: false, DisabledReason: reasonStackNotFound},
{Name: "manual", Kind: config.KindStack, Project: "manual", Runtime: "podman", Enabled: false, DisabledReason: "by hand"},
}}
exists := func(u config.Unit) (bool, error) { return u.Name != "gone", nil }
var st syncStats
reconcile(cfg, map[string]bool{}, exists, io.Discard, &st)
if u := cfg.FindUnit("gone"); u.Enabled || u.DisabledReason != reasonStackNotFound {
t.Errorf("gone: enabled=%v reason=%q", u.Enabled, u.DisabledReason)
}
if u := cfg.FindUnit("back"); !u.Enabled {
t.Error("back should be re-enabled")
}
if u := cfg.FindUnit("manual"); u.Enabled {
t.Error("manually disabled unit must stay disabled")
}
if st.disabled != 1 || st.reenabled != 1 {
t.Errorf("disabled=%d reenabled=%d, want 1/1", st.disabled, st.reenabled)
}
}
func TestReconcile_RunningSkipsExistsCheck(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "app", Runtime: "podman", User: "hein", Enabled: true},
}}
called := false
exists := func(config.Unit) (bool, error) { called = true; return false, nil }
var st syncStats
reconcile(cfg, map[string]bool{scopeKey("hein", "app"): true}, exists, io.Discard, &st)
if called || !cfg.Units[0].Enabled {
t.Errorf("running unit should not be checked or disabled (called=%v)", called)
}
}
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var uninstallallCmd = &cobra.Command{
Use: "uninstallall",
Short: "Stop, disable, and remove service files for all installed units",
Long: `Uninstallall runs 'systemctl disable --now' and removes the .service file for every installed unit, in reverse startup order.`,
RunE: runUninstallall,
}
func init() {
rootCmd.AddCommand(uninstallallCmd)
}
func runUninstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order > units[j].Order
}
return units[i].Name > units[j].Name
})
removed := 0
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
continue
}
svcName := systemd.ServiceName(u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
failed++
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
failed++
continue
}
fmt.Printf(" ✓ uninstalled: %s\n", u.Name)
removed++
}
fmt.Printf("\nDone. Uninstalled: %d Failed: %d\n", removed, failed)
return nil
}
+25 -3
View File
@@ -10,6 +10,17 @@ import (
const DefaultConfigPath = "/etc/unitdore/units.yaml" const DefaultConfigPath = "/etc/unitdore/units.yaml"
// UserConfigPath returns the config path used when running as a normal user.
func UserConfigPath(home string) string {
return filepath.Join(home, ".config", "unitdore", "units.yaml")
}
// Unit kinds.
const (
KindContainer = "container"
KindStack = "stack"
)
// Unit represents a single managed container unit. // Unit represents a single managed container unit.
type Unit struct { type Unit struct {
Name string `yaml:"name"` Name string `yaml:"name"`
@@ -23,13 +34,24 @@ type Unit struct {
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts
RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst) RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst)
// Stack fields (kind: stack) — one unit for a whole podman-compose project.
Kind string `yaml:"kind,omitempty"` // container (default) | stack
Project string `yaml:"project,omitempty"` // compose project name
WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory
ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute
ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose
} }
// IsStack reports whether the unit manages a compose stack.
func (u Unit) IsStack() bool { return u.Kind == KindStack }
// Config is the root config structure. // Config is the root config structure.
type Config struct { type Config struct {
Units []Unit `yaml:"units"` Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-" Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc" Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
} }
// Load reads and parses the config file at path. // Load reads and parses the config file at path.
+39
View File
@@ -144,3 +144,42 @@ func TestDisabledReason_Roundtrip(t *testing.T) {
t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason) t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason)
} }
} }
func TestSaveAndLoad_Stack(t *testing.T) {
path := filepath.Join(t.TempDir(), "units.yaml")
cfg := &Config{
Users: []string{"hein"},
Units: []Unit{{
Name: "shop", Runtime: "podman", Kind: KindStack, Project: "shop",
WorkingDir: "/srv/shop", ComposeFiles: []string{"compose.yaml"},
ComposeBin: "/usr/bin/podman-compose", User: "hein", 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)
}
u := loaded.Units[0]
if !u.IsStack() || u.Project != "shop" || u.WorkingDir != "/srv/shop" ||
len(u.ComposeFiles) != 1 || u.ComposeBin != "/usr/bin/podman-compose" {
t.Errorf("stack fields not round-tripped: %+v", u)
}
if len(loaded.Users) != 1 || loaded.Users[0] != "hein" {
t.Errorf("users not round-tripped: %v", loaded.Users)
}
}
func TestIsStack_DefaultKind(t *testing.T) {
if (Unit{Name: "x"}).IsStack() {
t.Error("unit without kind must be a container")
}
}
func TestUserConfigPath(t *testing.T) {
if got := UserConfigPath("/home/hein"); got != "/home/hein/.config/unitdore/units.yaml" {
t.Errorf("UserConfigPath() = %q", got)
}
}
+53
View File
@@ -277,6 +277,59 @@ units:
--- ---
## Stack Unit (podman-compose, compose mode)
Used when `compose_bin`, `working_dir` and all `compose_files` exist.
```yaml
- name: shop
kind: stack
runtime: podman
project: shop
working_dir: /srv/shop
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
enabled: true
```
```ini
# /etc/systemd/system/unitdore-shop.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: shop (podman-compose stack)
After=network.target
[Service]
Type=simple
WorkingDirectory=/srv/shop
ExecStart=/usr/bin/podman-compose -p shop -f compose.yaml up
ExecStop=-/usr/bin/podman-compose -p shop -f compose.yaml stop
[Install]
WantedBy=multi-user.target
```
---
## Stack Unit (pod fallback)
Used when the compose binary, working dir or a compose file is missing. Requires pod `pod_<project>`.
```ini
[Unit]
Description=Unitdore: shop (podman pod)
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/podman pod start pod_shop
ExecStop=-/usr/bin/podman pod stop pod_shop
```
---
## Quick Reference: Service File Locations ## Quick Reference: Service File Locations
| Scenario | Path | | Scenario | Path |
+29 -4
View File
@@ -9,18 +9,26 @@ unitdore \- manage container units via systemd
.SH DESCRIPTION .SH DESCRIPTION
.B unitdore .B unitdore
bridges your container runtime (Podman, Docker) and systemd. bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates It discovers running containers and podman\-compose stacks, stores them in a
and manages systemd \fI.service\fR units for each one. config file, and generates and manages systemd \fI.service\fR units for each one.
.PP
As root: system units, plus user units for users listed in \fBusers:\fR.
As a normal user: own config (\fI~/.config/unitdore/units.yaml\fR) and user units only.
.SH OPTIONS .SH OPTIONS
.TP .TP
.BR \-\-config " " \fIpath\fR .BR \-\-config " " \fIpath\fR
Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR
(non\-root: \fI~/.config/unitdore/units.yaml\fR)
.SH COMMANDS .SH COMMANDS
.TP .TP
.B syncup .B syncup
Query all available runtimes for running containers and add any new ones to Query all available runtimes for running containers and add any new ones to
the config. Reconciles existing entries: if a configured unit's container no the config. Reconciles existing entries: if a configured unit's container no
longer exists it is marked disabled with a reason. longer exists it is marked disabled with a reason.
Podman is queried first; Docker only for names Podman does not have.
podman\-compose containers become one \fBkind: stack\fR unit per project;
existing member units are disabled. As root, rootless Podman containers of
each user in \fBusers:\fR are also discovered.
.TP .TP
.B install .B install
Generate \fI.service\fR files for all enabled units and write them to the Generate \fI.service\fR files for all enabled units and write them to the
@@ -74,7 +82,7 @@ Open the units config file in \fB$EDITOR\fR. Falls back to \fBnvim\fR,
.nf .nf
prefix: "" # prepended to all service names, e.g. "prod-" prefix: "" # prepended to all service names, e.g. "prod-"
suffix: "" # appended to all service names, e.g. "-svc" suffix: "" # appended to all service names, e.g. "-svc"
service_user: "" # User= in [Service]; defaults to current OS user users: [] # root only: rootless users scanned by syncup
units: units:
- name: nginx - name: nginx
@@ -85,7 +93,19 @@ units:
delay: 0s # delay after previous order group delay: 0s # delay after previous order group
enabled: true enabled: true
disabled_reason: "" # auto-set by syncup reconciliation disabled_reason: "" # auto-set by syncup reconciliation
- name: shop # compose project name
kind: stack # container (default) | stack
runtime: podman
project: shop
working_dir: /srv/shop
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
.fi .fi
.PP
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
pod_\fR\fIproject\fR.
.SH GENERATED SERVICE FILES .SH GENERATED SERVICE FILES
System units are written to \fI/etc/systemd/system/\fR and user units to System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR. \fI~/.config/systemd/user/\fR.
@@ -127,13 +147,18 @@ sudo unitdore update nginx \-\-order 2
.I /etc/unitdore/units.yaml .I /etc/unitdore/units.yaml
Default configuration file. Default configuration file.
.TP .TP
.I ~/.config/unitdore/units.yaml
Configuration file when run as a normal user.
.TP
.I /etc/systemd/system/unitdore-*.service .I /etc/systemd/system/unitdore-*.service
Generated system unit files. Generated system unit files.
.TP .TP
.I ~/.config/systemd/user/unitdore-*.service .I ~/.config/systemd/user/unitdore-*.service
Generated rootless user unit files. Generated rootless user unit files.
.SH REQUIREMENTS .SH REQUIREMENTS
systemd; Podman and/or Docker (only installed runtimes are used). systemd; Podman and/or Docker (only installed runtimes are used);
podman\-compose for compose\-mode stacks.
User units need linger to start at boot: \fBloginctl enable\-linger\fR \fIuser\fR.
Root is required for system units; the relevant user for rootless units. Root is required for system units; the relevant user for rootless units.
.SH SEE ALSO .SH SEE ALSO
.BR systemctl (1), .BR systemctl (1),
+5 -5
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore pkgname=unitdore
pkgver=0.0.8 pkgver=0.0.11
pkgrel=1 pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd" pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
@@ -12,11 +12,11 @@ optdepends=(
'docker: Docker container runtime support' 'docker: Docker container runtime support'
) )
makedepends=('go') makedepends=('go')
source=("$pkgname-$pkgver.zip::$url/archive/v$pkgver.zip") source=("$pkgname-$pkgver.zip::$url/releases/download/v$pkgver/$pkgname-v$pkgver-source.zip")
sha256sums=('SKIP') sha256sums=('SKIP')
build() { build() {
cd "$pkgname" cd "$pkgname-$pkgver"
export CGO_ENABLED=0 export CGO_ENABLED=0
go build \ go build \
-trimpath \ -trimpath \
@@ -25,12 +25,12 @@ build() {
} }
check() { check() {
cd "$pkgname" cd "$pkgname-$pkgver"
go test ./... go test ./...
} }
package() { package() {
cd "$pkgname" cd "$pkgname-$pkgver"
# Binary # Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname" install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
+1 -1
View File
@@ -1,5 +1,5 @@
Name: unitdore Name: unitdore
Version: 0.0.8 Version: 0.0.11
Release: 1%{?dist} Release: 1%{?dist}
Summary: Manage container units via systemd Summary: Manage container units via systemd
+59 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/warkanum/unitdore/sysuser"
) )
type Docker struct{} type Docker struct{}
@@ -17,21 +19,44 @@ type dockerContainer struct {
Image string `json:"Image"` Image string `json:"Image"`
Command string `json:"Command"` Command string `json:"Command"`
State string `json:"State"` State string `json:"State"`
Labels string `json:"Labels"`
} }
func (d *Docker) ListRunning() ([]Container, error) { // dockerCmd builds a docker command for the given user scope.
// Returns nil if docker is not installed.
func dockerCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("docker") bin, err := exec.LookPath("docker")
if err != nil { if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (d *Docker) ListRunning(user string) ([]Container, error) {
cmd, err := dockerCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // docker not installed — not an error return nil, nil // docker not installed — not an error
} }
out, err := exec.Command(bin, "ps", "--format", "json").Output() out, err := cmd.Output()
if err != nil { if err != nil {
// Docker installed but daemon not running — treat as no containers // Docker installed but daemon not running — treat as no containers
return nil, nil return nil, nil
} }
// Docker outputs one JSON object per line (not a JSON array) containers := parseDockerPS(out)
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parseDockerPS parses `docker ps --format json` output
// (one JSON object per line, not a JSON array).
func parseDockerPS(out []byte) []Container {
var containers []Container var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" { if line == "" {
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
Image: c.Image, Image: c.Image,
Command: c.Command, Command: c.Command,
Runtime: "docker", Runtime: "docker",
Labels: parseDockerLabels(c.Labels),
}) })
} }
return containers, nil return containers
} }
func (d *Docker) Exists(name string) (bool, error) { // parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
bin, err := exec.LookPath("docker") // "=" is a continuation of the previous value (values may contain commas,
// e.g. multiple compose config files).
func parseDockerLabels(s string) map[string]string {
labels := map[string]string{}
last := ""
for _, part := range strings.Split(s, ",") {
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
if last != "" {
labels[last] += "," + part
}
continue
}
labels[k] = v
last = k
}
return labels
}
func (d *Docker) Exists(user, name string) (bool, error) {
cmd, err := dockerCmd(user, "ps", "--format", "{{.Names}}")
if err != nil { if err != nil {
return false, err
}
if cmd == nil {
return false, nil return false, nil
} }
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output() out, err := cmd.Output()
if err != nil { if err != nil {
return false, fmt.Errorf("docker ps: %w", err) return false, fmt.Errorf("docker ps: %w", err)
} }
+65 -12
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/warkanum/unitdore/sysuser"
) )
type Podman struct{} type Podman struct{}
@@ -12,30 +14,56 @@ type Podman struct{}
func (p *Podman) Name() string { return "podman" } func (p *Podman) Name() string { return "podman" }
type podmanContainer struct { type podmanContainer struct {
ID string `json:"Id"` ID string `json:"Id"`
Names []string `json:"Names"` Names []string `json:"Names"`
Image string `json:"Image"` Image string `json:"Image"`
Command []string `json:"Command"` Command []string `json:"Command"`
State string `json:"State"` State string `json:"State"`
Labels map[string]string `json:"Labels"`
} }
func (p *Podman) ListRunning() ([]Container, error) { // podmanCmd builds a podman command for the given user scope.
// Returns nil if podman is not installed.
func podmanCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("podman") bin, err := exec.LookPath("podman")
if err != nil { if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (p *Podman) ListRunning(user string) ([]Container, error) {
cmd, err := podmanCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // podman not installed — not an error return nil, nil // podman not installed — not an error
} }
out, err := exec.Command(bin, "ps", "--format", "json").Output() out, err := cmd.Output()
if err != nil { if err != nil {
// Podman installed but not usable — treat as no containers // Podman installed but not usable — treat as no containers
return nil, nil return nil, nil
} }
var raw []podmanContainer containers, err := parsePodmanPS(out)
if err := json.Unmarshal(out, &raw); err != nil { if err != nil {
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured) // Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil return nil, nil
} }
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parsePodmanPS parses `podman ps --format json` output (a JSON array).
func parsePodmanPS(out []byte) ([]Container, error) {
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
return nil, err
}
var containers []Container var containers []Container
for _, c := range raw { for _, c := range raw {
@@ -58,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
Image: c.Image, Image: c.Image,
Command: strings.Join(c.Command, " "), Command: strings.Join(c.Command, " "),
Runtime: "podman", Runtime: "podman",
Labels: c.Labels,
}) })
} }
return containers, nil return containers, nil
} }
func (p *Podman) Exists(name string) (bool, error) { func (p *Podman) Exists(user, name string) (bool, error) {
bin, err := exec.LookPath("podman") cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
if err != nil { if err != nil {
return false, err
}
if cmd == nil {
return false, nil return false, nil
} }
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output() out, err := cmd.Output()
if err != nil { if err != nil {
return false, fmt.Errorf("podman ps: %w", err) return false, fmt.Errorf("podman ps: %w", err)
} }
@@ -81,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
} }
return false, nil return false, nil
} }
// PodExists reports whether a pod with the given name exists (any state).
func (p *Podman) PodExists(user, pod string) (bool, error) {
cmd, err := podmanCmd(user, "pod", "exists", pod)
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return false, nil // exit 1 = pod does not exist
}
return false, fmt.Errorf("podman pod exists: %w", err)
}
return true, nil
}
// PodName returns the pod name podman-compose uses for a project.
func PodName(project string) string { return "pod_" + project }
+45 -3
View File
@@ -1,21 +1,63 @@
package runtime package runtime
import (
"path/filepath"
"strings"
)
// Compose labels set by podman-compose on every container it creates.
const (
LabelPodmanComposeProject = "io.podman.compose.project"
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
)
// Container represents a discovered running container. // Container represents a discovered running container.
type Container struct { type Container struct {
Name string Name string
Image string Image string
Command string Command string
Runtime string // "podman" or "docker" Runtime string // "podman" or "docker"
User string // empty = root/system scope
Labels map[string]string
}
// Project returns the podman-compose project the container belongs to, or "".
func (c Container) Project() string {
return c.Labels[LabelPodmanComposeProject]
}
// WorkingDir returns the compose project working directory, or "".
func (c Container) WorkingDir() string {
return c.Labels[LabelComposeWorkingDir]
}
// ComposeFiles returns the compose files used to create the container.
// Relative paths are relative to WorkingDir.
func (c Container) ComposeFiles() []string {
raw := c.Labels[LabelComposeConfigFiles]
if raw == "" {
return nil
}
var files []string
for _, f := range strings.Split(raw, ",") {
if f = strings.TrimSpace(f); f != "" {
files = append(files, filepath.Clean(f))
}
}
return files
} }
// Runtime is the interface all container runtimes must implement. // Runtime is the interface all container runtimes must implement.
// user selects the scope: "" = root/system, otherwise a rootless user.
type Runtime interface { type Runtime interface {
Name() string Name() string
ListRunning() ([]Container, error) ListRunning(user string) ([]Container, error)
Exists(name string) (bool, error) Exists(user, name string) (bool, error)
} }
// Available returns all supported runtimes (callers skip those with no binary). // Available returns all supported runtimes in priority order: podman first,
// docker as fallback (callers skip those with no binary).
func Available() []Runtime { func Available() []Runtime {
return []Runtime{&Podman{}, &Docker{}} return []Runtime{&Podman{}, &Docker{}}
} }
+80 -2
View File
@@ -1,6 +1,8 @@
package runtime package runtime
import ( import (
"os"
"reflect"
"testing" "testing"
) )
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
func TestPodman_ListRunning_NoBinary(t *testing.T) { 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 // This test is meaningful if podman isn't installed; if it is, we just check no error occurs
p := &Podman{} p := &Podman{}
containers, err := p.ListRunning() containers, err := p.ListRunning("")
if err != nil { if err != nil {
t.Errorf("ListRunning() should not error when podman is absent: %v", err) t.Errorf("ListRunning() should not error when podman is absent: %v", err)
} }
@@ -63,9 +65,85 @@ func TestPodman_ListRunning_NoBinary(t *testing.T) {
func TestDocker_ListRunning_NoBinary(t *testing.T) { func TestDocker_ListRunning_NoBinary(t *testing.T) {
d := &Docker{} d := &Docker{}
containers, err := d.ListRunning() containers, err := d.ListRunning("")
if err != nil { if err != nil {
t.Errorf("ListRunning() should not error when docker is absent: %v", err) t.Errorf("ListRunning() should not error when docker is absent: %v", err)
} }
_ = containers _ = containers
} }
func TestAvailable_PodmanFirst(t *testing.T) {
runtimes := Available()
if len(runtimes) < 2 || runtimes[0].Name() != "podman" || runtimes[1].Name() != "docker" {
t.Errorf("Available() order must be podman, docker; got %v", runtimes)
}
}
// Fixture captured from `podman-compose up -d` of testdata/stack/compose.yaml.
func TestParsePodmanPS_ComposeStack(t *testing.T) {
data, err := os.ReadFile("../testdata/stack/podman_ps.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
containers, err := parsePodmanPS(data)
if err != nil {
t.Fatalf("parsePodmanPS() error: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
for _, c := range containers {
if c.Project() != "unitdore-test" {
t.Errorf("%s: Project() = %q, want unitdore-test", c.Name, c.Project())
}
if c.WorkingDir() == "" {
t.Errorf("%s: WorkingDir() empty", c.Name)
}
if !reflect.DeepEqual(c.ComposeFiles(), []string{"compose.yaml"}) {
t.Errorf("%s: ComposeFiles() = %v", c.Name, c.ComposeFiles())
}
}
}
func TestContainer_NoProject(t *testing.T) {
c := Container{Name: "plain"}
if c.Project() != "" || c.WorkingDir() != "" || c.ComposeFiles() != nil {
t.Errorf("container without labels should have no stack info: %+v", c)
}
}
func TestContainer_ComposeFilesMultiple(t *testing.T) {
c := Container{Labels: map[string]string{LabelComposeConfigFiles: "compose.yaml, ./override.yaml"}}
want := []string{"compose.yaml", "override.yaml"}
if got := c.ComposeFiles(); !reflect.DeepEqual(got, want) {
t.Errorf("ComposeFiles() = %v, want %v", got, want)
}
}
func TestParseDockerPS_Labels(t *testing.T) {
out := []byte(`{"ID":"abc","Names":"web","Image":"nginx","Command":"nginx","State":"running","Labels":"com.docker.compose.project=shop,com.docker.compose.project.config_files=/s/a.yml,/s/b.yml,com.docker.compose.service=web"}`)
cs := parseDockerPS(out)
if len(cs) != 1 {
t.Fatalf("expected 1 container, got %d", len(cs))
}
l := cs[0].Labels
if l["com.docker.compose.project"] != "shop" {
t.Errorf("project label = %q", l["com.docker.compose.project"])
}
if l["com.docker.compose.project.config_files"] != "/s/a.yml,/s/b.yml" {
t.Errorf("config_files label = %q", l["com.docker.compose.project.config_files"])
}
if l["com.docker.compose.service"] != "web" {
t.Errorf("service label = %q", l["com.docker.compose.service"])
}
// Docker compose stacks are not grouped yet (podman-compose only)
if cs[0].Project() != "" {
t.Errorf("docker compose container should not report a podman-compose project")
}
}
func TestPodName(t *testing.T) {
if PodName("shop") != "pod_shop" {
t.Errorf("PodName() = %q", PodName("shop"))
}
}
+102 -11
View File
@@ -2,6 +2,8 @@ package systemd
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"text/template" "text/template"
@@ -12,14 +14,20 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
# Generated by unitdore — do not edit manually # Generated by unitdore — do not edit manually
[Unit] [Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=network.target{{with .RuntimeService}} {{.}} After=network.target{{with .RuntimeService}} {{.}}
Requires={{.}}{{end}} Requires={{.}}{{end}}
[Service] [Service]
Type=simple Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec={{.Unit.RestartSec}} RestartSec={{.Unit.RestartSec}}
@@ -36,13 +44,19 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}}
# Generated by unitdore — do not edit manually # Generated by unitdore — do not edit manually
[Unit] [Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=default.target After=default.target
[Service] [Service]
Type=simple Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
Restart=on-failure Restart=on-failure
RestartSec={{.Unit.RestartSec}} RestartSec={{.Unit.RestartSec}}
@@ -61,6 +75,9 @@ type templateData struct {
ExecStart string ExecStart string
ExecStop string ExecStop string
RuntimeService string RuntimeService string
Label string // shown in Description
ServiceType string // simple | oneshot
WorkingDir string
} }
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix. // ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
@@ -70,14 +87,25 @@ func ServiceName(u config.Unit, prefix, suffix string) string {
// Generate produces the .service file content for a unit. // Generate produces the .service file content for a unit.
func Generate(u config.Unit, prefix, suffix string) (string, error) { func Generate(u config.Unit, prefix, suffix string) (string, error) {
execStart, execStop := buildExecCommands(u)
data := templateData{ data := templateData{
ServiceName: ServiceName(u, prefix, suffix), ServiceName: ServiceName(u, prefix, suffix),
Unit: u, Unit: u,
ExecStart: execStart,
ExecStop: execStop,
RuntimeService: runtimeService(u.Runtime), RuntimeService: runtimeService(u.Runtime),
Label: u.Runtime,
ServiceType: "simple",
}
switch {
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
data.ExecStart, data.ExecStop = buildComposeCommands(u)
data.Label = "podman-compose stack"
data.WorkingDir = u.WorkingDir
case u.IsStack() && u.Command == "":
data.ExecStart, data.ExecStop = buildPodCommands(u)
data.Label = "podman pod"
data.ServiceType = "oneshot"
default:
data.ExecStart, data.ExecStop = buildExecCommands(u)
} }
tmplStr := systemUnitTemplate tmplStr := systemUnitTemplate
@@ -117,7 +145,7 @@ func buildExecCommands(u config.Unit) (start, stop string) {
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name) start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name) stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker": case "docker":
start = fmt.Sprintf("/usr/bin/docker start %s", u.Name) start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name) stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default: default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name) start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
@@ -125,3 +153,66 @@ func buildExecCommands(u config.Unit) (start, stop string) {
} }
return start, stop return start, stop
} }
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
const (
StackModeCompose = "compose"
StackModePod = "pod"
)
// StackMode picks how a stack unit is started: compose when the compose binary,
// working dir and compose files all exist; otherwise pod start/stop.
func StackMode(u config.Unit) string {
if ComposeAvailable(u) {
return StackModeCompose
}
return StackModePod
}
// ComposeAvailable reports whether the stack can be driven by podman-compose.
func ComposeAvailable(u config.Unit) bool {
if u.ComposeBin == "" || u.WorkingDir == "" {
return false
}
if !exists(u.ComposeBin) || !exists(u.WorkingDir) {
return false
}
for _, f := range u.ComposeFiles {
if !filepath.IsAbs(f) {
f = filepath.Join(u.WorkingDir, f)
}
if !exists(f) {
return false
}
}
return true
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func buildComposeCommands(u config.Unit) (start, stop string) {
args := []string{quoteArg(u.ComposeBin), "-p", quoteArg(u.Project)}
for _, f := range u.ComposeFiles {
args = append(args, "-f", quoteArg(f))
}
base := strings.Join(args, " ")
return base + " up", base + " stop"
}
func buildPodCommands(u config.Unit) (start, stop string) {
pod := quoteArg("pod_" + u.Project)
return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod
}
// quoteArg double-quotes an argument for systemd command lines if it
// contains whitespace or quotes.
func quoteArg(s string) string {
if !strings.ContainsAny(s, " \t\"'\\") {
return s
}
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return `"` + r.Replace(s) + `"`
}
+134 -6
View File
@@ -1,6 +1,8 @@
package systemd package systemd
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -49,7 +51,7 @@ func TestGenerate_SystemUnit(t *testing.T) {
"After=network.target", "After=network.target",
"WantedBy=multi-user.target", "WantedBy=multi-user.target",
"ExecStart=/usr/bin/podman start -a nginx", "ExecStart=/usr/bin/podman start -a nginx",
"ExecStop=/usr/bin/podman stop nginx", "ExecStop=-/usr/bin/podman stop nginx",
"Generated by unitdore", "Generated by unitdore",
} }
@@ -87,8 +89,8 @@ func TestGenerate_UserUnit(t *testing.T) {
checks := []string{ checks := []string{
"After=default.target", "After=default.target",
"WantedBy=default.target", "WantedBy=default.target",
"ExecStart=/usr/bin/docker start myapp", "ExecStart=/usr/bin/docker start -a myapp",
"ExecStop=/usr/bin/docker stop myapp", "ExecStop=-/usr/bin/docker stop myapp",
} }
for _, check := range checks { for _, check := range checks {
@@ -178,8 +180,8 @@ func TestGenerate_DockerRuntime(t *testing.T) {
} }
checks := []string{ checks := []string{
"ExecStart=/usr/bin/docker start redis", "ExecStart=/usr/bin/docker start -a redis",
"ExecStop=/usr/bin/docker stop redis", "ExecStop=-/usr/bin/docker stop redis",
"After=network.target docker.service", "After=network.target docker.service",
"Requires=docker.service", "Requires=docker.service",
} }
@@ -240,7 +242,7 @@ func TestBuildExecCommands(t *testing.T) {
{ {
name: "docker", name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"}, unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start app", wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app", wantStop: "/usr/bin/docker stop app",
}, },
{ {
@@ -263,3 +265,129 @@ func TestBuildExecCommands(t *testing.T) {
}) })
} }
} }
// newComposeStack creates a stack unit whose compose binary, working dir and
// compose file exist on disk (compose mode).
func newComposeStack(t *testing.T) config.Unit {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "podman-compose")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil {
t.Fatal(err)
}
return config.Unit{
Name: "shop",
Runtime: "podman",
Kind: config.KindStack,
Project: "shop",
WorkingDir: dir,
ComposeFiles: []string{"compose.yaml"},
ComposeBin: bin,
Enabled: true,
}
}
func TestStackMode(t *testing.T) {
u := newComposeStack(t)
if StackMode(u) != StackModeCompose {
t.Errorf("StackMode() = %q, want compose", StackMode(u))
}
missingFile := u
missingFile.ComposeFiles = []string{"gone.yaml"}
if StackMode(missingFile) != StackModePod {
t.Error("missing compose file should fall back to pod mode")
}
missingBin := u
missingBin.ComposeBin = ""
if StackMode(missingBin) != StackModePod {
t.Error("missing compose binary should fall back to pod mode")
}
missingDir := u
missingDir.WorkingDir = "/nonexistent/unitdore-test"
if StackMode(missingDir) != StackModePod {
t.Error("missing working dir should fall back to pod mode")
}
}
func TestGenerate_StackCompose(t *testing.T) {
u := newComposeStack(t)
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
base := u.ComposeBin + " -p shop -f compose.yaml"
for _, want := range []string{
"Description=Unitdore: shop (podman-compose stack)",
"Type=simple",
"WorkingDirectory=" + u.WorkingDir,
"ExecStart=" + base + " up",
"ExecStop=-" + base + " stop",
"WantedBy=multi-user.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "RemainAfterExit") || strings.Contains(content, "Requires=") {
t.Errorf("compose stack unit has unexpected directives:\n%s", content)
}
}
func TestGenerate_StackPodFallback(t *testing.T) {
u := newComposeStack(t)
u.ComposeBin = ""
u.User = "hein"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, want := range []string{
"Description=Unitdore: shop (podman pod)",
"Type=oneshot",
"RemainAfterExit=yes",
"ExecStart=/usr/bin/podman pod start pod_shop",
"ExecStop=-/usr/bin/podman pod stop pod_shop",
"WantedBy=default.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "WorkingDirectory=") {
t.Errorf("pod fallback should not set WorkingDirectory:\n%s", content)
}
}
func TestGenerate_StackCustomCommand(t *testing.T) {
u := newComposeStack(t)
u.Command = "/usr/local/bin/stack.sh"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "ExecStart=/usr/local/bin/stack.sh") {
t.Errorf("custom command should override stack commands:\n%s", content)
}
}
func TestBuildComposeCommands_Quoting(t *testing.T) {
u := config.Unit{
Project: "shop",
ComposeBin: "/usr/bin/podman-compose",
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
}
start, stop := buildComposeCommands(u)
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
if start != wantBase+" up" {
t.Errorf("start = %q", start)
}
if stop != wantBase+" stop" {
t.Errorf("stop = %q", stop)
}
}
+11 -18
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
) )
const systemUnitDir = "/etc/systemd/system" const systemUnitDir = "/etc/systemd/system"
@@ -19,7 +20,7 @@ func UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(systemUnitDir, svcName), nil return filepath.Join(systemUnitDir, svcName), nil
} }
// Rootless: write to the user's systemd config dir // Rootless: write to the user's systemd config dir
home, err := userHomeDir(u.User) home, err := sysuser.HomeDir(u.User)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -130,27 +131,19 @@ func systemctl(user string, args ...string) error {
} }
func runAsUser(user string, bin string, args ...string) ([]byte, error) { func runAsUser(user string, bin string, args ...string) ([]byte, error) {
full := append([]string{"-u", user, "--", bin}, args...) cmd, err := sysuser.Command(user, bin, args...)
return exec.Command("runuser", full...).Output() if err != nil {
return nil, err
}
return cmd.Output()
} }
func runAsUserCmd(user string, bin string, args ...string) error { func runAsUserCmd(user string, bin string, args ...string) error {
full := append([]string{"-u", user, "--", bin}, args...) cmd, err := sysuser.Command(user, bin, args...)
cmd := exec.Command("runuser", full...) if err != nil {
return err
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }
func userHomeDir(user string) (string, error) {
out, err := exec.Command("getent", "passwd", user).Output()
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", user, err)
}
// passwd format: name:pass:uid:gid:gecos:home:shell
parts := strings.Split(strings.TrimSpace(string(out)), ":")
if len(parts) < 6 {
return "", fmt.Errorf("unexpected passwd entry for %s", user)
}
return parts[5], nil
}
+123
View File
@@ -0,0 +1,123 @@
package systemd
import (
"sort"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
)
// TestStart_BuildsCorrectExecCommand verifies that the start command builds the right exec string.
func TestStart_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStop_BuildsCorrectExecCommand verifies that the stop command builds the right exec string.
func TestStop_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like startall does (Order asc, Name asc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order < sorted[j].Order
}
return sorted[i].Name < sorted[j].Name
})
// Expected order: app1(1), app3(3), app2(5) by Order asc
if sorted[0].Name != "app1" {
t.Errorf("expected first unit to be app1 (order 1), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app2" {
t.Errorf("expected third unit to be app2 (order 5), got %s", sorted[2].Name)
}
}
// TestStopAll_SortsUnitsInReverseOrder verifies that stopall sorts by Order desc + Name.
func TestStopAll_SortsUnitsInReverseOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like stopall does (Order desc, Name desc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order > sorted[j].Order
}
return sorted[i].Name > sorted[j].Name
})
// Expected order: app2(5), app3(3), app1(1) by Order desc
if sorted[0].Name != "app2" {
t.Errorf("expected first unit to be app2 (order 5), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app1" {
t.Errorf("expected third unit to be app1 (order 1), got %s", sorted[2].Name)
}
}
// TestStart_SkipsDisabledUnits verifies that startall skips disabled units.
func TestStart_SkipsDisabledUnits(t *testing.T) {
u1 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u2 := config.Unit{Name: "app2", Runtime: "podman", Order: 2, Enabled: false}
units := []config.Unit{u1, u2}
enabledCount := 0
for _, u := range units {
if !u.Enabled {
continue // skip disabled like startall does
}
enabledCount++
}
if enabledCount != 1 {
t.Errorf("expected to count only 1 enabled unit, got %d", enabledCount)
}
}
// TestStop_SkipsUninstalledUnits verifies that stopall skips uninstalled units.
func TestStop_SkipsUninstalledUnits(t *testing.T) {
u := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
units := []config.Unit{u}
for _, unit := range units {
if !IsInstalled(unit, "", "") {
continue // skip uninstalled like stopall does
}
}
t.Log("uninstalled units are skipped (no service files exist in test env)")
}
+95
View File
@@ -0,0 +1,95 @@
// Package sysuser runs commands as the current process or as another user,
// and answers questions about local user accounts.
package sysuser
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
)
// IsRoot reports whether the process runs with euid 0.
func IsRoot() bool { return os.Geteuid() == 0 }
// Current returns the username of the effective user.
func Current() string {
u, err := user.LookupId(fmt.Sprint(os.Geteuid()))
if err != nil {
return ""
}
return u.Username
}
// IsSelf reports whether name is the effective user (or empty).
func IsSelf(name string) bool {
return name == "" || name == Current()
}
// HomeDir returns the home directory of the given user.
func HomeDir(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.HomeDir, nil
}
// UID returns the numeric uid of the given user as a string.
func UID(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.Uid, nil
}
// Command builds a command that runs bin as the given user.
// An empty user, or the current user, runs it directly. Otherwise runuser is
// used (requires root) with XDG_RUNTIME_DIR pointed at the user's runtime dir,
// which rootless podman and systemctl --user need.
func Command(name string, bin string, args ...string) (*exec.Cmd, error) {
if IsSelf(name) {
return exec.Command(bin, args...), nil
}
if !IsRoot() {
return nil, fmt.Errorf("running as user %s requires root", name)
}
uid, err := UID(name)
if err != nil {
return nil, err
}
full := append([]string{"-u", name, "--", "env",
"XDG_RUNTIME_DIR=/run/user/" + uid,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus",
bin}, args...)
return exec.Command("runuser", full...), nil
}
// LookPath finds bin in PATH, then in the user's ~/.local/bin.
// Returns "" if not found.
func LookPath(name string, bin string) string {
if p, err := exec.LookPath(bin); err == nil {
return p
}
if name == "" {
name = Current()
}
home, err := HomeDir(name)
if err != nil {
return ""
}
p := filepath.Join(home, ".local", "bin", bin)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p
}
return ""
}
// LingerEnabled reports whether systemd lingering is enabled for the user,
// so user units start at boot without a login session.
func LingerEnabled(name string) bool {
_, err := os.Stat(filepath.Join("/var/lib/systemd/linger", name))
return err == nil
}
+31
View File
@@ -0,0 +1,31 @@
package sysuser
import "testing"
func TestIsSelf(t *testing.T) {
if !IsSelf("") {
t.Error("empty user must be self")
}
if cur := Current(); cur != "" && !IsSelf(cur) {
t.Errorf("current user %q must be self", cur)
}
}
func TestCommand_Self(t *testing.T) {
cmd, err := Command(Current(), "true")
if err != nil {
t.Fatalf("Command() error: %v", err)
}
if cmd.Args[0] != "true" {
t.Errorf("self command should run directly, got %v", cmd.Args)
}
}
func TestCommand_OtherUserRequiresRoot(t *testing.T) {
if IsRoot() {
t.Skip("running as root")
}
if _, err := Command("root", "true"); err == nil {
t.Error("running as another user without root should error")
}
}
+15
View File
@@ -0,0 +1,15 @@
# Test stack for unitdore compose-stack detection.
# Run: podman-compose -p unitdore-test up -d
name: unitdore-test
services:
web:
image: docker.io/library/alpine:latest
init: true
command: ["sleep", "infinity"]
depends_on:
- db
db:
image: docker.io/library/alpine:latest
init: true
command: ["sleep", "infinity"]
+100
View File
@@ -0,0 +1,100 @@
[
{
"AutoRemove": false,
"Command": [
"sleep",
"infinity"
],
"CreatedAt": "6 seconds ago",
"CIDFile": "",
"Exited": false,
"ExitedAt": -62135596800,
"ExitCode": 0,
"ExposedPorts": null,
"Id": "6d5a1e4d8ab39d64b26842a0e50b5943cfa62d71c70d63ce5c2ad92f598811b9",
"Image": "docker.io/library/alpine:latest",
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
"IsInfra": false,
"Labels": {
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
"com.docker.compose.container-number": "1",
"com.docker.compose.project": "unitdore-test",
"com.docker.compose.project.config_files": "compose.yaml",
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
"com.docker.compose.service": "db",
"io.podman.compose.config-hash": "1a54723a4ead4531f2ddce5aa7e85019b4ebd1612ca8973b85cd9026f4f87949",
"io.podman.compose.project": "unitdore-test",
"io.podman.compose.service": "db",
"io.podman.compose.version": "1.6.0"
},
"Mounts": [],
"Names": [
"unitdore-test_db_1"
],
"Namespaces": {
},
"Networks": [
"unitdore-test_default"
],
"Pid": 75799,
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
"PodName": "",
"Ports": null,
"Restarts": 0,
"Size": null,
"StartedAt": 1791823934,
"State": "running",
"Status": "Up 6 seconds",
"Created": 1791823933
},
{
"AutoRemove": false,
"Command": [
"sleep",
"infinity"
],
"CreatedAt": "6 seconds ago",
"CIDFile": "",
"Exited": false,
"ExitedAt": -62135596800,
"ExitCode": 0,
"ExposedPorts": null,
"Id": "b6b364f2a885baa583b7112cd7d37832e7f11f9091698ef76ca4c44259e501cc",
"Image": "docker.io/library/alpine:latest",
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
"IsInfra": false,
"Labels": {
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
"com.docker.compose.container-number": "1",
"com.docker.compose.project": "unitdore-test",
"com.docker.compose.project.config_files": "compose.yaml",
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
"com.docker.compose.service": "web",
"io.podman.compose.config-hash": "f38934df837f52ead7f2848b58acef10380a7929125a7c4f61199ad5b557302f",
"io.podman.compose.project": "unitdore-test",
"io.podman.compose.service": "web",
"io.podman.compose.version": "1.6.0"
},
"Mounts": [],
"Names": [
"unitdore-test_web_1"
],
"Namespaces": {
},
"Networks": [
"unitdore-test_default"
],
"Pid": 75919,
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
"PodName": "",
"Ports": null,
"Restarts": 0,
"Size": null,
"StartedAt": 1791823934,
"State": "running",
"Status": "Up 6 seconds",
"Created": 1791823933
}
]