Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
@@ -1,5 +1,21 @@
|
|||||||
module pve-confsync
|
module pve-confsync
|
||||||
|
|
||||||
go 1.24
|
go 1.24.0
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require (
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.40.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -1,3 +1,53 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||||
|
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -24,6 +24,8 @@ type Result struct {
|
|||||||
Client string `json:"client"`
|
Client string `json:"client"`
|
||||||
Steps []Step `json:"steps"`
|
Steps []Step `json:"steps"`
|
||||||
Err error `json:"-"`
|
Err error `json:"-"`
|
||||||
|
|
||||||
|
notify func(Step)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Result) log(action string, err error) {
|
func (r *Result) log(action string, err error) {
|
||||||
@@ -32,6 +34,9 @@ func (r *Result) log(action string, err error) {
|
|||||||
s.Error = err.Error()
|
s.Error = err.Error()
|
||||||
}
|
}
|
||||||
r.Steps = append(r.Steps, s)
|
r.Steps = append(r.Steps, s)
|
||||||
|
if r.notify != nil {
|
||||||
|
r.notify(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskPoll is the poll interval of the SDN reload task.
|
// TaskPoll is the poll interval of the SDN reload task.
|
||||||
@@ -42,9 +47,15 @@ var TaskPoll = 2 * time.Second
|
|||||||
// committed with PUT /cluster/sdn. Any failure before the commit rolls the
|
// committed with PUT /cluster/sdn. Any failure before the commit rolls the
|
||||||
// pending SDN changes back (POST /cluster/sdn/rollback).
|
// pending SDN changes back (POST /cluster/sdn/rollback).
|
||||||
func Apply(ctx context.Context, c *pve.Client, plan Plan) Result {
|
func Apply(ctx context.Context, c *pve.Client, plan Plan) Result {
|
||||||
res := Result{Client: plan.Client}
|
return ApplyWithLog(ctx, c, plan, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyWithLog is Apply calling notify after each step (live journal of the web UI).
|
||||||
|
func ApplyWithLog(ctx context.Context, c *pve.Client, plan Plan, notify func(Step)) Result {
|
||||||
|
res := Result{Client: plan.Client, notify: notify}
|
||||||
if len(plan.Errors) > 0 {
|
if len(plan.Errors) > 0 {
|
||||||
res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors))
|
res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors))
|
||||||
|
res.log("plan check", res.Err)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
if _, err := c.CheckVersion(ctx); err != nil {
|
if _, err := c.CheckVersion(ctx); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pve-confsync/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key is the identity of a planned change ("kind/id"), as model.Object.Key.
|
||||||
|
func (c Change) Key() string { return string(c.Kind) + "/" + c.ID }
|
||||||
|
|
||||||
|
// Filter keeps the changes of the given objects, plus the ones they depend on:
|
||||||
|
// creating a vnet needs its zone when the zone is created too, a subnet needs its
|
||||||
|
// vnet; deleting a zone needs the deletion of its vnets and their subnets first.
|
||||||
|
// The execution order of the plan is kept. An empty key list keeps everything.
|
||||||
|
func Filter(p Plan, keys []string) Plan {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
byKey := map[string]Change{}
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
byKey[c.Key()] = c
|
||||||
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, k := range keys {
|
||||||
|
if _, ok := byKey[k]; ok {
|
||||||
|
want[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for changed := true; changed; {
|
||||||
|
changed = false
|
||||||
|
add := func(k string) {
|
||||||
|
if _, ok := byKey[k]; ok && !want[k] {
|
||||||
|
want[k] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range want {
|
||||||
|
c := byKey[k]
|
||||||
|
switch c.Op {
|
||||||
|
case OpCreate, OpUpdate:
|
||||||
|
// parents written in the same plan must exist first
|
||||||
|
if z, ok := c.Set["zone"].(string); ok && byKey[string(model.KindZone)+"/"+z].Op == OpCreate {
|
||||||
|
add(string(model.KindZone) + "/" + z)
|
||||||
|
}
|
||||||
|
if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate {
|
||||||
|
add(string(model.KindVNet) + "/" + v)
|
||||||
|
}
|
||||||
|
case OpDelete:
|
||||||
|
// children deleted in the same plan must go first
|
||||||
|
for _, d := range p.Changes {
|
||||||
|
if d.Op != OpDelete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case c.Kind == model.KindZone && d.Kind == model.KindVNet && d.Old["zone"] == c.ID,
|
||||||
|
c.Kind == model.KindVNet && d.Kind == model.KindSubnet && d.Old["vnet"] == c.ID:
|
||||||
|
add(d.Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// an error on the parent of a selected object blocks it too (a vnet whose
|
||||||
|
// zone cannot be created)
|
||||||
|
blocking := map[string]bool{}
|
||||||
|
for k := range want {
|
||||||
|
c := byKey[k]
|
||||||
|
if c.Op == OpDelete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if z, ok := c.Set["zone"].(string); ok {
|
||||||
|
blocking[string(model.KindZone)+"/"+z] = true
|
||||||
|
}
|
||||||
|
if v, ok := c.Set["vnet"].(string); ok {
|
||||||
|
blocking[string(model.KindVNet)+"/"+v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := Plan{Client: p.Client, Warnings: p.Warnings}
|
||||||
|
// errors about other objects ("kind/id: ...") do not block this selection
|
||||||
|
for _, e := range p.Errors {
|
||||||
|
if k, ok := ErrorKey(e); !ok || want[k] || blocking[k] || contains(keys, k) {
|
||||||
|
out.Errors = append(out.Errors, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
if want[c.Key()] {
|
||||||
|
out.Changes = append(out.Changes, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorKey returns the object an error of MakePlan is about ("kind/id: ...";
|
||||||
|
// ": " and not ":" because IPv6 subnet ids hold colons).
|
||||||
|
func ErrorKey(e string) (string, bool) {
|
||||||
|
for _, k := range model.Kinds {
|
||||||
|
if strings.HasPrefix(e, string(k)+"/") {
|
||||||
|
if i := strings.Index(e, ": "); i > 0 {
|
||||||
|
return e[:i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(l []string, s string) bool {
|
||||||
|
for _, x := range l {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest identifies the content of a plan: the web UI shows a dry-run, then applies
|
||||||
|
// only if the plan computed again just before the apply has the same digest, so
|
||||||
|
// that what is written is exactly what the user reviewed.
|
||||||
|
func (p Plan) Digest() string {
|
||||||
|
type ch struct {
|
||||||
|
Op Op `json:"op"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Set map[string]any `json:"set,omitempty"`
|
||||||
|
Unset []string `json:"unset,omitempty"`
|
||||||
|
}
|
||||||
|
l := make([]ch, 0, len(p.Changes))
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
l = append(l, ch{c.Op, c.Key(), c.Set, c.Unset})
|
||||||
|
}
|
||||||
|
errs := append([]string(nil), p.Errors...)
|
||||||
|
sort.Strings(errs)
|
||||||
|
// map keys are sorted by encoding/json: the digest is stable
|
||||||
|
b, _ := json.Marshal(struct {
|
||||||
|
Client string `json:"client"`
|
||||||
|
Changes []ch `json:"changes"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}{p.Client, l, errs})
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:12])
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pve-confsync/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keys(p Plan) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
out = append(out, c.Key())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterDependencies(t *testing.T) {
|
||||||
|
p := Plan{Client: "c", Changes: []Change{
|
||||||
|
{Kind: model.KindZone, ID: "z1", Op: OpCreate, Set: map[string]any{"type": "vlan"}},
|
||||||
|
{Kind: model.KindZone, ID: "z2", Op: OpUpdate, Set: map[string]any{"mtu": 1500}},
|
||||||
|
{Kind: model.KindVNet, ID: "v1", Op: OpCreate, Set: map[string]any{"zone": "z1"}},
|
||||||
|
{Kind: model.KindVNet, ID: "v2", Op: OpCreate, Set: map[string]any{"zone": "z2"}},
|
||||||
|
{Kind: model.KindSubnet, ID: "z1-10.0.0.0-24", Op: OpCreate, Set: map[string]any{"vnet": "v1"}},
|
||||||
|
}}
|
||||||
|
got := keys(Filter(p, []string{"sdn-subnet/z1-10.0.0.0-24"}))
|
||||||
|
want := []string{"sdn-zone/z1", "sdn-vnet/v1", "sdn-subnet/z1-10.0.0.0-24"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("got %v, want %v (plan order kept)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// an update of the parent zone is not pulled in
|
||||||
|
if got := keys(Filter(p, []string{"sdn-vnet/v2"})); len(got) != 1 {
|
||||||
|
t.Fatalf("got %v, want only the vnet", got)
|
||||||
|
}
|
||||||
|
// unknown keys select nothing; no keys select everything
|
||||||
|
if got := Filter(p, []string{"sdn-vnet/nope"}); len(got.Changes) != 0 {
|
||||||
|
t.Fatalf("unknown key selected %v", keys(got))
|
||||||
|
}
|
||||||
|
if got := Filter(p, nil); len(got.Changes) != len(p.Changes) {
|
||||||
|
t.Fatal("empty selection must keep the whole plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterDeleteChildrenFirst(t *testing.T) {
|
||||||
|
p := Plan{Changes: []Change{
|
||||||
|
{Kind: model.KindSubnet, ID: "z-10.0.0.0-24", Op: OpDelete, Old: map[string]any{"vnet": "v"}},
|
||||||
|
{Kind: model.KindVNet, ID: "v", Op: OpDelete, Old: map[string]any{"zone": "z"}},
|
||||||
|
{Kind: model.KindVNet, ID: "other", Op: OpDelete, Old: map[string]any{"zone": "y"}},
|
||||||
|
{Kind: model.KindZone, ID: "z", Op: OpDelete},
|
||||||
|
}}
|
||||||
|
got := keys(Filter(p, []string{"sdn-zone/z"}))
|
||||||
|
if len(got) != 3 || got[2] != "sdn-zone/z" {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterErrors(t *testing.T) {
|
||||||
|
p := Plan{
|
||||||
|
Changes: []Change{
|
||||||
|
{Kind: model.KindVNet, ID: "ceph", Op: OpCreate, Set: map[string]any{"zone": "stor"}},
|
||||||
|
{Kind: model.KindVNet, ID: "vx1", Op: OpCreate, Set: map[string]any{"zone": "vxz"}},
|
||||||
|
},
|
||||||
|
Errors: []string{`sdn-zone/stor: field "bridge" required`, "sdn-subnet/z-fd00::-64: bad", "global problem"},
|
||||||
|
}
|
||||||
|
if got := Filter(p, []string{"sdn-vnet/vx1"}); len(got.Errors) != 1 || got.Errors[0] != "global problem" {
|
||||||
|
t.Fatalf("vx1: errors %v", got.Errors)
|
||||||
|
}
|
||||||
|
// the zone of ceph cannot be created: its error blocks ceph
|
||||||
|
if got := Filter(p, []string{"sdn-vnet/ceph"}); len(got.Errors) != 2 {
|
||||||
|
t.Fatalf("ceph: errors %v", got.Errors)
|
||||||
|
}
|
||||||
|
if k, ok := ErrorKey("sdn-subnet/z-fd00::-64: bad"); !ok || k != "sdn-subnet/z-fd00::-64" {
|
||||||
|
t.Fatalf("ErrorKey IPv6: %q %v", k, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest(t *testing.T) {
|
||||||
|
a := Plan{Client: "c", Changes: []Change{{Kind: model.KindZone, ID: "z", Op: OpUpdate, Set: map[string]any{"mtu": 1500}}}}
|
||||||
|
b := Plan{Client: "c", Changes: []Change{{Kind: model.KindZone, ID: "z", Op: OpUpdate, Set: map[string]any{"mtu": 9000}}}}
|
||||||
|
if a.Digest() == b.Digest() {
|
||||||
|
t.Fatal("different plans, same digest")
|
||||||
|
}
|
||||||
|
a2 := a
|
||||||
|
a2.Warnings = []string{"w"}
|
||||||
|
if a.Digest() != a2.Digest() {
|
||||||
|
t.Fatal("warnings must not change the digest")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type Options struct {
|
|||||||
TokenID string // user@realm!tokenname
|
TokenID string // user@realm!tokenname
|
||||||
TokenSecret string // never logged
|
TokenSecret string // never logged
|
||||||
CAFile string // PEM bundle to trust (optional)
|
CAFile string // PEM bundle to trust (optional)
|
||||||
|
CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set
|
||||||
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
|
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
|
||||||
Insecure bool // skip TLS verification (lab only)
|
Insecure bool // skip TLS verification (lab only)
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
@@ -93,14 +94,18 @@ func New(o Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case o.CAFile != "":
|
case o.CAFile != "" || len(o.CAPEM) > 0:
|
||||||
pem, err := os.ReadFile(o.CAFile)
|
pem, src := o.CAPEM, "the CA certificate"
|
||||||
if err != nil {
|
if o.CAFile != "" {
|
||||||
|
var err error
|
||||||
|
if pem, err = os.ReadFile(o.CAFile); err != nil {
|
||||||
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
|
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
|
||||||
}
|
}
|
||||||
|
src = o.CAFile
|
||||||
|
}
|
||||||
pool := x509.NewCertPool()
|
pool := x509.NewCertPool()
|
||||||
if !pool.AppendCertsFromPEM(pem) {
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, o.CAFile)
|
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src)
|
||||||
}
|
}
|
||||||
tlsCfg.RootCAs = pool
|
tlsCfg.RootCAs = pool
|
||||||
case o.Insecure:
|
case o.Insecure:
|
||||||
|
|||||||
Reference in new issue
Block a user