From 8deb1bed25b0431c167d51a3feb0637c590f6c4c Mon Sep 17 00:00:00 2001 From: Reid 'arrdem' McKenzie Date: Sun, 10 Oct 2021 21:41:01 -0600 Subject: [PATCH] First cut at a cram tool --- projects/cram/BUILD | 10 ++ projects/cram/README.md | 36 +++++ projects/cram/src/python/cram/__main__.py | 168 ++++++++++++++++++++++ tools/python/requirements.txt | 1 + 4 files changed, 215 insertions(+) create mode 100644 projects/cram/BUILD create mode 100644 projects/cram/README.md create mode 100644 projects/cram/src/python/cram/__main__.py diff --git a/projects/cram/BUILD b/projects/cram/BUILD new file mode 100644 index 0000000..6723694 --- /dev/null +++ b/projects/cram/BUILD @@ -0,0 +1,10 @@ +zapp_binary( + name = "cram", + main = "src/python/cram/__main__.py", + imports = [ + "src/python" + ], + deps = [ + py_requirement("toposort"), + ] +) diff --git a/projects/cram/README.md b/projects/cram/README.md new file mode 100644 index 0000000..e5d6166 --- /dev/null +++ b/projects/cram/README.md @@ -0,0 +1,36 @@ +# Cram + +> To force (people or things) into a place or container that is or appears to be too small to contain them. + +An alternative to GNU Stow, which critically supports jinja2 templating and some other niceties. + +## Usage + +``` +$ cram.zapp [hostname] +``` + + +Cram consumes a directory tree of the following structure: + +``` +# Hosts +./hosts.d// +./hosts.d//REQUIRES +./hosts.d//PRE_INSTALL +./hosts.d//INSTALL +./hosts.d//POST_INSTALL + +# Profiles +./profiles.d// +./profiles.d//REQUIRES +./profiles.d//PRE_INSTALL +./profiles.d//INSTALL +./profiles.d//POST_INSTALL + +# Packages +./packages.d// +./packages.d//REQUIRES +./packages.d//PRE_INSTALL +./packages.d//POST_INSTALL +``` diff --git a/projects/cram/src/python/cram/__main__.py b/projects/cram/src/python/cram/__main__.py new file mode 100644 index 0000000..c6f95ef --- /dev/null +++ b/projects/cram/src/python/cram/__main__.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 + +import argparse +from itertools import chain +import os +from pathlib import Path +from subprocess import run +import sys +from typing import NamedTuple + +from toposort import toposort_flatten + + +parser = argparse.ArgumentParser() +parser.add_argument("-x", "--execute", dest="execute", action="store_true", default=False) +parser.add_argument("-d", "--dry-run", dest="execute", action="store_false") +parser.add_argument("confdir", default="~/conf", type=Path) +parser.add_argument("destdir", default="~/", type=Path) + + +class Fs(object): + """An abstract filesystem device which can accumulate changes, and apply them in a batch.""" + + def __init__(self): + self._log = [] + + def execute(self, execute=False): + for e in self._log: + print(e) + + if not execute: + continue + + elif e[0] == "exec": + _, dir, cmd = e + run(cmd, cwd=str(dir)) + + elif e[0] == "link": + _, src, dest = e + if dest.exists() and dest.is_symlink() and dest.readlink() == dest: + continue + else: + if dest.exists(): + dest.unlink() + dest.symlink_to(src) + + elif e[0] == "copy": + raise NotImplementedError() + + elif e[0] == "chmod": + _, dest, mode = e + dest.chmod(mode) + + elif e[0] == "mkdir": + _, dest = e + dest.mkdir(exist_ok=True) + + + def _append(self, msg): + self._log.append(msg) + + def link(self, src, dest): + self._append(("link", src, dest)) + + def copy(self, src, dest): + self._append(("copy", src, dest)) + + def chmod(self, dest, mode): + self._append(("chmod", dest, mode)) + + def mkdir(self, dest): + self._append(("mkdir", dest)) + + def exec(self, dest, cmd): + self._append(("exec", dest, cmd)) + + +def stow(fs: Fs, src_dir: Path, dest_dir: Path, skip=[]): + """Recursively 'stow' (link) the contents of the source into the destination.""" + + dest_root = Path(dest_dir) + src_root = Path(src_dir) + skip = [src_root / n for n in skip] + + for src in src_root.glob("**/*"): + if src in skip: + continue + + dest = dest_root / src.relative_to(src_root) + if src.is_dir(): + fs.mkdir(dest) + fs.chmod(dest, src.stat().st_mode) + elif src.is_file(): + fs.link(src, dest) + + +class PackageV0(NamedTuple): + """The original package format from install.sh.""" + + root: Path + name: str + + SPECIAL_FILES = ["BUILD", "INSTALL", "POST_INSTALL", "requires"] + + def requires(self): + """Get the dependencies of this package.""" + requiresf = self.root / "requires" + if requiresf.exists(): + with open(requiresf) as fp: + return [l.strip() for l in fp] + return [] + + def install(self, fs, dest): + """Install this package.""" + buildf = self.root / "BUILD" + if buildf.exists(): + fs.exec(self.root, ["bash", str(buildf)]) + + installf = self.root / "INSTALL" + if installf.exists(): + fs.exec(self.root, ["bash", str(installf)]) + else: + stow(fs, self.root, dest, self.SPECIAL_FILES) + + postf = self.root / "POST_INSTALL" + if postf.exists(): + fs.exec(self.root, ["bash", str(postf)]) + + +def main(): + """The entry point of cram.""" + + opts, args = parser.parse_known_args() + + root = opts.confdir + + packages = {str(p.relative_to(root)): PackageV0(p, str(p)) + for p in chain((root / "packages.d").glob("*"), + (root / "profiles.d").glob("*"), + (root / "hosts.d").glob("*"))} + + hostname = os.uname()[1] + + # Compute the closure of packages to install + requirements = [f"hosts.d/{hostname}"] + + for r in requirements: + try: + for d in packages[r].requires(): + if d not in requirements: + requirements.append(d) + except KeyError: + print(f"Error: Unable to load package {r}", file=sys.stderr) + exit(1) + + # Compute the topsort graph + requirements = {r: packages[r].requires() for r in requirements} + fs = Fs() + + for r in toposort_flatten(requirements): + r = packages[r] + r.install(fs, opts.destdir) + + fs.execute(opts.execute) + + +if __name__ == "__main__" or 1: + main() diff --git a/tools/python/requirements.txt b/tools/python/requirements.txt index 4d601dc..7e311f0 100644 --- a/tools/python/requirements.txt +++ b/tools/python/requirements.txt @@ -90,6 +90,7 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 toml==0.10.2 tomli==1.2.1 +toposort==1.7 tornado==6.1 typed-ast==1.4.2 typing-extensions==3.10.0.2