Files
mesh-host/internal/apply/into.go
T

236 lines
7.0 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{}}
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)
} else {
rec.Created = !existed
}
tracked := func(k string) bool { _, ok := rec.Before[k]; return ok || slices.Contains(rec.Absent, k) }
// Drift: the machine no longer holds what this host last set in its keys.
drifted := previous.Wrote != "" && existed && digestOf(keysOf(object, 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.
for _, k := range keysIn(declared) {
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 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
}
out.into = &rec
out.wrote = digestOf(keysOf(check, 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 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)
}
keys = append(keys, rec.Absent...)
sort.Strings(keys)
return slices.Compact(keys)
}
func keysOf(object map[string]json.RawMessage, keys []string) string {
var b bytes.Buffer
for _, k := range keys {
b.WriteString(k + "=" + canonical(object[k]) + "\n")
}
return b.String()
}
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
}