feat: podman-compose stack units and root/user execution scopes
- syncup: podman first, docker only as fallback (podman wins on name clash;
existing docker units found in podman switch to podman)
- syncup: group podman-compose containers into one `kind: stack` unit per
project (project, working_dir, compose_files, compose_bin from labels);
disable existing member units ("part of stack <project>")
- generator: compose mode (podman-compose up/stop, WorkingDirectory) with
pod start/stop fallback when compose binary/dir/files are missing
- reconcile: stacks disabled with "stack not found" when neither compose
files nor pod exist
- root: scan root + rootless users listed in `users:` (podman only)
- non-root: config ~/.config/unitdore/units.yaml, own user units only
- sysuser package: runuser with XDG_RUNTIME_DIR/DBUS env, home/linger lookup
- install: warn when linger is not enabled for user units
- testdata/stack: podman-compose test stack and captured podman ps fixture
- docs: README, generated-units, man page, AGENTS, PLAN
This commit is contained in:
+25
-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"`
|
||||
@@ -23,13 +34,24 @@ type Unit struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user