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 }