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).
293 lines
10 KiB
Go
293 lines
10 KiB
Go
package apply
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// A set of files, fetched by digest and unpacked.
|
|
//
|
|
// For what inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
|
|
// files inlined would make every declaration enormous and rewrite all of them when one changed.
|
|
//
|
|
// **This is the one place the host reaches out on its own.** Everywhere else it holds a single
|
|
// outbound connection to the broker and fetches nothing; a container image is pulled by the
|
|
// runtime rather than by this process. So the discipline has to be explicit and it is the same
|
|
// one the bootstrap uses for images: **pinned by digest, and the digest is checked before
|
|
// anything is written.** What is fetched is bytes from a network the mesh does not control, and
|
|
// the only thing making them safe to unpack is that they hash to what was declared.
|
|
|
|
// maxArchive is how much will be read before giving up.
|
|
//
|
|
// Because a fetch with no limit is a machine somebody can fill up from the far end. Chosen large
|
|
// enough for a desktop theme and small enough to notice.
|
|
const maxArchive = 512 << 20
|
|
|
|
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
|
|
out := begin(r)
|
|
out.Action = "unchanged"
|
|
|
|
body, err := fetch(ctx, r.Source)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
got := "sha256:" + hex.EncodeToString(sum[:])
|
|
if got != r.Digest {
|
|
// Refused before a single file is written. A digest that does not match means the thing
|
|
// at that address is not the thing that was declared, and unpacking it would be applying
|
|
// something nobody reviewed.
|
|
return out, fmt.Errorf(
|
|
"%s was declared as %s and what arrived is %s; nothing was unpacked",
|
|
r.Source, r.Digest, got)
|
|
}
|
|
out.wrote = got
|
|
|
|
// Already what it should be. The digest is the whole identity of an archive, so a matching
|
|
// record means the unpacked tree came from these exact bytes — at this path: a record of the
|
|
// same bytes somewhere else says nothing about what is here.
|
|
if previous.Wrote == got && previous.Target == r.Path {
|
|
if _, err := os.Stat(r.Path); err == nil {
|
|
owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), r.Owner)
|
|
if err == nil && owned {
|
|
// What it unpacked is carried, or — on a record from before the host kept it — read
|
|
// from the archive now, so the record can say it from here on (novox/hq issue 162).
|
|
out.unpacked, err = stillUnpacked(body, r.Path, previous.Unpacked)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path))
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.unpacked = &unpacked
|
|
out.Action = "updated"
|
|
if previous.Wrote == "" {
|
|
out.Action = "created"
|
|
}
|
|
out.Detail = fmt.Sprintf("%d file(s)", written)
|
|
return out, nil
|
|
}
|
|
|
|
// replaceWith makes the directory exactly the archive (novox/hq issue 220), and says what it put
|
|
// there (novox/hq issue 162).
|
|
//
|
|
// **The tree on disk is the archive and nothing else — of what the mesh put there.** The digest is
|
|
// the whole identity of what is unpacked here, so a file the previous archive had and this one does
|
|
// not must go. Unpacked over the old tree, it stayed: a bundle rebuilt as one file per entrypoint
|
|
// kept the package directory of the version before, which code could still import, and a fix that
|
|
// removed a file worked on a fresh machine only. So the archive is unpacked into a fresh directory
|
|
// beside the old one, owned, and swapped in by rename. A running process keeps the files it has open,
|
|
// and the old tree is removed only once the new one is in place. A failed unpack leaves the old tree
|
|
// untouched.
|
|
//
|
|
// **What the mesh did not put there is never swapped away** (novox/hq issue 162, ADR 0030). The
|
|
// swap is for a directory that is the host's own: one it made, holding nothing but what the mesh
|
|
// put there. A directory that was there before the archive, or that something else has written
|
|
// into since, is the machine's: the archive's files are moved into it one by one, what the previous
|
|
// archive placed and this one does not is taken out, and everything else is left as it is. A file
|
|
// the archive would write over that the mesh did not put there refuses the archive before anything
|
|
// is moved — unless it already holds exactly the archive's bytes.
|
|
func replaceWith(body []byte, path, owner string, o ours) (store.Unpacked, int, error) {
|
|
parent := filepath.Dir(path)
|
|
madeParents, err := makeDirsSaying(parent, 0o755, owner)
|
|
if err != nil {
|
|
return store.Unpacked{}, 0, err
|
|
}
|
|
parents := joinParents(madeParents, o.parents)
|
|
fresh := path + ".unpacking"
|
|
replaced := path + ".replaced"
|
|
// What an interrupted earlier attempt — or removal — left beside the directory.
|
|
for _, leftover := range []string{fresh, replaced, path + ".removing"} {
|
|
if err := os.RemoveAll(leftover); err != nil {
|
|
return store.Unpacked{}, 0, err
|
|
}
|
|
}
|
|
if err := os.Mkdir(fresh, 0o755); err != nil {
|
|
return store.Unpacked{}, 0, err
|
|
}
|
|
written, err := unpack(body, fresh)
|
|
if err == nil {
|
|
err = ownAll(fresh, owner)
|
|
}
|
|
var files, dirs []string
|
|
if err == nil {
|
|
files, dirs, err = treeOf(fresh)
|
|
}
|
|
if err != nil {
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
|
|
info, err := os.Lstat(path)
|
|
existed := err == nil
|
|
if err != nil && !os.IsNotExist(err) {
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
if existed && !info.IsDir() {
|
|
// Swapped, it would be deleted: a file at the path is nothing an archive put there.
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, fmt.Errorf(
|
|
"%s is there and is not a directory, and the mesh did not put it there; nothing was unpacked", path)
|
|
}
|
|
foreign := 0
|
|
if existed && !o.all {
|
|
if foreign, err = foreignIn(path, o.paths); err != nil {
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
}
|
|
if existed && (foreign > 0 || !(o.made || o.all)) {
|
|
u, err := mergeInto(fresh, path, owner, files, dirs, o)
|
|
os.RemoveAll(fresh)
|
|
if err != nil {
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
u.Parents = parents
|
|
return u, written, nil
|
|
}
|
|
|
|
hadOne := true
|
|
if err := os.Rename(path, replaced); err != nil {
|
|
if !os.IsNotExist(err) {
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
hadOne = false
|
|
}
|
|
if err := os.Rename(fresh, path); err != nil {
|
|
if hadOne {
|
|
// Put the old tree back rather than leave nothing at the path.
|
|
os.Rename(replaced, path)
|
|
}
|
|
os.RemoveAll(fresh)
|
|
return store.Unpacked{}, written, err
|
|
}
|
|
// The directory is the host's own: it made it, now or before, and nothing else is in it.
|
|
u := store.Unpacked{Files: files, Dirs: dirs, Made: true, Parents: parents}
|
|
if hadOne {
|
|
if err := os.RemoveAll(replaced); err != nil {
|
|
return u, written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
|
|
}
|
|
}
|
|
return u, written, nil
|
|
}
|
|
|
|
func fetch(ctx context.Context, source string) ([]byte, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%s answered %s", source, response.Status)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > maxArchive {
|
|
return nil, fmt.Errorf("%s is larger than %d bytes, which is not an archive this host "+
|
|
"will unpack", source, maxArchive)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// unpack writes a gzipped tar into a directory, refusing anything that would land outside it.
|
|
func unpack(body []byte, into string) (int, error) {
|
|
zipped, err := gzip.NewReader(strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("this is not a gzipped tar: %w", err)
|
|
}
|
|
defer zipped.Close()
|
|
|
|
root, err := filepath.Abs(into)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
reader := tar.NewReader(zipped)
|
|
written := 0
|
|
for {
|
|
header, err := reader.Next()
|
|
if err == io.EOF {
|
|
return written, nil
|
|
}
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
|
|
// The oldest bug in unpacking: an entry named ../../etc/passwd writes outside the
|
|
// directory it was unpacked into.
|
|
//
|
|
// **Refused, not sanitised.** Rewriting the name so it lands inside would put a file
|
|
// somewhere nobody asked for and report success — the "looks configured and is not"
|
|
// failure this host exists to prevent. An archive that names a path outside itself is
|
|
// either hostile or broken, and both want the same answer.
|
|
cleaned := filepath.Clean(header.Name)
|
|
if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
|
|
return written, fmt.Errorf(
|
|
"%s names a path outside the archive; nothing more was unpacked", header.Name)
|
|
}
|
|
// And the same question asked of the result, because a name can be made to resolve
|
|
// outside without saying so.
|
|
target := filepath.Join(root, cleaned)
|
|
if !strings.HasPrefix(target, root+string(os.PathSeparator)) && target != root {
|
|
return written, fmt.Errorf(
|
|
"%s would land outside %s; nothing more was unpacked", header.Name, into)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, os.FileMode(header.Mode)&os.ModePerm); err != nil {
|
|
return written, err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return written, err
|
|
}
|
|
file, err := os.OpenFile(target,
|
|
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&os.ModePerm)
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
if _, err := io.Copy(file, io.LimitReader(reader, maxArchive)); err != nil {
|
|
file.Close()
|
|
return written, err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return written, err
|
|
}
|
|
written++
|
|
default:
|
|
// Symlinks, devices, fifos. Refused rather than skipped: a theme that needed one
|
|
// would silently arrive incomplete, and a device node in an archive is not something
|
|
// to unpack quietly onto a machine.
|
|
return written, fmt.Errorf(
|
|
"%s is a %c, and this host unpacks only files and directories",
|
|
header.Name, header.Typeflag)
|
|
}
|
|
}
|
|
}
|