diff --git a/cmd/pve-confsync/main.go b/cmd/pve-confsync/main.go new file mode 100644 index 0000000..6bf87b5 --- /dev/null +++ b/cmd/pve-confsync/main.go @@ -0,0 +1,437 @@ +// pve-confsync synchronizes part of the configuration of a reference Proxmox VE +// cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client +// clusters through the PVE API. Phase 0: command line only. +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "sort" + "strings" + "syscall" + + "gopkg.in/yaml.v3" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/config" + "pve-confsync/internal/engine" + "pve-confsync/internal/model" + "pve-confsync/internal/pve" +) + +var version = "dev" + +const usage = `pve-confsync %s - synchronisation de configuration Proxmox VE 9.x (référence -> clients) + +Usage : pve-confsync [-config fichier] [options] + +Commandes : + check vérifie l'accès, la version PVE (9.x) et les droits des tokens + export exporte l'état normalisé d'un cluster en YAML (défaut : la référence) + diff affiche les écarts référence -> clients (code retour 2 s'il y en a) + apply dry-run par défaut ; applique avec -confirm + version affiche la version + +Lancer "pve-confsync -h" pour les options d'une commande. +` + +func main() { + global := flag.NewFlagSet("pve-confsync", flag.ExitOnError) + cfgFile := global.String("config", envOr("CONFSYNC_CONFIG", "confsync.yaml"), "fichier de configuration") + global.Usage = func() { fmt.Fprintf(os.Stderr, usage, version); global.PrintDefaults() } + _ = global.Parse(os.Args[1:]) + args := global.Args() + if len(args) == 0 { + global.Usage() + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var err error + code := 0 + switch args[0] { + case "version": + fmt.Println(version) + return + case "check": + err = cmdCheck(ctx, *cfgFile, args[1:]) + case "export": + err = cmdExport(ctx, *cfgFile, args[1:]) + case "diff": + code, err = cmdDiff(ctx, *cfgFile, args[1:]) + case "apply": + err = cmdApply(ctx, *cfgFile, args[1:]) + default: + global.Usage() + os.Exit(1) + } + if err != nil { + fmt.Fprintln(os.Stderr, "erreur :", err) + os.Exit(1) + } + os.Exit(code) +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// ---- check ---- + +func cmdCheck(ctx context.Context, cfgFile string, args []string) error { + fs := flag.NewFlagSet("check", flag.ExitOnError) + _ = fs.Parse(args) + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + var errs []error + check := func(cl config.Cluster, role string, wantWrite bool) { + c, err := cl.PVE() + if err != nil { + errs = append(errs, err) + return + } + vs, err := c.CheckVersion(ctx) + for _, v := range vs { + fmt.Printf("%-10s %-16s nœud %-12s PVE %s\n", role, cl.Name, v.Node, v.Version) + } + if err != nil { + errs = append(errs, err) + return + } + perms, err := c.Permissions(ctx) + if err != nil { + fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err) + return + } + for _, w := range privilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) { + fmt.Println(" avertissement :", w) + } + } + check(cfg.Reference, "référence", false) + for _, cl := range cfg.Clients { + check(cl.Cluster, "client", true) + } + return errors.Join(errs...) +} + +// privilegeWarnings flags a reference token able to write, and a client token +// missing the privileges needed by the synchronized kinds. +func privilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string { + has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok } + any := func(priv string) bool { + for p := range perms { + if has(p, priv) { + return true + } + } + return false + } + var w []string + if !write { + for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} { + if any(p) { + w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu") + } + } + return w + } + if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") { + w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera") + } + if dcOptions && !has("/", "Sys.Modify") { + w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites") + } + if !has("/", "Sys.Audit") { + w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles") + } + return w +} + +// ---- export ---- + +func cmdExport(ctx context.Context, cfgFile string, args []string) error { + fs := flag.NewFlagSet("export", flag.ExitOnError) + name := fs.String("cluster", "", "cluster à exporter (défaut : la référence)") + out := fs.String("o", "-", "fichier de sortie YAML") + _ = fs.Parse(args) + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + cl, mode := cfg.Reference, adapter.ReadRunning + if *name != "" && *name != cfg.Reference.Name { + c, ok := cfg.Client(*name) + if !ok { + return fmt.Errorf("cluster %q inconnu", *name) + } + cl, mode = c.Cluster, adapter.ReadCurrent + } + c, err := cl.PVE() + if err != nil { + return err + } + snap, err := engine.Read(ctx, c, cfg.Kinds, mode) + if err != nil { + return err + } + warn(snap.Warnings) + return writeYAML(*out, snap) +} + +func writeYAML(file string, v any) error { + var w io.Writer = os.Stdout + if file != "-" { + f, err := os.Create(file) + if err != nil { + return err + } + defer f.Close() + w = f + } + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + if err := enc.Encode(v); err != nil { + return err + } + return enc.Close() +} + +func loadSnapshot(file string) (model.Snapshot, error) { + var s model.Snapshot + raw, err := os.ReadFile(file) + if err != nil { + return s, err + } + if err := yaml.Unmarshal(raw, &s); err != nil { + return s, fmt.Errorf("%s: %w", file, err) + } + for i := range s.Objects { + s.Objects[i].Fields = model.Normalize(s.Objects[i].Fields) + if a, err := adapter.ByKind(s.Objects[i].Kind); err == nil { + a.Canonicalize(s.Objects[i].Fields) + } else { + return s, fmt.Errorf("%s: %w", file, err) + } + } + return s, nil +} + +// ---- diff / apply ---- + +type target struct { + cl *config.Client + client *pve.Client + plan engine.Plan +} + +// prepare reads the source (live reference or YAML snapshot) and each selected +// client, and computes the plans. +func prepare(ctx context.Context, cfg *config.Config, source, only string) ([]target, error) { + var ref model.Snapshot + var err error + if source != "" { + ref, err = loadSnapshot(source) + } else { + var c *pve.Client + if c, err = cfg.Reference.PVE(); err == nil { + ref, err = engine.Read(ctx, c, cfg.Kinds, adapter.ReadRunning) + } + } + if err != nil { + return nil, err + } + warn(ref.Warnings) + var ts []target + for i := range cfg.Clients { + cl := &cfg.Clients[i] + if only != "" && cl.Name != only { + continue + } + c, err := cl.PVE() + if err != nil { + return nil, err + } + cur, err := engine.Read(ctx, c, cfg.Kinds, adapter.ReadCurrent) + if err != nil { + return nil, err + } + p := engine.MakePlan(cfg, cl, ref.Objects, cur.Objects) + p.Warnings = append(p.Warnings, cur.Warnings...) + ts = append(ts, target{cl: cl, client: c, plan: p}) + } + if only != "" && len(ts) == 0 { + return nil, fmt.Errorf("client %q inconnu", only) + } + return ts, nil +} + +func cmdDiff(ctx context.Context, cfgFile string, args []string) (int, error) { + fs := flag.NewFlagSet("diff", flag.ExitOnError) + only := fs.String("client", "", "limiter à un client") + source := fs.String("source", "", "lire la référence depuis un export YAML au lieu de l'API") + format := fs.String("format", "text", "text ou json") + _ = fs.Parse(args) + cfg, err := config.Load(cfgFile) + if err != nil { + return 1, err + } + ts, err := prepare(ctx, cfg, *source, *only) + if err != nil { + return 1, err + } + code := 0 + var plans []engine.Plan + for _, t := range ts { + plans = append(plans, t.plan) + if !t.plan.Empty() || len(t.plan.Errors) > 0 { + code = 2 + } + } + if *format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return code, enc.Encode(plans) + } + for _, p := range plans { + printPlan(os.Stdout, p) + } + return code, nil +} + +func cmdApply(ctx context.Context, cfgFile string, args []string) error { + fs := flag.NewFlagSet("apply", flag.ExitOnError) + only := fs.String("client", "", "limiter à un client") + source := fs.String("source", "", "lire la référence depuis un export YAML au lieu de l'API") + confirm := fs.Bool("confirm", false, "appliquer réellement (sans cette option : dry-run)") + yes := fs.Bool("yes", false, "ne pas demander la confirmation interactive des changements SDN") + prune := fs.Bool("prune", false, "supprimer chez les clients les objets absents de la référence") + _ = fs.Parse(args) + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + cfg.Prune = cfg.Prune || *prune + ts, err := prepare(ctx, cfg, *source, *only) + if err != nil { + return err + } + var src model.Snapshot + if *source != "" { + src, _ = loadSnapshot(*source) + } + var errs []error + for _, t := range ts { + printPlan(os.Stdout, t.plan) + if t.plan.Empty() || len(t.plan.Errors) > 0 { + if len(t.plan.Errors) > 0 { + errs = append(errs, fmt.Errorf("client %s : plan en erreur, rien appliqué", t.cl.Name)) + } + continue + } + if !*confirm { + fmt.Printf("[%s] dry-run : rien n'a été appliqué (ajouter -confirm)\n\n", t.cl.Name) + continue + } + if t.plan.HasSDN() && !*yes && !askSDN(t.cl.Name) { + fmt.Printf("[%s] changements SDN non confirmés, client ignoré\n\n", t.cl.Name) + continue + } + res := engine.Apply(ctx, t.client, t.plan) + for _, s := range res.Steps { + status := "ok" + if s.Error != "" { + status = "ERREUR " + s.Error + } + fmt.Printf("[%s] %s %s : %s\n", t.cl.Name, s.At.Format("15:04:05"), s.Action, status) + } + if res.Err != nil { + errs = append(errs, fmt.Errorf("client %s : %w", t.cl.Name, res.Err)) + continue + } + replan := func(actual []model.Object) engine.Plan { + ref := src.Objects + if *source == "" { + // the reference is re-read so that verification uses the same input + c, err := cfg.Reference.PVE() + var r model.Snapshot + if err == nil { + r, err = engine.Read(ctx, c, cfg.Kinds, adapter.ReadRunning) + } + if err != nil { + return engine.Plan{Errors: []string{err.Error()}} + } + ref = r.Objects + } + return engine.MakePlan(cfg, t.cl, ref, actual) + } + if err := engine.Verify(ctx, t.client, replan, cfg.Kinds); err != nil { + errs = append(errs, fmt.Errorf("client %s : vérification : %w", t.cl.Name, err)) + continue + } + fmt.Printf("[%s] appliqué et vérifié\n\n", t.cl.Name) + } + return errors.Join(errs...) +} + +// askSDN requires typing the client name: an SDN apply reloads the network of every node. +func askSDN(client string) bool { + fi, err := os.Stdin.Stat() + if err != nil || fi.Mode()&os.ModeCharDevice == 0 { + fmt.Fprintln(os.Stderr, "entrée non interactive : utiliser -yes pour confirmer les changements SDN") + return false + } + fmt.Printf("Appliquer les changements SDN sur %q (rechargement réseau de tous les nœuds) ?\nTaper le nom du client pour confirmer : ", client) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + return strings.TrimSpace(line) == client +} + +func printPlan(w io.Writer, p engine.Plan) { + for _, m := range p.Warnings { + fmt.Fprintf(w, "[%s] avertissement : %s\n", p.Client, m) + } + for _, e := range p.Errors { + fmt.Fprintf(w, "[%s] ERREUR : %s\n", p.Client, e) + } + if p.Empty() { + fmt.Fprintf(w, "[%s] aucun écart\n\n", p.Client) + return + } + for _, ch := range p.Changes { + fmt.Fprintf(w, "[%s] %-6s %s/%s\n", p.Client, ch.Op, ch.Kind, ch.ID) + keys := make([]string, 0, len(ch.Set)) + for k := range ch.Set { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if old, ok := ch.Old[k]; ok { + fmt.Fprintf(w, " ~ %s : %s -> %s\n", k, model.Format(old), model.Format(ch.Set[k])) + } else { + fmt.Fprintf(w, " + %s = %s\n", k, model.Format(ch.Set[k])) + } + } + for _, k := range ch.Unset { + fmt.Fprintf(w, " - %s (était %s)\n", k, model.Format(ch.Old[k])) + } + } + fmt.Fprintln(w) +} + +func warn(ws []string) { + for _, w := range ws { + fmt.Fprintln(os.Stderr, "avertissement :", w) + } +}