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 }