First cut at a cram tool

This commit is contained in:
Reid 'arrdem' McKenzie 2021-10-10 21:41:01 -06:00
parent 376d032808
commit 8deb1bed25
4 changed files with 215 additions and 0 deletions

10
projects/cram/BUILD Normal file
View file

@ -0,0 +1,10 @@
zapp_binary(
name = "cram",
main = "src/python/cram/__main__.py",
imports = [
"src/python"
],
deps = [
py_requirement("toposort"),
]
)

36
projects/cram/README.md Normal file
View file

@ -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/<hostname>/
./hosts.d/<hostname>/REQUIRES
./hosts.d/<hostname>/PRE_INSTALL
./hosts.d/<hostname>/INSTALL
./hosts.d/<hostname>/POST_INSTALL
# Profiles
./profiles.d/<profilename>/
./profiles.d/<profilename>/REQUIRES
./profiles.d/<profilename>/PRE_INSTALL
./profiles.d/<profilename>/INSTALL
./profiles.d/<profilename>/POST_INSTALL
# Packages
./packages.d/<packagename>/
./packages.d/<packagename>/REQUIRES
./packages.d/<packagename>/PRE_INSTALL
./packages.d/<packagename>/POST_INSTALL
```

View file

@ -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()

View file

@ -90,6 +90,7 @@ sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.4 sphinxcontrib-serializinghtml==1.1.4
toml==0.10.2 toml==0.10.2
tomli==1.2.1 tomli==1.2.1
toposort==1.7
tornado==6.1 tornado==6.1
typed-ast==1.4.2 typed-ast==1.4.2
typing-extensions==3.10.0.2 typing-extensions==3.10.0.2