diff --git a/examples/confsync.example.yaml b/examples/confsync.example.yaml new file mode 100644 index 0000000..90a2c74 --- /dev/null +++ b/examples/confsync.example.yaml @@ -0,0 +1,63 @@ +# pve-confsync - exemple de configuration (phase 0, CLI) +# Les secrets des tokens ne sont JAMAIS écrits ici : variable d'environnement +# (token_secret_env) ou fichier, par exemple un secret docker (token_secret_file). + +reference: + name: ref + url: https://pve-ref.lab:8006 + token_id: confsync@pve!ro # token en lecture seule (Sys.Audit, SDN.Audit) + token_secret_file: /run/secrets/pve_ref # secret docker (voir docker-compose.yml) + fingerprint: "AA:BB:CC:...:FF" # SHA-256 du certificat (pinning, certificat auto-signé) + +clients: + - name: site-a + url: https://pve-a.lab:8006 + token_id: confsync@pve!rw + token_secret_file: /run/secrets/pve_site_a + ca_file: /config/ca-site-a.pem # ou fingerprint, ou insecure: true (labo uniquement) + # Valeurs propres au cluster, prioritaires sur la référence : + # type d'objet -> id -> champ -> valeur + mapping: + sdn-zone: + vlanz: + bridge: vmbr1 # obligatoire pour créer une zone VLAN + nodes: pve-a1,pve-a2 + vxz: + peers: 192.168.10.1,192.168.10.2 + datacenter-options: + datacenter: + next-id: lower=2000,upper=2999 + # Politiques propres à ce client (prioritaires sur les politiques globales) + policies: + sdn-zone: + mtu: ignore + + - name: site-b + url: https://pve-b.lab:8006 + token_id: confsync@pve!rw + token_secret_env: PVE_SITE_B_SECRET + insecure: true + mapping: + sdn-zone: + vlanz: + bridge: vmbr0 + +# Types synchronisés (défaut : tous) +kinds: [datacenter-options, sdn-zone, sdn-vnet, sdn-subnet] + +# Sélection des objets par motif (glob) sur l'id +objects: + sdn-zone: + exclude: ["lab*"] + +# Politique par champ : sync | ignore | override (valeur fournie par le mapping du client) +# Défauts : options datacenter = tag-style, registered-tags, user-tag-access, next-id en sync, +# le reste ignoré ; zones SDN = nodes, bridge, peers, fabric, dns, reversedns, dnszone en +# override, le reste en sync ; vnets et subnets = sync. +policies: + datacenter-options: + keyboard: sync + language: sync + +# Suppression chez les clients des objets absents de la référence (défaut : non) +prune: false diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..911fe9e --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,232 @@ +package engine + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/config" + "pve-confsync/internal/model" + "pve-confsync/internal/pve" + "pve-confsync/internal/pve/pvetest" +) + +func client(t *testing.T, s *pvetest.Server, name string) *pve.Client { + t.Helper() + sum := sha256.Sum256(s.Certificate().Raw) + c, err := pve.New(pve.Options{Name: name, BaseURL: s.URL, TokenID: "confsync@pve!t", TokenSecret: "secret", + Fingerprint: hex.EncodeToString(sum[:])}) + if err != nil { + t.Fatal(err) + } + return c +} + +// reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags. +func reference(t *testing.T) *pvetest.Server { + ref := pvetest.New("9.1.4") + t.Cleanup(ref.Close) + ref.Options = map[string]string{ + "registered-tags": "prod;dev", + "tag-style": "color-map=prod:ff0000,shape=full", + "next-id": "lower=100,upper=9999", + "keyboard": "fr", + } + ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"} + ref.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "10.0.0.2,10.0.0.1", "ipam": "pve"} + ref.Pending.Zones["evpnz"] = map[string]any{"type": "evpn", "controller": "c1"} + ref.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"} + ref.Pending.VNets["vx1"] = map[string]any{"zone": "vxz", "tag": 10001} + ref.Pending.VNets["ev1"] = map[string]any{"zone": "evpnz", "tag": 5000} + ref.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1", + "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} + ref.Commit() + return ref +} + +func testConfig() *config.Config { + return &config.Config{ + Kinds: model.Kinds, + Clients: []config.Client{{ + Cluster: config.Cluster{Name: "site-a"}, + Mapping: map[model.Kind]map[string]map[string]any{ + model.KindZone: { + "vlanz": {"bridge": "vmbr1", "nodes": "a2,a1"}, + "vxz": {"peers": "192.168.0.1,192.168.0.2"}, + }, + }, + }}, + } +} + +func TestSyncEndToEnd(t *testing.T) { + ctx := context.Background() + ref := reference(t) + cli := pvetest.New("9.0.10") + defer cli.Close() + cli.Options = map[string]string{"registered-tags": "old", "keyboard": "de"} + + cfg := testConfig() + cl := &cfg.Clients[0] + rc, cc := client(t, ref, "ref"), client(t, cli, "site-a") + + src, err := Read(ctx, rc, cfg.Kinds, adapter.ReadRunning) + if err != nil { + t.Fatal(err) + } + if len(src.Warnings) != 2 { // evpn zone + its vnet + t.Fatalf("warnings = %v", src.Warnings) + } + cur, err := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent) + if err != nil { + t.Fatal(err) + } + plan := MakePlan(cfg, cl, src.Objects, cur.Objects) + if len(plan.Errors) > 0 { + t.Fatal(plan.Errors) + } + // 1 datacenter update + 2 zones + 2 vnets + 1 subnet + if len(plan.Changes) != 6 { + t.Fatalf("changes = %+v", plan.Changes) + } + dc := plan.Changes[0] + if dc.Kind != model.KindDatacenter || dc.Set["registered-tags"] != "dev;prod" || dc.Set["keyboard"] != nil { + t.Fatalf("datacenter change = %+v", dc) + } + + res := Apply(ctx, cc, plan) + if res.Err != nil { + t.Fatalf("apply: %v (%+v)", res.Err, res.Steps) + } + if cli.Lock != "" { + t.Fatal("SDN lock not released") + } + z := cli.Running.Zones["vlanz"] + if z["bridge"] != "vmbr1" || z["nodes"] != "a1,a2" || z["mtu"] != "1500" { + t.Fatalf("vlan zone = %v (mapping not applied)", z) + } + if cli.Running.Zones["evpnz"] != nil { + t.Fatal("evpn zone must not be synchronized") + } + if cli.Options["keyboard"] != "de" || cli.Options["tag-style"] != "color-map=prod:ff0000,shape=full" { + t.Fatalf("options = %v", cli.Options) + } + if cli.Running.Subnets["vlanz-10.1.0.0-24"]["gateway"] != "10.1.0.1" { + t.Fatalf("subnets = %v", cli.Running.Subnets) + } + + replan := func(actual []model.Object) Plan { return MakePlan(cfg, cl, src.Objects, actual) } + if err := Verify(ctx, cc, replan, cfg.Kinds); err != nil { + t.Fatalf("verify: %v", err) + } +} + +func TestMissingMappingIsAnError(t *testing.T) { + ref := reference(t) + cli := pvetest.New("9.1.0") + defer cli.Close() + cfg := testConfig() + cfg.Clients[0].Mapping = nil + ctx := context.Background() + src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning) + cur, _ := Read(ctx, client(t, cli, "c"), cfg.Kinds, adapter.ReadCurrent) + plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects) + if len(plan.Errors) != 1 || !strings.Contains(plan.Errors[0], `"bridge"`) { + t.Fatalf("errors = %v", plan.Errors) + } + if res := Apply(ctx, client(t, cli, "c"), plan); res.Err == nil || len(cli.Calls) != 0 { + t.Fatalf("a plan in error must not be applied: %v %v", res.Err, cli.Calls) + } +} + +func TestRollbackOnFailure(t *testing.T) { + ref := reference(t) + cli := pvetest.New("9.1.0") + defer cli.Close() + cli.FailOn = "POST cluster/sdn/vnets" + cfg := testConfig() + ctx := context.Background() + src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning) + cc := client(t, cli, "c") + cur, _ := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent) + res := Apply(ctx, cc, MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)) + if res.Err == nil { + t.Fatal("expected an error") + } + if len(cli.Pending.Zones) != 0 || cli.Lock != "" { + t.Fatalf("pending not rolled back or lock kept: %v lock=%q", cli.Pending.Zones, cli.Lock) + } + last := cli.Calls[len(cli.Calls)-1] + if last != "POST cluster/sdn/rollback" { + t.Fatalf("last call = %s", last) + } +} + +func TestRefuseNonPVE9(t *testing.T) { + s := pvetest.New("8.4.1") + defer s.Close() + if _, err := client(t, s, "old").CheckVersion(context.Background()); err == nil { + t.Fatal("PVE 8 must be refused") + } +} + +func TestPruneAndFieldRemoval(t *testing.T) { + ref := reference(t) + cli := pvetest.New("9.1.0") + defer cli.Close() + cli.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr1", "mtu": "1500", "ipam": "pve", "vlan-protocol": "802.1q"} + cli.Pending.Zones["local"] = map[string]any{"type": "vlan", "bridge": "vmbr9"} + cli.Pending.VNets["lv"] = map[string]any{"zone": "local", "tag": 9} + cli.Commit() + cfg := testConfig() + cfg.Prune = true + cfg.Objects = map[model.Kind]config.Selection{model.KindZone: {Exclude: []string{"keep-*"}}} + ctx := context.Background() + src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning) + cur, _ := Read(ctx, client(t, cli, "c"), cfg.Kinds, adapter.ReadCurrent) + plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects) + var ops []string + for _, c := range plan.Changes { + ops = append(ops, string(c.Op)+" "+c.ID) + if c.ID == "vlanz" && (len(c.Unset) != 1 || c.Unset[0] != "vlan-protocol") { + t.Fatalf("vlanz unset = %v", c.Unset) + } + } + got := strings.Join(ops, "|") + // deletions at the end, vnet before its zone + if !strings.HasSuffix(got, "delete lv|delete local") { + t.Fatalf("order = %s", got) + } +} + +func TestDatacenterCanonicalForm(t *testing.T) { + s := pvetest.New("9.1.0") + defer s.Close() + s.Options = map[string]string{"user-tag-access": "user-allow=list,user-allow-list=b;a", "next-id": "upper=500,lower=200"} + snap, err := Read(context.Background(), client(t, s, "c"), []model.Kind{model.KindDatacenter}, adapter.ReadCurrent) + if err != nil { + t.Fatal(err) + } + f := snap.Objects[0].Fields + if f["user-tag-access"] != "user-allow=list,user-allow-list=a;b" || f["next-id"] != "lower=200,upper=500" { + t.Fatalf("fields = %v", f) + } + if _, ok := f["mac_prefix"]; ok { + t.Fatal("default mac_prefix injected by PVE must be dropped") + } +} + +func TestSubnetCanonicalForm(t *testing.T) { + a, _ := adapter.ByKind(model.KindSubnet) + f := model.Normalize(map[string]any{"dhcp-range": "start-address=10.0.0.10,end-address=10.0.0.20"}) + a.Canonicalize(f) + if l, ok := f["dhcp-range"].([]string); !ok || len(l) != 1 { + t.Fatalf("dhcp-range = %#v", f["dhcp-range"]) + } + if cidr, err := adapter.SubnetCIDR("vxz-fd00:1::-64"); err != nil || cidr != "fd00:1::/64" { + t.Fatalf("cidr = %q %v", cidr, err) + } +}