package nodesync import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "regexp" "sort" "strings" "pve-confsync/internal/model" ) // Profile is one node replication: a master node, target nodes, the // replicated items and their rules. type Profile struct { Name string `json:"name"` Master string `json:"master"` // "cluster/node" Targets []string `json:"targets"` // "cluster/node" Items []string `json:"items"` // Prune deletes on the targets what the master does not have: network // interfaces (never the physical ones) and the files of the glob items. Prune bool `json:"prune"` // Policies of the fields of the API items (item -> field -> policy), // over the defaults (addresses and gateways: override). Policies map[string]map[string]Policy `json:"policies"` // Protect: per file item, regular expressions of the lines kept from the // target (hostname, local address…). Protect map[string][]string `json:"protect"` // Mapping: per target, its interface names and its own values. Mapping map[string]TargetMap `json:"mapping"` Position int `json:"position"` } // TargetMap holds what is specific to one target. type TargetMap struct { // NICs renames the interfaces of the master (eno1 -> enp1s0f0), in the // interface names and in the fields that list interfaces. NICs map[string]string `json:"nics,omitempty"` // Values: "item/id" -> field -> value written on this target (cidr of // vmbr0…), whatever the master has. Values map[string]map[string]string `json:"values,omitempty"` } // Validate checks a profile. func (p *Profile) Validate() error { var errs []error p.Name = strings.TrimSpace(p.Name) if p.Name == "" { errs = append(errs, errors.New("name is required")) } if p.Master == "" { errs = append(errs, errors.New("master node is required")) } seen := map[string]bool{} for _, t := range p.Targets { if t == p.Master { errs = append(errs, fmt.Errorf("target %s is the master", t)) } if seen[t] { errs = append(errs, fmt.Errorf("target %s listed twice", t)) } seen[t] = true } if len(p.Items) == 0 { errs = append(errs, errors.New("select at least one item")) } for _, id := range p.Items { if _, err := ItemByID(id); err != nil { errs = append(errs, err) } } for item, fields := range p.Policies { for f, pol := range fields { switch pol { case Sync, Ignore, Override: default: errs = append(errs, fmt.Errorf("%s.%s: unknown policy %q", item, f, pol)) } if item == "network" && f == "type" && pol != Sync { errs = append(errs, errors.New("network.type: always synchronized")) } } } for item, res := range p.Protect { for _, re := range res { if _, err := regexp.Compile(re); err != nil { errs = append(errs, fmt.Errorf("%s: protected lines: %w", item, err)) } } } return errors.Join(errs...) } // Policy resolves the policy of a field. func (p *Profile) Policy(item, field string) Policy { if item == "network" && field == "type" { return Sync } if pol, ok := p.Policies[item][field]; ok { return pol } return defaultPolicy(item, field) } // Has tells whether an item is replicated. func (p *Profile) Has(item string) bool { for _, x := range p.Items { if x == item { return true } } return false } // Op is the action planned on one object. type Op string const ( OpCreate Op = "create" OpUpdate Op = "update" OpDelete Op = "delete" ) // Change is one planned action: an interface or the DNS servers (Set, Unset, // Old), or a file (Diff, Hash of the target file, content to write). type Change struct { Item string `json:"kind"` // item id ("kind" like the cluster plans, for the UI) ID string `json:"id"` // interface, "dns" or file path Op Op `json:"op"` Set map[string]any `json:"set,omitempty"` Unset []string `json:"unset,omitempty"` Old map[string]any `json:"old,omitempty"` Diff []Line `json:"diff,omitempty"` Hash string `json:"hash,omitempty"` // current file on the target ("absent" when created) Mode string `json:"mode,omitempty"` Owner string `json:"owner,omitempty"` // Full is the whole writable configuration of an interface after the // change: PUT /nodes/{node}/network/{iface} recomputes the method and the // families from the parameters, so every field is sent again. Full map[string]any `json:"-"` Content []byte `json:"-"` } // Key identifies the object of a change ("item/id"). func (c Change) Key() string { return c.Item + "/" + c.ID } // Issue is a blocking error or a warning, about one object (Key) or the // whole plan. type Issue struct { Key string `json:"key,omitempty"` Message string `json:"message"` } // Plan is the list of changes for one target, in execution order. type Plan struct { Target string `json:"client"` Changes []Change `json:"changes"` Errors []Issue `json:"errors,omitempty"` Warnings []Issue `json:"warnings,omitempty"` } // Empty tells whether there is nothing to do. func (p Plan) Empty() bool { return len(p.Changes) == 0 } // Network tells whether the plan reloads the network of the node. func (p Plan) Network() bool { for _, c := range p.Changes { if it, _ := ItemByID(c.Item); it.Network { return true } } return false } // Reloads lists the commands run after the writes, in order. func (p Plan) Reloads() []string { seen := map[string]bool{} var out []string for _, it := range Items { for _, c := range p.Changes { if c.Item != it.ID { continue } cmd := it.Reload if it.Kind == KindNetwork { cmd = "ifreload -a" } if cmd != "" && !seen[cmd] { seen[cmd] = true out = append(out, cmd) } break } } return out } func (p *Plan) errorf(key, format string, a ...any) { p.Errors = append(p.Errors, Issue{key, fmt.Sprintf(format, a...)}) } func (p *Plan) warnf(key, format string, a ...any) { p.Warnings = append(p.Warnings, Issue{key, fmt.Sprintf(format, a...)}) } // MakePlan computes the changes that bring the target in line with the master. func MakePlan(prof *Profile, master, target Snapshot) Plan { plan := Plan{Target: target.Key} tm := prof.Mapping[target.Key] if target.Pending && prof.Has("network") { plan.errorf("", "pending network changes on %s (interfaces.new): apply or revert them in PVE first", target.Key) } if master.Pending && prof.Has("network") { plan.warnf("", "the master has pending network changes: its pending configuration is replicated") } for _, id := range prof.Items { it, _ := ItemByID(id) switch it.Kind { case KindNetwork: planNetwork(&plan, prof, tm, master, target) case KindDNS: planDNS(&plan, prof, tm, master, target) case KindFile: planFiles(&plan, prof, it, master.Files[it.ID], target.Files[it.ID]) } } sortChanges(plan.Changes) return plan } // rank orders the interfaces: what is referenced first (ports, bonds, VLAN // devices), bridges last; deletions run in the reverse order. func netRank(typ string) int { switch typ { case "eth", "alias": return 0 case "bond", "OVSBond": return 1 case "vlan": return 2 case "bridge", "OVSBridge": return 3 } return 4 } func sortChanges(chs []Change) { typ := func(c Change) string { if t, ok := c.Full["type"].(string); ok { return t } t, _ := c.Old["type"].(string) return t } sort.SliceStable(chs, func(i, j int) bool { a, b := chs[i], chs[j] if (a.Op == OpDelete) != (b.Op == OpDelete) { return b.Op == OpDelete } if a.Item != b.Item { return itemRank(a.Item) < itemRank(b.Item) } if a.Item == "network" { ra, rb := netRank(typ(a)), netRank(typ(b)) if a.Op == OpDelete { ra, rb = rb, ra } if ra != rb { return ra < rb } } return a.ID < b.ID }) } // netLists are the fields naming other interfaces (space separated lists or // one name): renamed with the NIC mapping of the target. var netLists = map[string]bool{ "bridge_ports": true, "slaves": true, "ovs_ports": true, "ovs_bonds": true, "bond-primary": true, "vlan-raw-device": true, "ovs_bridge": true, } // rename maps an interface name of the master to the target: eno1, and the // VLAN interfaces built on it (eno1.100). func rename(name string, nics map[string]string) string { if to, ok := nics[name]; ok { return to } if base, vlan, ok := strings.Cut(name, "."); ok { if to, ok := nics[base]; ok { return to + "." + vlan } } return name } func renameList(v string, nics map[string]string) string { parts := strings.Fields(v) for i, p := range parts { parts[i] = rename(p, nics) } return strings.Join(parts, " ") } func planNetwork(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) { cur := map[string]model.Object{} for _, o := range target.Network { cur[o.ID] = o } inMaster := map[string]bool{} for _, m := range master.Network { id := rename(m.ID, tm.NICs) key := "network/" + id inMaster[id] = true desired := map[string]any{} managed := map[string]bool{} for f, v := range m.Fields { if prof.Policy("network", f) != Sync { continue } if netLists[f] { v = renameList(v.(string), tm.NICs) } desired[f] = v } for f := range netWritable { if prof.Policy("network", f) == Sync { managed[f] = true } } for f, v := range tm.Values[key] { if prof.Policy("network", f) == Ignore || !netWritable[f] || f == "type" { continue } desired[f] = v managed[f] = true } typ, _ := desired["type"].(string) o, exists := cur[id] if !exists { if physical(typ) { plan.errorf(key, "physical interface %s absent on %s: add it to the NIC mapping of the target", id, target.Key) continue } plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpCreate, Set: desired, Full: desired}) continue } if t, _ := o.Fields["type"].(string); t != typ { plan.errorf(key, "type differs (%s on the target, %s on the master): re-create the interface by hand", t, typ) continue } if mo, to := strings.Join(master.Options[m.ID], "\n"), strings.Join(target.Options[id], "\n"); mo != to { plan.warnf(key, "options the API does not write differ (%s): not replicated", optionNames(master.Options[m.ID], target.Options[id])) } ch := Change{Item: "network", ID: id, Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}} full := map[string]any{} for f, v := range o.Fields { full[f] = v } for f, v := range desired { if !model.Equal(v, o.Fields[f]) { ch.Set[f] = v if old, ok := o.Fields[f]; ok { ch.Old[f] = old } } full[f] = v } for f := range managed { if _, want := desired[f]; want { continue } if old, has := o.Fields[f]; has { ch.Unset = append(ch.Unset, f) ch.Old[f] = old delete(full, f) } } sort.Strings(ch.Unset) if len(ch.Set) > 0 || len(ch.Unset) > 0 { ch.Full = full plan.Changes = append(plan.Changes, ch) } } if !prof.Prune { return } for id, o := range cur { if inMaster[id] { continue } if t, _ := o.Fields["type"].(string); physical(t) { continue } plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpDelete, Old: o.Fields}) } } // optionNames gives the option keywords that differ (bond-miimon, post-up…). func optionNames(a, b []string) string { set := map[string]bool{} count := map[string]int{} for _, l := range a { count[l]++ } for _, l := range b { count[l]-- } for l, n := range count { if n != 0 { set[strings.Fields(l + " ")[0]] = true } } var out []string for k := range set { out = append(out, k) } sort.Strings(out) return strings.Join(out, ", ") } func planDNS(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) { const key = "dns/dns" desired := map[string]any{} for _, f := range dnsFields { pol := prof.Policy("dns", f) if v, ok := master.DNS[f]; ok && pol == Sync { desired[f] = v } if pol != Sync { if v, ok := target.DNS[f]; ok { desired[f] = v // PUT /nodes/{node}/dns rewrites every server } } if v, ok := tm.Values[key][f]; ok && pol != Ignore { desired[f] = v } } ch := Change{Item: "dns", ID: "dns", Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}, Full: desired} for _, f := range dnsFields { v, want := desired[f] old, has := target.DNS[f] switch { case want && !model.Equal(v, old): ch.Set[f] = v if has { ch.Old[f] = old } case !want && has: ch.Unset = append(ch.Unset, f) ch.Old[f] = old } } if len(ch.Set) == 0 && len(ch.Unset) == 0 { return } if desired["search"] == nil { plan.errorf(key, "a search domain is required by the API (PUT /nodes/{node}/dns)") return } plan.Changes = append(plan.Changes, ch) } func planFiles(plan *Plan, prof *Profile, it Item, master, target map[string]File) { var protect []*regexp.Regexp for _, s := range prof.Protect[it.ID] { if re, err := regexp.Compile(s); err == nil { protect = append(protect, re) } } if len(master) == 0 && !it.Glob { plan.warnf(it.ID+"/"+it.Paths[0], "%s absent on the master: not replicated", it.Paths[0]) return } paths := make([]string, 0, len(master)) for p := range master { paths = append(paths, p) } sort.Strings(paths) for _, p := range paths { m := master[p] t, exists := target[p] var content []byte switch { case it.Additive && exists: content = additive(t.Content, m.Content) case exists: content = protectLines(m.Content, t.Content, protect) default: content = protectLines(m.Content, nil, protect) } if exists && hash(content) == t.Hash { continue } ch := Change{Item: it.ID, ID: p, Content: content, Mode: m.Mode, Owner: m.Owner, Hash: "absent", Op: OpCreate} var old []byte if exists { // an update keeps the mode and owner of the target file ch.Op, ch.Hash, ch.Mode, ch.Owner, old = OpUpdate, t.Hash, t.Mode, t.Owner, t.Content } ch.Diff = Diff(old, content) plan.Changes = append(plan.Changes, ch) } if !prof.Prune || !it.Glob { return } for p, t := range target { if _, ok := master[p]; ok { continue } plan.Changes = append(plan.Changes, Change{Item: it.ID, ID: p, Op: OpDelete, Hash: t.Hash, Diff: Diff(t.Content, nil)}) } } // protectLines: each line of the master matching a protected expression takes // the value of the first line of the target matching the same expression // (dropped when the target has none). func protectLines(master, target []byte, protect []*regexp.Regexp) []byte { if len(protect) == 0 { return master } tl := lines(target) used := map[int]bool{} var out []string for _, l := range lines(master) { var re *regexp.Regexp for _, r := range protect { if r.MatchString(l) { re = r break } } if re == nil { out = append(out, l) continue } for i, x := range tl { if !used[i] && re.MatchString(x) { used[i] = true out = append(out, x) break } } } return joinLines(out) } // additive appends to the target the lines of the master it does not have. func additive(target, master []byte) []byte { tl := lines(target) have := map[string]bool{} for _, l := range tl { have[l] = true } out := tl for _, l := range lines(master) { if !have[l] { have[l] = true out = append(out, l) } } return joinLines(out) } func joinLines(l []string) []byte { if len(l) == 0 { return nil } return []byte(strings.Join(l, "\n") + "\n") } // Filter keeps the changes of the given objects ("item/id"), plus the // interfaces they need that the plan creates (ports of a bridge, raw device // of a VLAN). Issues about other objects do not block the selection. 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 { want[k] = true } for changed := true; changed; { changed = false for k := range want { c, ok := byKey[k] if !ok || c.Item != "network" || c.Op == OpDelete { continue } for f, v := range c.Full { if !netLists[f] { continue } for _, n := range strings.Fields(v.(string)) { dep := "network/" + n if d, ok := byKey[dep]; ok && d.Op == OpCreate && !want[dep] { want[dep] = true changed = true } } } } } out := Plan{Target: p.Target} for _, e := range p.Errors { if e.Key == "" || want[e.Key] { out.Errors = append(out.Errors, e) } } for _, w := range p.Warnings { if w.Key == "" || want[w.Key] { out.Warnings = append(out.Warnings, w) } } for _, c := range p.Changes { if want[c.Key()] { out.Changes = append(out.Changes, c) } } return out } // Digest identifies the content of a plan: the apply only runs when the plan // computed again is the one reviewed in the dry-run. 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"` Hash string `json:"hash,omitempty"` New string `json:"new,omitempty"` } l := make([]ch, 0, len(p.Changes)) for _, c := range p.Changes { x := ch{Op: c.Op, Key: c.Key(), Set: c.Set, Unset: c.Unset, Hash: c.Hash} if c.Content != nil || c.Op != OpDelete && c.Hash != "" { x.New = hash(c.Content) } l = append(l, x) } errs := make([]string, 0, len(p.Errors)) for _, e := range p.Errors { errs = append(errs, e.Key+" "+e.Message) } sort.Strings(errs) b, _ := json.Marshal(struct { Target string `json:"target"` Changes []ch `json:"changes"` Errors []string `json:"errors"` }{p.Target, l, errs}) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:12]) }