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>
662 lines
17 KiB
Go
662 lines
17 KiB
Go
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])
|
|
}
|