diff --git a/projects/cram/BUILD b/projects/cram/BUILD index 6723694..e2c8edd 100644 --- a/projects/cram/BUILD +++ b/projects/cram/BUILD @@ -5,6 +5,7 @@ zapp_binary( "src/python" ], deps = [ + "//projects/vfs", py_requirement("toposort"), ] ) diff --git a/projects/cram/README.md b/projects/cram/README.md index e5d6166..47824b7 100644 --- a/projects/cram/README.md +++ b/projects/cram/README.md @@ -2,35 +2,32 @@ > 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. +An alternative to GNU Stow, more some notion of packages with dependencies and install scripts. ## Usage ``` -$ cram.zapp [hostname] +# cram [--dry-run | --execute] +$ cram ~/conf ~/ # --dry-run is the default ``` - -Cram consumes a directory tree of the following structure: +Cram operates in terms of packages, which are directories with the following structure - ``` -# Hosts -./hosts.d// -./hosts.d//REQUIRES -./hosts.d//PRE_INSTALL -./hosts.d//INSTALL -./hosts.d//POST_INSTALL +/REQUIRES # A list of other packages this one requires +/BUILD # 1. Perform any compile or package management tasks +/PRE_INSTALL # 2. Any other tasks required before installation occurs +/INSTALL # 3. Do whatever constitutes installation +/POST_INSTALL # 4. Any cleanup or other tasks following installation -# Profiles -./profiles.d// -./profiles.d//REQUIRES -./profiles.d//PRE_INSTALL -./profiles.d//INSTALL -./profiles.d//POST_INSTALL +... # Any other files are treated as package contents -# Packages -./packages.d// -./packages.d//REQUIRES -./packages.d//PRE_INSTALL -./packages.d//POST_INSTALL ``` + +Cram reads a config dir with three groups of packages +- `packages.d/` contains a package that installs but probably shouldn't configure a given tool, package or group of files. + Configuration should be left to profiles. +- `profiles.d/` contains a profile; a group of related profiles and packages that should be installed together. +- `hosts.d/` contains one package for each host, and should pull in a list of profiles. + +The intent of this tool is to keep GNU Stow's intuitive model of deploying configs via symlinks, and augment it with a useful pattern for talking about "layers" / "packages" of related configs. diff --git a/projects/cram/src/python/cram/__main__.py b/projects/cram/src/python/cram/__main__.py index c6f95ef..1f06f95 100644 --- a/projects/cram/src/python/cram/__main__.py +++ b/projects/cram/src/python/cram/__main__.py @@ -2,80 +2,25 @@ import argparse from itertools import chain +import logging import os from pathlib import Path -from subprocess import run import sys from typing import NamedTuple from toposort import toposort_flatten +from vfs import Vfs +log = logging.getLogger(__name__) 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) +parser.add_argument("confdir", type=Path) +parser.add_argument("destdir", 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=[]): +def stow(fs: Vfs, src_dir: Path, dest_dir: Path, skip=[]): """Recursively 'stow' (link) the contents of the source into the destination.""" dest_root = Path(dest_dir) @@ -100,22 +45,26 @@ class PackageV0(NamedTuple): root: Path name: str - SPECIAL_FILES = ["BUILD", "INSTALL", "POST_INSTALL", "requires"] + SPECIAL_FILES = ["BUILD", "PRE_INSTALL", "INSTALL", "POST_INSTALL", "REQUIRES"] def requires(self): """Get the dependencies of this package.""" - requiresf = self.root / "requires" + 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): + def install(self, fs: Vfs, dest): """Install this package.""" buildf = self.root / "BUILD" if buildf.exists(): fs.exec(self.root, ["bash", str(buildf)]) + pref = self.root / "PRE_INSTALL" + if pref.exists(): + fs.exec(self.root, ["bash", str(pref)]) + installf = self.root / "INSTALL" if installf.exists(): fs.exec(self.root, ["bash", str(installf)]) @@ -132,6 +81,11 @@ def main(): opts, args = parser.parse_known_args() + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + root = opts.confdir packages = {str(p.relative_to(root)): PackageV0(p, str(p)) @@ -155,7 +109,7 @@ def main(): # Compute the topsort graph requirements = {r: packages[r].requires() for r in requirements} - fs = Fs() + fs = Vfs() for r in toposort_flatten(requirements): r = packages[r] diff --git a/projects/vfs/BUILD b/projects/vfs/BUILD new file mode 100644 index 0000000..af8fe55 --- /dev/null +++ b/projects/vfs/BUILD @@ -0,0 +1,3 @@ +py_project( + name = "vfs" +) diff --git a/projects/vfs/src/python/vfs/__init__.py b/projects/vfs/src/python/vfs/__init__.py new file mode 100644 index 0000000..fdf6cb7 --- /dev/null +++ b/projects/vfs/src/python/vfs/__init__.py @@ -0,0 +1,5 @@ +""" +The published interface of the VFS package. +""" + +from .impl import Vfs # noqa diff --git a/projects/vfs/src/python/vfs/impl.py b/projects/vfs/src/python/vfs/impl.py new file mode 100644 index 0000000..82138cc --- /dev/null +++ b/projects/vfs/src/python/vfs/impl.py @@ -0,0 +1,65 @@ +""" +The implementation. +""" + +import logging +from subprocess import run + + +_log = logging.getLogger(__name__) + + +class Vfs(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: + _log.debug(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))