From 30f770ed8ff575e18e2ff6aac9d54176382581da Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 13 Oct 2026 14:51:37 +0200 Subject: [PATCH] Tests : faux serveur PVE 9.x, tests config et CLI --- cmd/pve-confsync/main_test.go | 53 +++++ internal/config/config_test.go | 44 +++++ internal/pve/pvetest/fake.go | 343 +++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 cmd/pve-confsync/main_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/pve/pvetest/fake.go diff --git a/cmd/pve-confsync/main_test.go b/cmd/pve-confsync/main_test.go new file mode 100644 index 0000000..b8cc6d1 --- /dev/null +++ b/cmd/pve-confsync/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "pve-confsync/internal/pve/pvetest" +) + +func TestCLIDiffExportApply(t *testing.T) { + ref := pvetest.New("9.1.4") + defer ref.Close() + cli := pvetest.New("9.1.4") + defer cli.Close() + ref.Options["registered-tags"] = "prod" + ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0"} + ref.Pending.VNets["v10"] = map[string]any{"zone": "vlanz", "tag": 10} + ref.Commit() + + dir := t.TempDir() + t.Setenv("SECRET", "s3cr3t") + cfg := filepath.Join(dir, "c.yaml") + _ = os.WriteFile(cfg, []byte(fmt.Sprintf(`reference: {name: ref, url: %s, token_id: a@pve!ro, token_secret_env: SECRET, insecure: true} +clients: + - {name: c1, url: %s, token_id: a@pve!rw, token_secret_env: SECRET, insecure: true, + mapping: {sdn-zone: {vlanz: {bridge: vmbr1}}}} +`, ref.URL, cli.URL)), 0o600) + ctx := context.Background() + + if code, err := cmdDiff(ctx, cfg, nil); err != nil || code != 2 { + t.Fatalf("diff: code=%d err=%v", code, err) + } + snap := filepath.Join(dir, "ref.yaml") + if err := cmdExport(ctx, cfg, []string{"-o", snap}); err != nil { + t.Fatal(err) + } + // dry-run by default: nothing written + if err := cmdApply(ctx, cfg, []string{"-source", snap}); err != nil || len(cli.Calls) != 0 { + t.Fatalf("dry-run wrote: %v %v", err, cli.Calls) + } + if err := cmdApply(ctx, cfg, []string{"-source", snap, "-confirm", "-yes"}); err != nil { + t.Fatal(err) + } + if cli.Running.VNets["v10"] == nil || cli.Options["registered-tags"] != "prod" { + t.Fatalf("not applied: %v %v", cli.Running.VNets, cli.Options) + } + if code, err := cmdDiff(ctx, cfg, nil); err != nil || code != 0 { + t.Fatalf("diff after apply: code=%d err=%v", code, err) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..75211db --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/model" +) + +func TestLoadExample(t *testing.T) { + c, err := Load(filepath.Join("..", "..", "examples", "confsync.example.yaml")) + if err != nil { + t.Fatal(err) + } + a, _ := adapter.ByKind(model.KindZone) + cl, _ := c.Client("site-a") + if p := c.Policy(cl, a, "mtu"); p != adapter.Ignore { + t.Fatalf("site-a mtu policy = %s", p) + } + if p := c.Policy(nil, a, "bridge"); p != adapter.Override { + t.Fatalf("bridge policy = %s", p) + } + if c.Selected(model.KindZone, "lab1") || !c.Selected(model.KindZone, "vlanz") { + t.Fatal("selection") + } +} + +func TestRejects(t *testing.T) { + for name, body := range map[string]string{ + "clear secret": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret: s}\n", + "no secret": "reference: {name: r, url: https://x, token_id: a@pve!b}\n", + "forbidden": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\npolicies: {datacenter-options: {webauthn: sync}}\n", + "bad policy": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\npolicies: {sdn-zone: {mtu: copy}}\n", + "unknown kind": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\nkinds: [firewall]\n", + } { + f := filepath.Join(t.TempDir(), "c.yaml") + _ = os.WriteFile(f, []byte(body), 0o600) + if _, err := Load(f); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} diff --git a/internal/pve/pvetest/fake.go b/internal/pve/pvetest/fake.go new file mode 100644 index 0000000..648b2fb --- /dev/null +++ b/internal/pve/pvetest/fake.go @@ -0,0 +1,343 @@ +// Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by +// pve-confsync, for tests. It mimics the behaviours that matter for the engine: +// parsed datacenter options on GET, property strings on PUT, SDN pending/running +// configurations, global SDN lock with token, rollback and reload task. +package pvetest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "sync" +) + +// SDN holds one SDN configuration (pending or running). +type SDN struct { + Zones map[string]map[string]any + VNets map[string]map[string]any + Subnets map[string]map[string]any // id "zone-ip-mask" -> fields (with vnet) +} + +func newSDN() SDN { + return SDN{map[string]map[string]any{}, map[string]map[string]any{}, map[string]map[string]any{}} +} + +func (s SDN) clone() SDN { + c := newSDN() + cp := func(dst, src map[string]map[string]any) { + for k, v := range src { + m := map[string]any{} + for f, x := range v { + m[f] = x + } + dst[k] = m + } + } + cp(c.Zones, s.Zones) + cp(c.VNets, s.VNets) + cp(c.Subnets, s.Subnets) + return c +} + +// Server is a fake PVE cluster. +type Server struct { + *httptest.Server + mu sync.Mutex + Version string // pve-manager version, e.g. 9.1.4 + Options map[string]string // datacenter.cfg as written (property strings) + Pending SDN + Running SDN + Lock string + Perms map[string]map[string]any + FailOn string // "METHOD path" answering 500, to test rollbacks + Calls []string // "METHOD path" of every write + locks int +} + +// New starts a fake cluster. +func New(version string) *Server { + s := &Server{Version: version, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(), + Perms: map[string]map[string]any{}} + s.Server = httptest.NewTLSServer(http.HandlerFunc(s.handle)) + return s +} + +// Commit makes the pending SDN configuration the running one (like an apply). +func (s *Server) Commit() { + s.mu.Lock() + defer s.mu.Unlock() + s.Running = s.Pending.clone() +} + +func reply(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) +} + +func fail(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]any{"data": nil, "message": msg}) +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if !strings.HasPrefix(r.Header.Get("Authorization"), "PVEAPIToken=") { + fail(w, 401, "no ticket") + return + } + _ = r.ParseForm() + p := strings.TrimPrefix(r.URL.Path, "/api2/json/") + seg := strings.Split(p, "/") + key := r.Method + " " + p + if r.Method != http.MethodGet { + s.Calls = append(s.Calls, key) + } + if s.FailOn != "" && s.FailOn == key { + fail(w, 500, "injected failure") + return + } + // writes on SDN objects must carry the token while the lock is held + if r.Method != http.MethodGet && strings.HasPrefix(p, "cluster/sdn/") && p != "cluster/sdn/lock" && p != "cluster/sdn/rollback" { + if s.Lock != "" && r.Form.Get("lock-token") != s.Lock { + fail(w, 500, "SDN config is locked") + return + } + } + running := r.Form.Get("running") == "1" + cfg := &s.Pending + if running { + cfg = &s.Running + } + switch { + case key == "GET version" || (r.Method == "GET" && len(seg) == 3 && seg[0] == "nodes" && seg[2] == "version"): + reply(w, map[string]any{"version": s.Version, "release": release(s.Version), "repoid": "abcdef"}) + case key == "GET cluster/status": + reply(w, []any{ + map[string]any{"type": "cluster", "name": "fake", "quorate": 1}, + map[string]any{"type": "node", "name": "n1", "online": 1}, + }) + case r.Method == "GET" && len(seg) == 4 && seg[0] == "nodes" && seg[2] == "sdn" && seg[3] == "zones": + var out []any + for id := range s.Running.Zones { + out = append(out, map[string]any{"zone": id, "status": "available"}) + } + reply(w, out) + case r.Method == "GET" && len(seg) == 5 && seg[0] == "nodes" && seg[2] == "tasks" && seg[4] == "status": + reply(w, map[string]any{"status": "stopped", "exitstatus": "OK"}) + case key == "GET access/permissions": + reply(w, s.Perms) + case key == "GET cluster/options": + reply(w, s.parsedOptions()) + case key == "PUT cluster/options": + for k, v := range r.PostForm { + if k == "delete" { + for _, d := range strings.Split(v[0], ",") { + delete(s.Options, d) + } + continue + } + s.Options[k] = v[0] + } + reply(w, nil) + case key == "POST cluster/sdn/lock": + if r.Form.Get("allow-pending") != "1" && s.hasPending() { + fail(w, 500, "configuration has pending changes") + return + } + if s.Lock != "" { + fail(w, 500, "could not acquire lock for SDN config") + return + } + s.locks++ + s.Lock = fmt.Sprintf("token-%d", s.locks) + reply(w, s.Lock) + case key == "POST cluster/sdn/rollback": + if r.Form.Get("lock-token") != s.Lock { + fail(w, 500, "bad token") + return + } + s.Pending = s.Running.clone() + if r.Form.Get("release-lock") != "0" { + s.Lock = "" + } + reply(w, nil) + case key == "PUT cluster/sdn": + s.Running = s.Pending.clone() + if r.Form.Get("release-lock") != "0" { + s.Lock = "" + } + reply(w, "UPID:n1:00001234:00005678:65000000:reloadnetworkall::root@pam!confsync:") + case p == "cluster/sdn/zones" && r.Method == "GET": + reply(w, list(cfg.Zones, "zone")) + case p == "cluster/sdn/zones" && r.Method == "POST": + create(w, r, cfg.Zones, "zone", "lock-token") + case len(seg) == 4 && seg[2] == "zones": + item(w, r, cfg.Zones, seg[3]) + case p == "cluster/sdn/vnets" && r.Method == "GET": + reply(w, list(cfg.VNets, "vnet")) + case p == "cluster/sdn/vnets" && r.Method == "POST": + if z := r.PostForm.Get("zone"); s.Pending.Zones[z] == nil { + fail(w, 400, "zone "+z+" does not exist") + return + } + create(w, r, cfg.VNets, "vnet", "lock-token") + case len(seg) == 4 && seg[2] == "vnets": + item(w, r, cfg.VNets, seg[3]) + case len(seg) == 5 && seg[2] == "vnets" && seg[4] == "subnets" && r.Method == "GET": + m := map[string]map[string]any{} + for id, f := range cfg.Subnets { + if f["vnet"] == seg[3] { + m[id] = f + } + } + reply(w, list(m, "subnet")) + case len(seg) == 5 && seg[2] == "vnets" && seg[4] == "subnets" && r.Method == "POST": + v := s.Pending.VNets[seg[3]] + if v == nil { + fail(w, 400, "vnet does not exist") + return + } + cidr := r.PostForm.Get("subnet") + id := fmt.Sprint(v["zone"]) + "-" + strings.Replace(cidr, "/", "-", 1) + if s.Pending.Subnets[id] != nil { + fail(w, 400, "already defined") + return + } + f := fields(r.PostForm, "subnet", "type", "lock-token") + f["vnet"] = seg[3] + s.Pending.Subnets[id] = f + reply(w, nil) + case len(seg) == 6 && seg[2] == "vnets" && seg[4] == "subnets": + item(w, r, cfg.Subnets, seg[5]) + default: + fail(w, 501, "not implemented in fake: "+key) + } +} + +func release(v string) string { + p := strings.SplitN(v, ".", 3) + if len(p) < 2 { + return v + } + return p[0] + "." + p[1] +} + +func (s *Server) hasPending() bool { + a, _ := json.Marshal(s.Pending) + b, _ := json.Marshal(s.Running) + return string(a) != string(b) +} + +// parsedOptions mimics parse_datacenter_config: some options come back as objects/lists. +func (s *Server) parsedOptions() map[string]any { + out := map[string]any{"description": "", "mac_prefix": "BC:24:11", "allowed-tags": []string{}} + for k, v := range s.Options { + switch k { + case "registered-tags": + out[k] = strings.Split(v, ";") + case "tag-style", "next-id", "user-tag-access", "migration", "ha", "crs": + m := map[string]any{} + for _, kv := range strings.Split(v, ",") { + p := strings.SplitN(kv, "=", 2) + if len(p) != 2 { + continue + } + if k == "user-tag-access" && p[0] == "user-allow-list" { + m[p[0]] = strings.Split(p[1], ";") + continue + } + if k == "next-id" { + var n json.Number = json.Number(p[1]) + m[p[0]] = n + continue + } + m[p[0]] = p[1] + } + out[k] = m + default: + out[k] = v + } + } + return out +} + +func list(m map[string]map[string]any, idKey string) []any { + ids := make([]string, 0, len(m)) + for id := range m { + ids = append(ids, id) + } + sort.Strings(ids) + out := []any{} + for _, id := range ids { + o := map[string]any{idKey: id, "digest": "0123"} + for k, v := range m[id] { + o[k] = v + } + out = append(out, o) + } + return out +} + +func fields(f url.Values, skip ...string) map[string]any { + out := map[string]any{} + sk := map[string]bool{} + for _, s := range skip { + sk[s] = true + } + for k, v := range f { + if sk[k] { + continue + } + if len(v) > 1 || k == "dhcp-range" { // array in the PVE schema + l := make([]any, len(v)) + for i := range v { + l[i] = v[i] + } + out[k] = l + } else { + out[k] = v[0] + } + } + return out +} + +func create(w http.ResponseWriter, r *http.Request, m map[string]map[string]any, idKey string, skip ...string) { + id := r.PostForm.Get(idKey) + if m[id] != nil { + fail(w, 400, idKey+" "+id+" already defined") + return + } + m[id] = fields(r.PostForm, append(skip, idKey)...) + reply(w, nil) +} + +func item(w http.ResponseWriter, r *http.Request, m map[string]map[string]any, id string) { + o := m[id] + if o == nil { + fail(w, 404, id+" does not exist") + return + } + switch r.Method { + case http.MethodPut: + for k, v := range fields(r.PostForm, "lock-token", "delete", "digest") { + o[k] = v + } + if d := r.PostForm.Get("delete"); d != "" { + for _, k := range strings.Split(d, ",") { + delete(o, k) + } + } + reply(w, nil) + case http.MethodDelete: + delete(m, id) + reply(w, nil) + default: + reply(w, o) + } +}