mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
The journal of an unpack sat at <target>.unpacked.json, in a directory the module's account may write: a forged one would make the root engine remove or adopt any path it named. It now lives under <statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its target, and is refused whole when an entry is not filepath.IsLocal or a parent is not above the target. It goes when the archive is taken off, whether its state record still said what it unpacked or not. The launcher's stop grace for a host on trial rises from 20 s to 75 s and the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own 60 s wait for the apply in flight, so neither cuts a save off again. Tests folded as rows into existing ones (the repository is over its unit budget): the journal's rows, a forged entry and parent refused, the journal gone with a record that lost its Unpacked; the test that passed on main dropped; the stop test waits on its own line, not a sleep, and no longer consumes the save it waits for. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
516 lines
17 KiB
Go
516 lines
17 KiB
Go
package apply
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// What an archive put on the machine, and taking exactly that away (novox/hq issue 162).
|
|
//
|
|
// An archive unpacks many files into a directory the mesh did not necessarily make, so undeclaring
|
|
// one has a real question in it: remove what the archive put there, or remove the directory? The
|
|
// second deletes whatever else lives there — for the host's own versions directory, every other
|
|
// delivered version. So the host records what each archive unpacked and whether it made the
|
|
// directory, and removal takes away exactly that: the files the archive placed, then the
|
|
// directories the host made for them once they are empty. Never a file the archive did not place,
|
|
// never a directory that was there before, never one that still holds anything else. It is the
|
|
// rule every other kind follows: the mesh gives back what it found (ADR 0118), and data outlives
|
|
// the mesh that declared it (ADR 0030).
|
|
|
|
// ours is what of the tree at an archive's path the record says is the mesh's.
|
|
type ours struct {
|
|
// all is a record from before the host kept what an archive unpacked: since the swap of issue
|
|
// 220 the tree at the path was the archive and nothing else, so the whole of it is taken for
|
|
// the mesh's, as the swap that follows has always taken it.
|
|
all bool
|
|
// paths are the files and directories the previous archive put there, relative to the path.
|
|
paths map[string]bool
|
|
files []string
|
|
dirs []string
|
|
made bool
|
|
// parents are the directories above the path the host made to reach it, deepest first.
|
|
parents []string
|
|
}
|
|
|
|
// oursFrom reads the record of the archive before this apply, for this path only: a record of the
|
|
// same archive at a path it has moved from says nothing about what is at the new one.
|
|
func oursFrom(previous store.Applied, path string) ours {
|
|
if previous.Wrote == "" || previous.Target != path {
|
|
return ours{}
|
|
}
|
|
u := previous.Unpacked
|
|
if u == nil {
|
|
return ours{all: true, made: true}
|
|
}
|
|
o := ours{paths: map[string]bool{}, files: u.Files, dirs: u.Dirs, made: u.Made, parents: u.Parents}
|
|
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
|
|
o.paths[rel] = true
|
|
}
|
|
return o
|
|
}
|
|
|
|
// ownershipProbe is what says whether an archive is still its owner's. The directory, when the host
|
|
// made it; one of the archive's own files when the directory was there before — that one is held as
|
|
// found (ADR 0182), so its owner is never the archive's to judge.
|
|
func ownershipProbe(path string, u *store.Unpacked) string {
|
|
if u != nil && !u.Made && len(u.Files) > 0 {
|
|
return filepath.Join(path, u.Files[0])
|
|
}
|
|
return path
|
|
}
|
|
|
|
// stillUnpacked is what an unchanged archive has on the machine. The record's, when it has one; on
|
|
// a record from before the host kept it, read from the archive's own bytes now — and the directory
|
|
// is taken for the host's only when it holds exactly the archive and nothing else, which is what the
|
|
// swap of issue 220 leaves. Otherwise the directory is kept for somebody's, and only the archive's
|
|
// files are recorded as its.
|
|
func stillUnpacked(body []byte, path string, recorded *store.Unpacked) (*store.Unpacked, error) {
|
|
if recorded != nil {
|
|
kept := *recorded
|
|
return &kept, nil
|
|
}
|
|
files, dirs, err := listArchive(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u := &store.Unpacked{Files: pathsOf(files)}
|
|
if exactly, err := holdsExactly(path, files, dirs); err == nil && exactly {
|
|
u.Dirs = pathsOf(dirs)
|
|
u.Made = true
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// listArchive reads what an archive holds without unpacking it: each file's digest by its path, and
|
|
// every directory, named or implied, relative to where it unpacks. Refused on the same terms as
|
|
// unpack, so a listing never names a path an unpack would not write.
|
|
func listArchive(body []byte) (map[string]string, map[string]bool, error) {
|
|
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("this is not a gzipped tar: %w", err)
|
|
}
|
|
defer zipped.Close()
|
|
files, dirs := map[string]string{}, map[string]bool{}
|
|
reader := tar.NewReader(zipped)
|
|
for {
|
|
header, err := reader.Next()
|
|
if err == io.EOF {
|
|
return files, dirs, nil
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
rel := filepath.Clean(header.Name)
|
|
if rel == "." {
|
|
continue
|
|
}
|
|
if !insideRel(rel) {
|
|
return nil, nil, fmt.Errorf("%s names a path outside the archive", header.Name)
|
|
}
|
|
for d := filepath.Dir(rel); d != "."; d = filepath.Dir(d) {
|
|
dirs[filepath.ToSlash(d)] = true
|
|
}
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
dirs[filepath.ToSlash(rel)] = true
|
|
case tar.TypeReg:
|
|
sum := sha256.New()
|
|
if _, err := io.Copy(sum, io.LimitReader(reader, maxArchive)); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
files[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil))
|
|
default:
|
|
return nil, nil, fmt.Errorf("%s is a %c, and this host unpacks only files and directories",
|
|
header.Name, header.Typeflag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// holdsExactly is whether a directory holds the archive's files with the archive's bytes, its
|
|
// directories, and nothing else.
|
|
func holdsExactly(root string, files map[string]string, dirs map[string]bool) (bool, error) {
|
|
seen := 0
|
|
exact := true
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
switch {
|
|
case d.IsDir():
|
|
if !dirs[rel] {
|
|
exact = false
|
|
return filepath.SkipAll
|
|
}
|
|
case d.Type().IsRegular():
|
|
want, ok := files[rel]
|
|
if !ok || digestOfFile(path) != want {
|
|
exact = false
|
|
return filepath.SkipAll
|
|
}
|
|
seen++
|
|
default:
|
|
exact = false
|
|
return filepath.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exact && seen == len(files), nil
|
|
}
|
|
|
|
func digestOfFile(path string) string {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer file.Close()
|
|
sum := sha256.New()
|
|
if _, err := io.Copy(sum, file); err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(sum.Sum(nil))
|
|
}
|
|
|
|
// treeOf is every file and directory under root, relative to it, slash-separated and sorted.
|
|
func treeOf(root string) (files, dirs []string, err error) {
|
|
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil || rel == "." {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
dirs = append(dirs, filepath.ToSlash(rel))
|
|
} else {
|
|
files = append(files, filepath.ToSlash(rel))
|
|
}
|
|
return nil
|
|
})
|
|
sort.Strings(files)
|
|
sort.Strings(dirs)
|
|
if files == nil {
|
|
files = []string{}
|
|
}
|
|
return files, dirs, err
|
|
}
|
|
|
|
// foreignIn counts what under root the mesh did not put there. A directory that is not the mesh's
|
|
// counts once, with everything in it.
|
|
func foreignIn(root string, mine map[string]bool) (int, error) {
|
|
count := 0
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil || rel == "." {
|
|
return err
|
|
}
|
|
if !mine[filepath.ToSlash(rel)] {
|
|
count++
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
// mergeInto moves a freshly unpacked archive into a directory that is not only the mesh's, one entry
|
|
// at a time, and takes out what the previous archive placed that this one does not. Everything the
|
|
// mesh did not put there stays as it is. Collisions are looked for before anything is moved, so a
|
|
// refused archive leaves the directory exactly as it was.
|
|
func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.Unpacked, error) {
|
|
var collisions []string
|
|
for _, rel := range dirs {
|
|
info, err := os.Lstat(filepath.Join(path, filepath.FromSlash(rel)))
|
|
if err == nil && !info.IsDir() && !o.paths[rel] {
|
|
collisions = append(collisions, rel)
|
|
}
|
|
}
|
|
for _, rel := range files {
|
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
|
info, err := os.Lstat(dest)
|
|
switch {
|
|
case err != nil:
|
|
case o.paths[rel] && !info.IsDir():
|
|
case info.IsDir():
|
|
if !o.paths[rel] {
|
|
collisions = append(collisions, rel)
|
|
} else if n, err := foreignIn(dest, o.paths); err != nil || n > 0 {
|
|
collisions = append(collisions, rel)
|
|
}
|
|
case !info.Mode().IsRegular() ||
|
|
digestOfFile(dest) != digestOfFile(filepath.Join(fresh, filepath.FromSlash(rel))):
|
|
// Already holding exactly the archive's bytes is not a collision: it is what an
|
|
// interrupted earlier apply of this same archive left, or the same file either way.
|
|
collisions = append(collisions, rel)
|
|
}
|
|
}
|
|
if len(collisions) > 0 {
|
|
shown := collisions
|
|
if len(shown) > 5 {
|
|
shown = shown[:5]
|
|
}
|
|
return store.Unpacked{}, fmt.Errorf("%s already holds %d path(s) the archive would write over "+
|
|
"and the mesh did not put there (%s); nothing was unpacked, and what is there is left as it "+
|
|
"is (novox/hq issue 162)", path, len(collisions), strings.Join(shown, ", "))
|
|
}
|
|
|
|
var made []string
|
|
for _, rel := range dirs {
|
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
|
info, err := os.Lstat(dest)
|
|
if err == nil && info.IsDir() {
|
|
if o.paths[rel] {
|
|
made = append(made, rel)
|
|
}
|
|
continue
|
|
}
|
|
if err == nil {
|
|
// The previous archive's file where this one has a directory.
|
|
if err := os.Remove(dest); err != nil {
|
|
return store.Unpacked{}, err
|
|
}
|
|
}
|
|
mode := os.FileMode(0o755)
|
|
if from, err := os.Stat(filepath.Join(fresh, filepath.FromSlash(rel))); err == nil {
|
|
mode = from.Mode().Perm()
|
|
}
|
|
if err := os.Mkdir(dest, mode); err != nil {
|
|
return store.Unpacked{}, err
|
|
}
|
|
if err := own(dest, owner); err != nil {
|
|
return store.Unpacked{}, err
|
|
}
|
|
made = append(made, rel)
|
|
}
|
|
for _, rel := range files {
|
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
|
if info, err := os.Lstat(dest); err == nil && info.IsDir() {
|
|
// The previous archive's directory where this one has a file, holding nothing else.
|
|
if err := os.RemoveAll(dest); err != nil {
|
|
return store.Unpacked{}, err
|
|
}
|
|
}
|
|
if err := os.Rename(filepath.Join(fresh, filepath.FromSlash(rel)), dest); err != nil {
|
|
return store.Unpacked{}, err
|
|
}
|
|
}
|
|
|
|
// What the previous archive placed and this one does not.
|
|
now := map[string]bool{}
|
|
for _, rel := range append(append([]string{}, files...), dirs...) {
|
|
now[rel] = true
|
|
}
|
|
for _, rel := range o.files {
|
|
if now[rel] {
|
|
continue
|
|
}
|
|
if err := os.Remove(filepath.Join(path, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) {
|
|
return store.Unpacked{}, err
|
|
}
|
|
}
|
|
for _, rel := range deepestFirst(o.dirs) {
|
|
if now[rel] {
|
|
continue
|
|
}
|
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
|
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
|
// Still holding something the mesh did not put there: kept, and still the host's to
|
|
// take away once it is empty.
|
|
made = append(made, rel)
|
|
}
|
|
}
|
|
sort.Strings(made)
|
|
return store.Unpacked{Files: files, Dirs: made, Made: o.made}, nil
|
|
}
|
|
|
|
// removeArchive is what undeclaring an archive does (novox/hq issue 162): exactly the files it
|
|
// unpacked, then the directories the host made for them once they are empty.
|
|
//
|
|
// **Never fatal.** An archive that could not be removed stopped the whole apply, on every apply
|
|
// after, until it was declared again — so every module with tools was un-unassignable, and a race
|
|
// between two pushes froze a machine against every other change. Whatever cannot be taken away is
|
|
// said, left in place, and forgotten, as a former target is (issue 194).
|
|
//
|
|
// **Removed whole when it is the host's own, and in one step.** A directory the host made that
|
|
// holds nothing but the archive is renamed aside and then removed: a reader — the runtime serving a
|
|
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
|
|
// open stays readable until it closes it. The runtime is told the module went by its own membership,
|
|
// not by the files disappearing.
|
|
func removeArchive(a store.Applied, journals string) (string, string, error) {
|
|
action, detail, err := removeArchiveFiles(a)
|
|
// And its journal (issue 1585), once the record goes: whether the record said what it unpacked or not.
|
|
if err == nil {
|
|
dropJournal(journals, a.Target)
|
|
}
|
|
return action, detail, err
|
|
}
|
|
|
|
func removeArchiveFiles(a store.Applied) (string, string, error) {
|
|
if store.IsFormer(a.ID) {
|
|
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
|
|
// open; the launcher retires the host's own versions, not the apply (issue 194).
|
|
return "forgotten", "a former target left in place: only an archive the declaration dropped is " +
|
|
"taken away (novox/hq issues 162, 194)", nil
|
|
}
|
|
u := a.Unpacked
|
|
if u == nil {
|
|
return "forgotten", "left in place: recorded before the host kept what an archive unpacked, so " +
|
|
"its files cannot be told from anything else there (novox/hq issue 162)", nil
|
|
}
|
|
root := filepath.Clean(a.Target)
|
|
mine := map[string]bool{}
|
|
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
|
|
if !insideRel(filepath.FromSlash(rel)) {
|
|
return "forgotten", fmt.Sprintf("left in place: its record names %q, which is not inside %s",
|
|
rel, root), nil
|
|
}
|
|
mine[rel] = true
|
|
}
|
|
|
|
info, err := os.Lstat(root)
|
|
if os.IsNotExist(err) {
|
|
removeParents(root, u.Parents)
|
|
return "forgotten", "no longer there", nil
|
|
}
|
|
if err != nil {
|
|
return "forgotten", fmt.Sprintf("left in place: %v", err), nil
|
|
}
|
|
if !info.IsDir() {
|
|
return "forgotten", "left in place: no longer a directory, so not what the archive was unpacked into", nil
|
|
}
|
|
foreign, err := foreignIn(root, mine)
|
|
if err != nil {
|
|
return "forgotten", fmt.Sprintf("left in place: cannot read what is in it: %v", err), nil
|
|
}
|
|
|
|
if u.Made && foreign == 0 {
|
|
aside := root + ".removing"
|
|
if err := os.RemoveAll(aside); err == nil {
|
|
if err := os.Rename(root, aside); err == nil {
|
|
if err := os.RemoveAll(aside); err != nil {
|
|
return "forgotten", fmt.Sprintf("taken out of place, and what it unpacked could not be "+
|
|
"removed from %s: %v — remove it by hand", aside, err), nil
|
|
}
|
|
removeParents(root, u.Parents)
|
|
return "removed", fmt.Sprintf("no longer declared; the %d file(s) it unpacked, and the "+
|
|
"directory the host made for them", len(u.Files)), nil
|
|
}
|
|
}
|
|
// A rename that could not be made is taken file by file instead.
|
|
}
|
|
|
|
removed := 0
|
|
var failed []string
|
|
for _, rel := range u.Files {
|
|
err := os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
|
|
switch {
|
|
case err == nil:
|
|
removed++
|
|
case os.IsNotExist(err):
|
|
default:
|
|
failed = append(failed, err.Error())
|
|
}
|
|
}
|
|
for _, rel := range deepestFirst(u.Dirs) {
|
|
// Only once empty: what is still inside is somebody's.
|
|
_ = os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
|
|
}
|
|
detail := fmt.Sprintf("no longer declared; %d file(s) it unpacked removed", removed)
|
|
switch {
|
|
case u.Made && os.Remove(root) == nil:
|
|
removeParents(root, u.Parents)
|
|
detail += ", and the directory the host made for them"
|
|
case u.Made:
|
|
left, _ := os.ReadDir(root)
|
|
detail += fmt.Sprintf("; the directory is kept: %d item(s) inside that the mesh did not put there",
|
|
len(left))
|
|
default:
|
|
detail += "; the directory is kept: it was there before the archive"
|
|
}
|
|
if len(failed) > 0 {
|
|
// Said and not fatal: fatal, the record would stay and fail the same way on every apply
|
|
// after — the very wedge this removal exists to end.
|
|
return "forgotten", detail + "; could not remove, and left in place: " + strings.Join(failed, "; "), nil
|
|
}
|
|
return "removed", detail, nil
|
|
}
|
|
|
|
// removeParents takes away the directories above an archive the host made to reach it, deepest
|
|
// first, each only once it is empty and only if it is above the archive's directory.
|
|
func removeParents(root string, parents []string) {
|
|
for _, p := range parents {
|
|
clean := filepath.Clean(p)
|
|
if !filepath.IsAbs(clean) || !strings.HasPrefix(root, clean+string(os.PathSeparator)) {
|
|
continue
|
|
}
|
|
_ = os.Remove(clean)
|
|
}
|
|
}
|
|
|
|
// joinParents is the parents made now and those recorded before, deepest first, once each.
|
|
func joinParents(now, before []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, p := range append(append([]string{}, now...), before...) {
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
|
return out
|
|
}
|
|
|
|
// insideRel is whether a relative path stays inside the directory it is relative to.
|
|
func insideRel(rel string) bool {
|
|
clean := filepath.Clean(rel)
|
|
return clean != "." && !filepath.IsAbs(clean) && clean != ".." &&
|
|
!strings.HasPrefix(clean, ".."+string(os.PathSeparator))
|
|
}
|
|
|
|
func deepestFirst(rels []string) []string {
|
|
out := append([]string{}, rels...)
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return strings.Count(out[i], "/") > strings.Count(out[j], "/") ||
|
|
(strings.Count(out[i], "/") == strings.Count(out[j], "/") && out[i] > out[j])
|
|
})
|
|
return out
|
|
}
|
|
|
|
func pathsOf[V any](m map[string]V) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|