Tests : faux serveur PVE 9.x, tests config et CLI
This commit is contained in:
1 parent
5a5422ca78
commit
30f770ed8f
3 files changed
+440
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user