377 lines
11 KiB
Go
377 lines
11 KiB
Go
package apply
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// A file written into, never over (novox/hq ADR 0102).
|
|
//
|
|
// **The file is the machine's; the mesh owns keys in it.** The container runtime's configuration
|
|
// is the case that needed it: the mesh states one fact there — its registry is trusted over the
|
|
// private network — and writing the file whole replaced everything the machine had set, down to
|
|
// where the runtime keeps its data. So the host reads what is there, sets only the declared keys,
|
|
// keeps every other key as it found it, and records what each of its keys held before. Undeclared,
|
|
// each key goes back, and a file the mesh created goes only if nothing but its keys is left.
|
|
|
|
// applyInto writes a file's declared keys into the object already at its path.
|
|
func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) {
|
|
out := begin(r)
|
|
if r.Into != declaration.IntoJSON {
|
|
return out, fmt.Errorf("%s: into %q is not a format this host writes into", r.Path, r.Into)
|
|
}
|
|
var declared map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(r.Content), &declared); err != nil {
|
|
return out, fmt.Errorf("%s: the keys to write are not a JSON object: %w", r.Path, err)
|
|
}
|
|
|
|
existing, err := os.ReadFile(r.Path)
|
|
existed := err == nil
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return out, err
|
|
}
|
|
object := map[string]json.RawMessage{}
|
|
if existed && len(bytes.TrimSpace(existing)) > 0 {
|
|
if err := json.Unmarshal(existing, &object); err != nil || object == nil {
|
|
// Refused, never replaced: a file the host cannot read as an object is a file it
|
|
// cannot write into without losing whatever it is.
|
|
return out, fmt.Errorf("%s is not a JSON object, so the mesh cannot write its keys into it "+
|
|
"without replacing what is there; it was left as it is", r.Path)
|
|
}
|
|
}
|
|
|
|
rec := store.Into{Format: declaration.IntoJSON, Before: map[string]json.RawMessage{},
|
|
Added: map[string][]json.RawMessage{}}
|
|
if previous.Into != nil {
|
|
rec.Created = previous.Into.Created
|
|
for k, v := range previous.Into.Before {
|
|
rec.Before[k] = v
|
|
}
|
|
rec.Absent = slices.Clone(previous.Into.Absent)
|
|
for k, v := range previous.Into.Added {
|
|
rec.Added[k] = slices.Clone(v)
|
|
}
|
|
} else {
|
|
rec.Created = !existed
|
|
}
|
|
tracked := func(k string) bool {
|
|
_, before := rec.Before[k]
|
|
_, added := rec.Added[k]
|
|
return before || added || slices.Contains(rec.Absent, k)
|
|
}
|
|
|
|
// Drift: the machine no longer holds what this host last set in its keys.
|
|
drifted := previous.Wrote != "" && existed && digestOf(viewOf(object, rec, keysTracked(rec))) != previous.Wrote
|
|
|
|
// Keys the mesh set before and no longer declares go back to what they held.
|
|
for _, k := range keysTracked(rec) {
|
|
if _, still := declared[k]; still {
|
|
continue
|
|
}
|
|
giveBack(object, &rec, k)
|
|
}
|
|
// Declared keys: remember what each held the first time, then set it. A list is the
|
|
// machine's too — a predecessor's own trusted registries, say — so the mesh adds its members
|
|
// to it rather than replacing it, and remembers exactly which it added.
|
|
for _, k := range keysIn(declared) {
|
|
_, scalar := rec.Before[k]
|
|
if isList(declared[k]) && !scalar {
|
|
current, had := object[k]
|
|
if had && !isList(current) {
|
|
return out, fmt.Errorf("%s: the mesh adds to the list %q, and the machine holds something "+
|
|
"other than a list there; it was left as it is", r.Path, k)
|
|
}
|
|
if !tracked(k) && !had {
|
|
rec.Absent = append(rec.Absent, k)
|
|
}
|
|
merged, added, err := addMembers(current, declared[k], rec.Added[k])
|
|
if err != nil {
|
|
return out, fmt.Errorf("%s: %q: %w", r.Path, k, err)
|
|
}
|
|
rec.Added[k] = added
|
|
object[k] = merged
|
|
continue
|
|
}
|
|
if !tracked(k) {
|
|
if v, had := object[k]; had {
|
|
rec.Before[k] = v
|
|
} else {
|
|
rec.Absent = append(rec.Absent, k)
|
|
}
|
|
}
|
|
object[k] = declared[k]
|
|
}
|
|
|
|
want, err := render(object)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
same := existed && canonical(existing) == canonical(want)
|
|
if !same {
|
|
mode := os.FileMode(0o644)
|
|
if info, err := os.Stat(r.Path); err == nil {
|
|
mode = info.Mode().Perm() // the machine's file keeps the machine's mode
|
|
} else if r.Mode != "" {
|
|
if m, err := modeOf(r.Mode, mode); err == nil {
|
|
mode = m
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
|
return out, err
|
|
}
|
|
if err := writeAtomically(r.Path, want, mode); err != nil {
|
|
return out, err
|
|
}
|
|
}
|
|
// Read back: every declared key holds what was declared.
|
|
written, err := os.ReadFile(r.Path)
|
|
if err != nil {
|
|
return out, fmt.Errorf("wrote into %s and cannot read it back: %w", r.Path, err)
|
|
}
|
|
var check map[string]json.RawMessage
|
|
if err := json.Unmarshal(written, &check); err != nil {
|
|
return out, fmt.Errorf("%s is not a JSON object after writing into it: %w", r.Path, err)
|
|
}
|
|
for k, v := range declared {
|
|
if _, list := rec.Added[k]; list {
|
|
members, _ := membersOf(v)
|
|
have, err := membersOf(check[k])
|
|
if err != nil {
|
|
return out, fmt.Errorf("%s does not hold a list at %q after writing into it", r.Path, k)
|
|
}
|
|
for _, m := range members {
|
|
if !hasMember(have, m) {
|
|
return out, fmt.Errorf("%s does not hold the declared %s in %q after writing into it", r.Path, m, k)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if canonical(check[k]) != canonical(v) {
|
|
return out, fmt.Errorf("%s does not hold the declared %q after writing into it", r.Path, k)
|
|
}
|
|
}
|
|
|
|
if len(rec.Before) == 0 {
|
|
rec.Before = nil
|
|
}
|
|
if len(rec.Added) == 0 {
|
|
rec.Added = nil
|
|
}
|
|
out.into = &rec
|
|
out.wrote = digestOf(viewOf(check, rec, keysIn(declared)))
|
|
switch {
|
|
case !existed:
|
|
out.Action = "created"
|
|
out.Detail = "written into; the file was not there"
|
|
case same:
|
|
out.Action = "unchanged"
|
|
case drifted:
|
|
out.Action = "corrected"
|
|
out.Detail = "the mesh's keys had been changed on the machine; the rest of the file was kept"
|
|
default:
|
|
out.Action = "updated"
|
|
out.Detail = "the mesh's keys written in; every other key kept as it was"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// removeInto gives back what a file written into held before the mesh's keys.
|
|
func removeInto(a store.Applied) (string, string, error) {
|
|
existing, err := os.ReadFile(a.Target)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "forgotten", "no longer there", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
object := map[string]json.RawMessage{}
|
|
if len(bytes.TrimSpace(existing)) > 0 {
|
|
if err := json.Unmarshal(existing, &object); err != nil || object == nil {
|
|
return "kept", "no longer a JSON object, so the mesh's keys were left in it; " +
|
|
"remove them by hand", nil
|
|
}
|
|
}
|
|
rec := *a.Into
|
|
for _, k := range keysTracked(rec) {
|
|
giveBack(object, &rec, k)
|
|
}
|
|
if a.Into.Created && len(object) == 0 {
|
|
if err := os.Remove(a.Target); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "removed", "no longer declared; the mesh had created it and nothing else was in it", nil
|
|
}
|
|
want, err := render(object)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
info, err := os.Stat(a.Target)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := writeAtomically(a.Target, want, info.Mode().Perm()); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "restored", "no longer declared; the mesh's keys were given back what they held", nil
|
|
}
|
|
|
|
func giveBack(object map[string]json.RawMessage, rec *store.Into, k string) {
|
|
if added, list := rec.Added[k]; list {
|
|
// Only the members the mesh added go; the list and everything else in it stay, unless
|
|
// the mesh made the key and nothing is left in it.
|
|
wasAbsent := slices.Contains(rec.Absent, k)
|
|
if current, had := object[k]; had && isList(current) {
|
|
have, _ := membersOf(current)
|
|
have = slices.DeleteFunc(have, func(m json.RawMessage) bool { return hasMember(added, m) })
|
|
if len(have) == 0 && wasAbsent {
|
|
delete(object, k)
|
|
} else {
|
|
object[k] = listOf(have)
|
|
}
|
|
}
|
|
delete(rec.Added, k)
|
|
rec.Absent = slices.DeleteFunc(rec.Absent, func(a string) bool { return a == k })
|
|
return
|
|
}
|
|
if v, had := rec.Before[k]; had {
|
|
object[k] = v
|
|
delete(rec.Before, k)
|
|
return
|
|
}
|
|
delete(object, k)
|
|
rec.Absent = slices.DeleteFunc(rec.Absent, func(a string) bool { return a == k })
|
|
}
|
|
|
|
func keysTracked(rec store.Into) []string {
|
|
var keys []string
|
|
for k := range rec.Before {
|
|
keys = append(keys, k)
|
|
}
|
|
for k := range rec.Added {
|
|
keys = append(keys, k)
|
|
}
|
|
keys = append(keys, rec.Absent...)
|
|
sort.Strings(keys)
|
|
return slices.Compact(keys)
|
|
}
|
|
|
|
// viewOf is what the mesh holds itself to in a file written into: each scalar key's value, and for
|
|
// a list only whether each member the mesh added is still there — what the machine keeps beside
|
|
// them is not the mesh's to judge.
|
|
func viewOf(object map[string]json.RawMessage, rec store.Into, keys []string) string {
|
|
var b bytes.Buffer
|
|
for _, k := range keys {
|
|
if added, list := rec.Added[k]; list {
|
|
have, _ := membersOf(object[k])
|
|
b.WriteString(k + " holds")
|
|
for _, m := range added {
|
|
fmt.Fprintf(&b, " %s=%v", canonical(m), hasMember(have, m))
|
|
}
|
|
b.WriteString("\n")
|
|
continue
|
|
}
|
|
b.WriteString(k + "=" + canonical(object[k]) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isList(raw json.RawMessage) bool {
|
|
t := bytes.TrimSpace(raw)
|
|
return len(t) > 0 && t[0] == '['
|
|
}
|
|
|
|
func membersOf(raw json.RawMessage) ([]json.RawMessage, error) {
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return nil, nil
|
|
}
|
|
var members []json.RawMessage
|
|
if err := json.Unmarshal(raw, &members); err != nil {
|
|
return nil, err
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
func hasMember(list []json.RawMessage, m json.RawMessage) bool {
|
|
for _, have := range list {
|
|
if canonical(have) == canonical(m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func listOf(members []json.RawMessage) json.RawMessage {
|
|
if members == nil {
|
|
members = []json.RawMessage{}
|
|
}
|
|
raw, _ := json.Marshal(members)
|
|
return raw
|
|
}
|
|
|
|
// addMembers adds the declared members to the machine's list, dropping only members the mesh
|
|
// added before and no longer declares. It returns the list and exactly which members the mesh
|
|
// added — a declared member the machine already had is the machine's, and is never recorded.
|
|
func addMembers(current, declared json.RawMessage, addedBefore []json.RawMessage) (json.RawMessage,
|
|
[]json.RawMessage, error) {
|
|
have, err := membersOf(current)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
want, err := membersOf(declared)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
added := []json.RawMessage{}
|
|
for _, a := range addedBefore {
|
|
if hasMember(want, a) {
|
|
added = append(added, a)
|
|
continue
|
|
}
|
|
have = slices.DeleteFunc(have, func(m json.RawMessage) bool { return canonical(m) == canonical(a) })
|
|
}
|
|
for _, m := range want {
|
|
if !hasMember(have, m) {
|
|
have = append(have, m)
|
|
if !hasMember(added, m) {
|
|
added = append(added, m)
|
|
}
|
|
}
|
|
}
|
|
return listOf(have), added, nil
|
|
}
|
|
|
|
func keysIn(m map[string]json.RawMessage) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// canonical is a JSON value compacted, so formatting is not mistaken for a change.
|
|
func canonical(raw []byte) string {
|
|
var b bytes.Buffer
|
|
if err := json.Compact(&b, raw); err != nil {
|
|
return string(raw)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func render(object map[string]json.RawMessage) ([]byte, error) {
|
|
b, err := json.MarshalIndent(object, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(b, '\n'), nil
|
|
}
|