// 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) } }