package engine import ( "crypto/sha256" "encoding/hex" "encoding/json" "sort" "strings" "pve-confsync/internal/adapter" "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) } for _, p := range adapter.Parents(Desired(c)) { if byKey[p].Op == OpCreate { add(p) } } 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 } for _, p := range adapter.Parents(Desired(c)) { blocking[p] = 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]) } // Skip removes from a plan the changes of the kinds left out of a replication // ("not synchronized" in the dry-run), then keeps what the remaining changes // need as Filter does: a group to create for an account stays in the plan. // keys narrows the plan to some objects first (empty: every object). func Skip(p Plan, keys []string, skip []model.Kind) Plan { if len(skip) == 0 { return Filter(p, keys) } out := map[model.Kind]bool{} for _, k := range skip { out[k] = true } if len(keys) == 0 { for _, c := range p.Changes { keys = append(keys, c.Key()) } for _, e := range p.Errors { if k, ok := ErrorKey(e); ok { keys = append(keys, k) } } } var kept []string for _, k := range keys { if kind, _, _ := strings.Cut(k, "/"); !out[model.Kind(kind)] { kept = append(kept, k) } } if len(kept) == 0 { return Plan{Client: p.Client, Warnings: p.Warnings} } return Filter(p, kept) }