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{ Use: "syncup", Short: "Discover running containers and sync them into the config", Long: `Syncup queries all available runtimes for running containers and adds any new ones to the config file. Existing entries are never removed. 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, } 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 } containers := discover(syncScopes(cfg)) 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", 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++ } } }