package apply import ( "errors" "fmt" "os" "path/filepath" "strings" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) // A file written into a marked block, never over (novox/hq issue 128, ADR 0102). // // **The file is the machine's; the mesh owns lines in it.** The machine's hosts file is the case // that needed it. The mesh wrote it whole — its own header, localhost, the machine's name and every // name in the mesh — and on a workstation that file is shared: the distribution's lines, a local // development tool's own marked blocks rewritten whenever its projects change, the operator's // hand-added names. Written whole, all of those went at the next change to the mesh's names, with // no failure anywhere: the tool believed it had written its block, and the mesh believed it owned // the file. It is ADR 0102's failure exactly, in a file ADR 0102's JSON verb cannot speak. // // So the host finds the lines between `# BEGIN mesh ` and `# END mesh `, rewrites those and // nothing else, and records what they held before. Every line outside the markers is kept byte for // byte — including another tool's `# BEGIN …` blocks, which are that tool's. Undeclared, the region // is given back what it held, or taken out with its markers when it held nothing, and a file the // mesh created goes only if nothing but whitespace is left. // applyBlock writes a file's declared lines into its region of the file already at its path. func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) { out := begin(r) opening, closing := declaration.BlockMarkers(r.ID) want := blockBody(r.Content) raw, err := os.ReadFile(r.Path) existed := err == nil if err != nil && !errors.Is(err, os.ErrNotExist) { return out, err } existing := string(raw) lines := linesOf(existing) at, found, err := regionIn(lines, opening, closing) if err != nil { // Refused, never guessed at: markers the host cannot pair are markers it cannot write // between without risking lines that are not the mesh's. return out, fmt.Errorf("%s: %w; it was left as it is", r.Path, err) } // A record of a block is carried; anything else — no record, a file once written whole, one // once written into as JSON — is a file the host is seeing for the first time as a block. rec := store.Into{Format: declaration.IntoBlock} recorded := previous.Into != nil && previous.Into.Format == declaration.IntoBlock if recorded && existed { rec.Created = previous.Into.Created rec.Region = previous.Into.Region rec.Separated = previous.Into.Separated rec.At = previous.Into.At } else { // A file gone since the last apply is made again, and made by the mesh: what it held // before went with it, so there is nothing to give back but the file's absence. rec.Created = !existed if found { // **What the host may have written itself is not the machine's** — the same reasoning // as a key in a JSON file (novox/hq ADR 0102). With no record, a region already holding // exactly the declared lines cannot be told from one this host wrote a moment ago and // died before saving; remembered as the machine's, it would be put back on undeclare // for ever. So it is the mesh's, and undeclaring takes it out. if held := at.body(lines); held != want { rec.Region = &held } } } // Drift: the machine no longer holds, between the mesh's markers, what this host last put // there. Judged only against a record of a block: a digest of a whole file says nothing about // a region of it. drifted := recorded && previous.Wrote != "" && existed && (!found || digestOf(at.body(lines)) != previous.Wrote) var next string switch { case !existed: next = regionOf(opening, closing, want) case found: // Where it is, whatever At says: the region is never moved, because moving it moves the // machine's lines around it. next = strings.Join(lines[:at.begin+1], "") + want + strings.Join(lines[at.end:], "") case r.At == declaration.AtStart: // Above everything, and one blank line between the region and the machine's first line // unless there is one already — a line in some files means what the lines above it say. rec.At, rec.Separated = declaration.AtStart, false next = regionOf(opening, closing, want) if existing != "" && !strings.HasPrefix(existing, "\n") { next += "\n" rec.Separated = true } next += existing default: // At the end, apart from whatever is there: the file's last line is ended if it was not, // and one blank line separates the region from the machine's lines unless there is one. rec.At, rec.Separated = "", false next = existing if next != "" && !strings.HasSuffix(next, "\n") { next += "\n" } if next != "" && next != "\n" && !strings.HasSuffix(next, "\n\n") { next += "\n" rec.Separated = true } next += regionOf(opening, closing, want) } same := existed && next == existing if !same { var info os.FileInfo 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 mode, err = modeOf(r.Mode, mode); err != nil { return out, err } if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { return out, err } if err := writeAtomically(r.Path, []byte(next), mode); err != nil { return out, err } if existed { // The write is a new file renamed over the old, so it belongs to whoever wrote it. The // machine's file keeps the machine's owner, as it keeps its mode. if err := keepOwner(r.Path, info); err != nil { return out, err } } else if err := own(r.Path, r.Owner); err != nil { return out, err } } // Read back: the region holds what was declared, and nothing outside it moved. 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) } if string(written) != next { return out, fmt.Errorf("%s does not hold the mesh's region as written after writing into it", r.Path) } out.into = &rec out.wrote = digestOf(want) 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 region had been changed on the machine; every line outside it was kept" case !found: out.Action = "updated" where := "end" if rec.At == declaration.AtStart { where = "start" } out.Detail = "the mesh's region added at the " + where + "; every other line kept as it was" default: out.Action = "updated" out.Detail = "the mesh's region rewritten; every line outside it kept as it was" } return out, nil } // removeBlock gives back what a file written into a block held before the mesh's region. func removeBlock(a store.Applied) (string, string, error) { raw, err := os.ReadFile(a.Target) if errors.Is(err, os.ErrNotExist) { return "forgotten", "no longer there", nil } if err != nil { return "", "", err } info, err := os.Stat(a.Target) if err != nil { return "", "", err } opening, closing := declaration.BlockMarkers(a.ID) lines := linesOf(string(raw)) at, found, err := regionIn(lines, opening, closing) if err != nil { return "kept", err.Error() + ", so nothing was taken out of it; remove the mesh's region by hand", nil } next, action, detail := string(raw), "forgotten", "the mesh's region was no longer in it" switch { case found && a.Into.Region != nil: next = strings.Join(lines[:at.begin+1], "") + *a.Into.Region + strings.Join(lines[at.end:], "") action, detail = "restored", "no longer declared; the region was given back what it held" case found: from, to := at.begin, at.end+1 // The blank line the host put beside the region, and only that one: if what stands there // now is not blank, it is somebody's, and it stays. if a.Into.Separated { if a.Into.At == declaration.AtStart { if to < len(lines) && lines[to] == "\n" { to++ } } else if from > 0 && lines[from-1] == "\n" { from-- } } next = strings.Join(lines[:from], "") + strings.Join(lines[to:], "") action, detail = "restored", "no longer declared; the mesh's region was taken out and every other line kept" } if a.Into.Created && strings.TrimSpace(next) == "" { 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 } if next == string(raw) { return action, detail, nil } if err := writeAtomically(a.Target, []byte(next), info.Mode().Perm()); err != nil { return "", "", err } if err := keepOwner(a.Target, info); err != nil { return "", "", err } return action, detail, nil } // blockBody is the declared lines as they stand in the region: ending in exactly one line end, or // nothing at all when there are no lines. func blockBody(content string) string { trimmed := strings.TrimRight(content, "\n") if trimmed == "" { return "" } return trimmed + "\n" } func regionOf(begin, end, body string) string { return begin + "\n" + body + end + "\n" } // linesOf splits text into lines that keep their line ends, so joining them again gives back // exactly the bytes that were read — a last line without one included. func linesOf(text string) []string { return strings.SplitAfter(text, "\n") } // region is where the mesh's markers stand, as indices into the lines of a file. type region struct{ begin, end int } // body is what stands between the markers. func (r region) body(lines []string) string { return strings.Join(lines[r.begin+1:r.end], "") } // regionIn finds the mesh's markers for one resource. A line is a marker only if it is exactly the // marker, so another tool's block and another resource's region are never it. Markers that do not // form one pair — a begin with no end, an end before its begin, either twice — are an error rather // than a best guess, because a guess is how the host would rewrite lines that are not its own. func regionIn(lines []string, begin, end string) (region, bool, error) { at := region{begin: -1, end: -1} for i, line := range lines { switch strings.TrimSuffix(line, "\n") { case begin: if at.begin >= 0 { return at, false, fmt.Errorf("%q is in it more than once", begin) } at.begin = i case end: if at.end >= 0 { return at, false, fmt.Errorf("%q is in it more than once", end) } at.end = i } } switch { case at.begin < 0 && at.end < 0: return at, false, nil case at.begin < 0: return at, false, fmt.Errorf("%q is in it with no %q before it", end, begin) case at.end < 0: return at, false, fmt.Errorf("%q is in it with no %q after it", begin, end) case at.end < at.begin: return at, false, fmt.Errorf("%q stands before %q", end, begin) } return at, true, nil } // keepOwner gives a file rewritten through a new one back to whoever owned what it replaced. // Changed only where it differs, so a host that is not root can still write a file it owns. func keepOwner(path string, was os.FileInfo) error { uid, gid, ok := ownerOf(was) if !ok { return nil } now, err := os.Stat(path) if err != nil { return err } if u, g, ok := ownerOf(now); ok && u == uid && g == gid { return nil } if err := os.Chown(path, uid, gid); err != nil { return fmt.Errorf("cannot give %s back to its owner %d:%d: %w", path, uid, gid, err) } return nil }