engine, pve : sélection d'objets, empreinte de plan, journal d'apply, CA en ligne

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-13 15:53:59 +02:00
1 parent 0b53bad1c1
commit b597830360
6 files changed
+326 -8

No files matched your search

+12 -1
View File
@@ -24,6 +24,8 @@ type Result struct {
Client string `json:"client"`
Steps []Step `json:"steps"`
Err error `json:"-"`
notify func(Step)
}
func (r *Result) log(action string, err error) {
@@ -32,6 +34,9 @@ func (r *Result) log(action string, err error) {
s.Error = err.Error()
}
r.Steps = append(r.Steps, s)
if r.notify != nil {
r.notify(s)
}
}
// 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
// pending SDN changes back (POST /cluster/sdn/rollback).
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 {
res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors))
res.log("plan check", res.Err)
return res
}
if _, err := c.CheckVersion(ctx); err != nil {
+144
View File
@@ -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])
}
+92
View File
@@ -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")
}
}