First cut at a cram tool
This commit is contained in:
parent
376d032808
commit
8deb1bed25
4 changed files with 215 additions and 0 deletions
10
projects/cram/BUILD
Normal file
10
projects/cram/BUILD
Normal 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
36
projects/cram/README.md
Normal 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
|
||||||
|
```
|
168
projects/cram/src/python/cram/__main__.py
Normal file
168
projects/cram/src/python/cram/__main__.py
Normal 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()
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue