Write into a marked block of a text file instead of over it, so a shared hosts file keeps every line that is not the mesh's (hq issue 128)
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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 <id>` and `# END mesh <id>`, 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
|
||||
}
|
||||
Reference in New Issue
Block a user