- 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
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|