A file or archive placed under a fresh account's home with an owner left the parents it created, such as ~/.config or ~/.local/share, owned by root, so the person's own programs could not write there. Parents that already existed, and any outside the owner's home, are left as before.
389 lines
12 KiB
Go
389 lines
12 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], previous.Into == nil)
|
|
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) {
|
|
v, had := object[k]
|
|
// **What the host may have written itself is not the machine's.** With no record of
|
|
// this file — the first apply, or a host that wrote and died before saving its state —
|
|
// a key already holding exactly what the mesh declares cannot be told from one the
|
|
// mesh set a moment ago. Remembered as the machine's, it would never be given back:
|
|
// undeclaring would leave the mesh's own value behind for ever. So it is the mesh's,
|
|
// and undeclaring takes it out (novox/hq ADR 0102).
|
|
if had && !(previous.Into == nil && canonical(v) == canonical(declared[k])) {
|
|
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 := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); 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.
|
|
// unrecorded says there is no record of this file yet, in which case a declared member already in
|
|
// the list may be one the host itself wrote before it could save its state, and is taken as the
|
|
// mesh's.
|
|
func addMembers(current, declared json.RawMessage, addedBefore []json.RawMessage,
|
|
unrecorded bool) (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)
|
|
} else if !unrecorded {
|
|
continue // the machine's own, and never the mesh's to take out
|
|
}
|
|
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
|
|
}
|