Réplication de nœuds : réseau et DNS par l'API, multipath, chrony, sysctl par SSH
Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.
- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
restauration si un rechargement échoue, timer systemd de restauration si le
nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dc1a7ff40e
commit
1e667ce8b9
24 files changed
+4973
-65
No files matched your search
@@ -0,0 +1,661 @@
|
||||
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])
|
||||
}
|
||||
Reference in new issue
Block a user