An archive had no removal, so an unassigned one failed as an orphan and aborted every apply after: a module with tools could not be unassigned, and a race between two pushes froze a machine against every change. The record now keeps what an archive unpacked: its files, the directories the host made inside its path, whether the host made the path itself, and the parents it made to reach it. Removal takes exactly that away, directories only once empty, never one that was there before; a directory that is the host's alone is renamed aside first so a reader sees the whole bundle or none of it. Whatever cannot be removed is said and forgotten, never fatal. A directory found before the archive is no longer swapped away with what was in it: the archive is moved in file by file, and one that would write over a file the mesh did not put there is refused before anything moves. A record from before this change learns its files from the archive's bytes on the next apply; one already orphaned is left in place, said and forgotten. A former target is still left in place: the version before is what a rollback starts (ADR 0141).
507 lines
17 KiB
Go
507 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) (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
|
|
}
|