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:
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func runInstallall(cmd *cobra.Command, args []string) error {
|
||||
installed := 0
|
||||
skipped := 0
|
||||
removed := 0
|
||||
lingerSeen := map[string]bool{}
|
||||
|
||||
for _, u := range cfg.Units {
|
||||
if !u.Enabled {
|
||||
@@ -72,6 +73,7 @@ func runInstallall(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
||||
fmt.Printf(" ✓ installed: %s\n", path)
|
||||
lingerHint(lingerSeen, u)
|
||||
installed++
|
||||
}
|
||||
}
|
||||
|
||||
+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"}
|
||||
|
||||
+19
-3
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warkanum/unitdore/config"
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
var version = "0.0.10"
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user