Extract init-neutral unit resolution into service/, add initsys.Init with systemd, openrc and runit backends, selected by 'init:' config or autodetect. OpenRC and runit are root only; rootless units run as system services as that user. Add docs and generated examples for all three.
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
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/service"
|
|
"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 service.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++
|
|
}
|
|
}
|
|
}
|