Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173b4b2523 | ||
|
|
cb4413b840 | ||
|
|
f0827fcdd7 | ||
|
|
cc4829046d | ||
|
|
13817f8d34 | ||
|
|
5026ae4d16 | ||
|
|
917480670f | ||
|
|
029b9cee2e | ||
|
|
e6ef6e11d6 | ||
|
|
f9dcb0b561 | ||
|
|
69069a2196 | ||
|
|
2efccc5d4f | ||
|
|
b58373ad2f | ||
|
|
a273232303 | ||
|
|
fae49a258c |
@@ -53,6 +53,13 @@ jobs:
|
||||
echo "Built $NAME"
|
||||
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
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
@@ -166,7 +173,7 @@ jobs:
|
||||
echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
|
||||
|
||||
# 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.
|
||||
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
|
||||
@@ -319,13 +326,13 @@ jobs:
|
||||
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}")
|
||||
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
|
||||
for f in pkg/centos/out/*.rpm; do
|
||||
while IFS= read -r f; do
|
||||
FNAME=$(basename "$f")
|
||||
echo "Uploading $FNAME..."
|
||||
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${f}" > /dev/null
|
||||
done
|
||||
done < <(find pkg/centos/out -name "*.rpm")
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
\_____________/
|
||||
\___________/
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
|
||||
## Non-Goals
|
||||
|
||||
- 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
|
||||
|
||||
---
|
||||
@@ -172,6 +173,8 @@ unitdore/
|
||||
| 5 | Reconcile (auto-disable missing units) in syncup |
|
||||
| 6 | User unit support (rootless containers) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> *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
|
||||
|
||||
@@ -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).
|
||||
|
||||
- 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
|
||||
|
||||
```bash
|
||||
@@ -104,6 +109,7 @@ oldthing podman hein no no — container
|
||||
```yaml
|
||||
prefix: "" # optional: prepended to all service names, e.g. "prod-"
|
||||
suffix: "" # optional: appended to all service names, e.g. "-svc"
|
||||
users: [hein] # root only: rootless users scanned by syncup
|
||||
|
||||
units:
|
||||
- name: nginx
|
||||
@@ -121,8 +127,36 @@ units:
|
||||
order: 2
|
||||
delay: 5s
|
||||
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
|
||||
|
||||
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)
|
||||
- Docker vs Podman
|
||||
- Custom commands
|
||||
- Compose stacks (compose + pod fallback)
|
||||
- Prefix/suffix naming
|
||||
- Startup ordering
|
||||
- Unnamed containers (short ID fallback)
|
||||
@@ -175,7 +210,7 @@ WantedBy=multi-user.target
|
||||
## 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
|
||||
@@ -183,4 +218,5 @@ WantedBy=multi-user.target
|
||||
- Go 1.21+
|
||||
- systemd
|
||||
- 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
installed := 0
|
||||
skipped := 0
|
||||
removed := 0
|
||||
lingerSeen := map[string]bool{}
|
||||
|
||||
for _, u := range cfg.Units {
|
||||
if !u.Enabled {
|
||||
@@ -73,6 +74,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
||||
fmt.Printf(" ✓ installed: %s\n", path)
|
||||
lingerHint(lingerSeen, u)
|
||||
installed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
+20
-4
@@ -5,9 +5,11 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warkanum/unitdore/config"
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
var version = "0.0.6"
|
||||
var version = "0.0.11"
|
||||
|
||||
var configPath string
|
||||
|
||||
@@ -16,9 +18,23 @@ var rootCmd = &cobra.Command{
|
||||
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.`,
|
||||
It discovers running containers and podman-compose stacks, stores them in a
|
||||
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,
|
||||
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() {
|
||||
@@ -29,5 +45,5 @@ func Execute() {
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -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
@@ -11,9 +11,9 @@ import (
|
||||
|
||||
var startallCmd = &cobra.Command{
|
||||
Use: "startall",
|
||||
Short: "Enable and start all installed, enabled units",
|
||||
Long: `Startall runs 'systemctl enable --now' for all enabled units that have
|
||||
been installed. Units must be installed first via 'unitdore install'.`,
|
||||
Short: "Start all installed, enabled units",
|
||||
Long: `Startall runs 'systemctl start' for all enabled units that have been installed.
|
||||
Units must be installed first via 'unitdore installall'.`,
|
||||
RunE: runStartall,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func runStartall(cmd *cobra.Command, args []string) error {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
failed++
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
rows = append(rows, statusRow{
|
||||
Name: u.Name,
|
||||
Runtime: u.Runtime,
|
||||
Runtime: runtimeLabel(u),
|
||||
User: user,
|
||||
Enabled: enabled,
|
||||
Installed: installed,
|
||||
|
||||
+3
-3
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
var stopallCmd = &cobra.Command{
|
||||
Use: "stopall",
|
||||
Short: "Stop and disable all running managed units",
|
||||
Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`,
|
||||
Short: "Stop all installed units",
|
||||
Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`,
|
||||
RunE: runStopall,
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func runStopall(cmd *cobra.Command, args []string) error {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
failed++
|
||||
} else {
|
||||
|
||||
+233
-56
@@ -2,11 +2,15 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warkanum/unitdore/config"
|
||||
"github.com/warkanum/unitdore/runtime"
|
||||
"github.com/warkanum/unitdore/systemd"
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
var syncupCmd = &cobra.Command{
|
||||
@@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{
|
||||
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.`,
|
||||
Podman is queried first. Docker is only a fallback: a container name found
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -24,71 +37,235 @@ func init() {
|
||||
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 {
|
||||
cfg, err := config.LoadOrEmpty(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
added := 0
|
||||
unchanged := 0
|
||||
discovered := map[string]bool{}
|
||||
containers := discover(syncScopes(cfg))
|
||||
|
||||
// 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++
|
||||
} 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++
|
||||
}
|
||||
}
|
||||
var st syncStats
|
||||
running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st)
|
||||
reconcile(cfg, running, unitExists, os.Stdout, &st)
|
||||
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+28
-3
@@ -10,6 +10,17 @@ import (
|
||||
|
||||
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.
|
||||
type Unit struct {
|
||||
Name string `yaml:"name"`
|
||||
@@ -20,13 +31,27 @@ type Unit struct {
|
||||
Delay string `yaml:"delay,omitempty"` // e.g. "5s"
|
||||
Enabled bool `yaml:"enabled"`
|
||||
DisabledReason string `yaml:"disabled_reason,omitempty"`
|
||||
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
|
||||
RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts
|
||||
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.
|
||||
type Config struct {
|
||||
Units []Unit `yaml:"units"`
|
||||
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"
|
||||
Units []Unit `yaml:"units"`
|
||||
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"
|
||||
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
|
||||
}
|
||||
|
||||
// Load reads and parses the config file at path.
|
||||
|
||||
@@ -144,3 +144,42 @@ func TestDisabledReason_Roundtrip(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
| Scenario | Path |
|
||||
|
||||
+29
-4
@@ -9,18 +9,26 @@ unitdore \- manage container units via systemd
|
||||
.SH DESCRIPTION
|
||||
.B unitdore
|
||||
bridges your container runtime (Podman, Docker) and systemd.
|
||||
It discovers running containers, stores them in a config file, and generates
|
||||
and manages systemd \fI.service\fR units for each one.
|
||||
It discovers running containers and podman\-compose stacks, stores them in a
|
||||
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
|
||||
.TP
|
||||
.BR \-\-config " " \fIpath\fR
|
||||
Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR
|
||||
(non\-root: \fI~/.config/unitdore/units.yaml\fR)
|
||||
.SH COMMANDS
|
||||
.TP
|
||||
.B syncup
|
||||
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
|
||||
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
|
||||
.B install
|
||||
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
|
||||
prefix: "" # prepended to all service names, e.g. "prod-"
|
||||
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:
|
||||
- name: nginx
|
||||
@@ -85,7 +93,19 @@ units:
|
||||
delay: 0s # delay after previous order group
|
||||
enabled: true
|
||||
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
|
||||
.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
|
||||
System units are written to \fI/etc/systemd/system/\fR and user units to
|
||||
\fI~/.config/systemd/user/\fR.
|
||||
@@ -127,13 +147,18 @@ sudo unitdore update nginx \-\-order 2
|
||||
.I /etc/unitdore/units.yaml
|
||||
Default configuration file.
|
||||
.TP
|
||||
.I ~/.config/unitdore/units.yaml
|
||||
Configuration file when run as a normal user.
|
||||
.TP
|
||||
.I /etc/systemd/system/unitdore-*.service
|
||||
Generated system unit files.
|
||||
.TP
|
||||
.I ~/.config/systemd/user/unitdore-*.service
|
||||
Generated rootless user unit files.
|
||||
.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.
|
||||
.SH SEE ALSO
|
||||
.BR systemctl (1),
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=unitdore
|
||||
pkgver=0.0.6
|
||||
pkgver=0.0.11
|
||||
pkgrel=1
|
||||
pkgdesc="A door you open and close for container units — manage containers via systemd"
|
||||
arch=('x86_64' 'aarch64')
|
||||
@@ -12,11 +12,11 @@ optdepends=(
|
||||
'docker: Docker container runtime support'
|
||||
)
|
||||
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')
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
cd "$pkgname-$pkgver"
|
||||
export CGO_ENABLED=0
|
||||
go build \
|
||||
-trimpath \
|
||||
@@ -25,12 +25,12 @@ build() {
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname"
|
||||
cd "$pkgname-$pkgver"
|
||||
go test ./...
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
cd "$pkgname-$pkgver"
|
||||
|
||||
# Binary
|
||||
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: unitdore
|
||||
Version: 0.0.6
|
||||
Version: 0.0.11
|
||||
Release: 1%{?dist}
|
||||
Summary: Manage container units via systemd
|
||||
|
||||
|
||||
+59
-7
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
type Docker struct{}
|
||||
@@ -17,21 +19,44 @@ type dockerContainer struct {
|
||||
Image string `json:"Image"`
|
||||
Command string `json:"Command"`
|
||||
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")
|
||||
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
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "json").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Docker installed but daemon not running — treat as no containers
|
||||
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
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
|
||||
Image: c.Image,
|
||||
Command: c.Command,
|
||||
Runtime: "docker",
|
||||
Labels: parseDockerLabels(c.Labels),
|
||||
})
|
||||
}
|
||||
return containers, nil
|
||||
return containers
|
||||
}
|
||||
|
||||
func (d *Docker) Exists(name string) (bool, error) {
|
||||
bin, err := exec.LookPath("docker")
|
||||
// parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
|
||||
// "=" 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 {
|
||||
return false, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
|
||||
+65
-11
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
type Podman struct{}
|
||||
@@ -12,28 +14,55 @@ type Podman struct{}
|
||||
func (p *Podman) Name() string { return "podman" }
|
||||
|
||||
type podmanContainer struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Command []string `json:"Command"`
|
||||
State string `json:"State"`
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Command []string `json:"Command"`
|
||||
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")
|
||||
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
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "json").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Podman installed but not usable — treat as no containers
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
containers, err := parsePodmanPS(out)
|
||||
if err != nil {
|
||||
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
|
||||
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, fmt.Errorf("parsing podman output: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var containers []Container
|
||||
@@ -57,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
|
||||
Image: c.Image,
|
||||
Command: strings.Join(c.Command, " "),
|
||||
Runtime: "podman",
|
||||
Labels: c.Labels,
|
||||
})
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (p *Podman) Exists(name string) (bool, error) {
|
||||
bin, err := exec.LookPath("podman")
|
||||
func (p *Podman) Exists(user, name string) (bool, error) {
|
||||
cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("podman ps: %w", err)
|
||||
}
|
||||
@@ -80,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
|
||||
}
|
||||
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
@@ -1,21 +1,63 @@
|
||||
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.
|
||||
type Container struct {
|
||||
Name string
|
||||
Image string
|
||||
Command string
|
||||
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.
|
||||
// user selects the scope: "" = root/system, otherwise a rootless user.
|
||||
type Runtime interface {
|
||||
Name() string
|
||||
ListRunning() ([]Container, error)
|
||||
Exists(name string) (bool, error)
|
||||
ListRunning(user string) ([]Container, 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 {
|
||||
return []Runtime{&Podman{}, &Docker{}}
|
||||
}
|
||||
|
||||
+80
-2
@@ -1,6 +1,8 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(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
|
||||
p := &Podman{}
|
||||
containers, err := p.ListRunning()
|
||||
containers, err := p.ListRunning("")
|
||||
if err != nil {
|
||||
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) {
|
||||
d := &Docker{}
|
||||
containers, err := d.ListRunning()
|
||||
containers, err := d.ListRunning("")
|
||||
if err != nil {
|
||||
t.Errorf("ListRunning() should not error when docker is absent: %v", err)
|
||||
}
|
||||
_ = 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"))
|
||||
}
|
||||
}
|
||||
|
||||
+114
-13
@@ -2,6 +2,8 @@ package systemd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -12,16 +14,27 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
|
||||
# Generated by unitdore — do not edit manually
|
||||
|
||||
[Unit]
|
||||
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
|
||||
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
|
||||
After=network.target{{with .RuntimeService}} {{.}}
|
||||
Requires={{.}}{{end}}
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type={{.ServiceType}}
|
||||
{{- if eq .ServiceType "oneshot"}}
|
||||
RemainAfterExit=yes
|
||||
{{- end}}
|
||||
{{- with .WorkingDir}}
|
||||
WorkingDirectory={{.}}
|
||||
{{- end}}
|
||||
ExecStart={{.ExecStart}}
|
||||
ExecStop={{.ExecStop}}
|
||||
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
|
||||
{{- if .Unit.Restart}}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartSec={{.Unit.RestartSec}}
|
||||
{{- if gt .Unit.RestartRetries 0}}
|
||||
StartLimitBurst={{.Unit.RestartRetries}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -31,15 +44,26 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}}
|
||||
# Generated by unitdore — do not edit manually
|
||||
|
||||
[Unit]
|
||||
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
|
||||
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type={{.ServiceType}}
|
||||
{{- if eq .ServiceType "oneshot"}}
|
||||
RemainAfterExit=yes
|
||||
{{- end}}
|
||||
{{- with .WorkingDir}}
|
||||
WorkingDirectory={{.}}
|
||||
{{- end}}
|
||||
ExecStart={{.ExecStart}}
|
||||
ExecStop={{.ExecStop}}
|
||||
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
|
||||
{{- if .Unit.Restart}}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartSec={{.Unit.RestartSec}}
|
||||
{{- if gt .Unit.RestartRetries 0}}
|
||||
StartLimitBurst={{.Unit.RestartRetries}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -51,6 +75,9 @@ type templateData struct {
|
||||
ExecStart string
|
||||
ExecStop 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.
|
||||
@@ -60,14 +87,25 @@ func ServiceName(u config.Unit, prefix, suffix string) string {
|
||||
|
||||
// Generate produces the .service file content for a unit.
|
||||
func Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||
execStart, execStop := buildExecCommands(u)
|
||||
|
||||
data := templateData{
|
||||
ServiceName: ServiceName(u, prefix, suffix),
|
||||
Unit: u,
|
||||
ExecStart: execStart,
|
||||
ExecStop: execStop,
|
||||
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
|
||||
@@ -107,7 +145,7 @@ func buildExecCommands(u config.Unit) (start, stop string) {
|
||||
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
|
||||
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
|
||||
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)
|
||||
default:
|
||||
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
|
||||
@@ -115,3 +153,66 @@ func buildExecCommands(u config.Unit) (start, stop string) {
|
||||
}
|
||||
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) + `"`
|
||||
}
|
||||
|
||||
+182
-7
@@ -1,6 +1,8 @@
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -49,8 +51,7 @@ func TestGenerate_SystemUnit(t *testing.T) {
|
||||
"After=network.target",
|
||||
"WantedBy=multi-user.target",
|
||||
"ExecStart=/usr/bin/podman start -a nginx",
|
||||
"ExecStop=/usr/bin/podman stop nginx",
|
||||
"Restart=on-failure",
|
||||
"ExecStop=-/usr/bin/podman stop nginx",
|
||||
"Generated by unitdore",
|
||||
}
|
||||
|
||||
@@ -66,6 +67,9 @@ func TestGenerate_SystemUnit(t *testing.T) {
|
||||
if strings.Contains(content, "Requires=") {
|
||||
t.Errorf("Generate() podman system unit should not contain Requires= (podman is daemonless):\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "Restart=") {
|
||||
t.Errorf("Generate() restart should be disabled by default:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_UserUnit(t *testing.T) {
|
||||
@@ -85,8 +89,8 @@ func TestGenerate_UserUnit(t *testing.T) {
|
||||
checks := []string{
|
||||
"After=default.target",
|
||||
"WantedBy=default.target",
|
||||
"ExecStart=/usr/bin/docker start myapp",
|
||||
"ExecStop=/usr/bin/docker stop myapp",
|
||||
"ExecStart=/usr/bin/docker start -a myapp",
|
||||
"ExecStop=-/usr/bin/docker stop myapp",
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
@@ -100,6 +104,51 @@ func TestGenerate_UserUnit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_WithRestart(t *testing.T) {
|
||||
t.Run("restart with retries", func(t *testing.T) {
|
||||
u := config.Unit{
|
||||
Name: "nginx",
|
||||
Runtime: "podman",
|
||||
Enabled: true,
|
||||
Restart: true,
|
||||
RestartSec: 5,
|
||||
RestartRetries: 3,
|
||||
}
|
||||
content, err := Generate(u, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Restart=on-failure", "RestartSec=5", "StartLimitBurst=3"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("Generate() missing %q in output:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restart without retries", func(t *testing.T) {
|
||||
u := config.Unit{
|
||||
Name: "nginx",
|
||||
Runtime: "podman",
|
||||
Enabled: true,
|
||||
Restart: true,
|
||||
RestartSec: 10,
|
||||
}
|
||||
content, err := Generate(u, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "Restart=on-failure") {
|
||||
t.Errorf("Generate() missing Restart=on-failure:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "RestartSec=10") {
|
||||
t.Errorf("Generate() missing RestartSec=10:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "StartLimitBurst") {
|
||||
t.Errorf("Generate() should not contain StartLimitBurst when retries=0:\n%s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerate_CustomCommand(t *testing.T) {
|
||||
u := config.Unit{
|
||||
Name: "custom",
|
||||
@@ -131,8 +180,8 @@ func TestGenerate_DockerRuntime(t *testing.T) {
|
||||
}
|
||||
|
||||
checks := []string{
|
||||
"ExecStart=/usr/bin/docker start redis",
|
||||
"ExecStop=/usr/bin/docker stop redis",
|
||||
"ExecStart=/usr/bin/docker start -a redis",
|
||||
"ExecStop=-/usr/bin/docker stop redis",
|
||||
"After=network.target docker.service",
|
||||
"Requires=docker.service",
|
||||
}
|
||||
@@ -193,7 +242,7 @@ func TestBuildExecCommands(t *testing.T) {
|
||||
{
|
||||
name: "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",
|
||||
},
|
||||
{
|
||||
@@ -216,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
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/warkanum/unitdore/config"
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
// Rootless: write to the user's systemd config dir
|
||||
home, err := userHomeDir(u.User)
|
||||
home, err := sysuser.HomeDir(u.User)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -130,27 +131,19 @@ func systemctl(user string, args ...string) error {
|
||||
}
|
||||
|
||||
func runAsUser(user string, bin string, args ...string) ([]byte, error) {
|
||||
full := append([]string{"-u", user, "--", bin}, args...)
|
||||
return exec.Command("runuser", full...).Output()
|
||||
cmd, err := sysuser.Command(user, bin, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func runAsUserCmd(user string, bin string, args ...string) error {
|
||||
full := append([]string{"-u", user, "--", bin}, args...)
|
||||
cmd := exec.Command("runuser", full...)
|
||||
cmd, err := sysuser.Command(user, bin, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -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"]
|
||||
Vendored
+100
@@ -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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user