Phase 0 : CLI check / export / diff / apply

This commit is contained in:
claude Bot committed 2026-10-13 14:50:37 +02:00
1 parent f52276eccd
commit 5a5422ca78
1 file changed
+437
+437
View File
@@ -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] <commande> [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 <commande> -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)
}
}