Teach cram about profile and host subpackages

This commit is contained in:
Reid 'arrdem' McKenzie 2021-10-31 10:59:27 -06:00
parent 8e43a7ce3e
commit 75a56695c0
3 changed files with 116 additions and 42 deletions

View file

@ -6,6 +6,7 @@ zapp_binary(
],
deps = [
"//projects/vfs",
py_requirement("click"),
py_requirement("toposort"),
]
)

View file

@ -29,6 +29,7 @@ Cram reads a config dir with three groups of packages
Configuration should be left to profiles.
- `profiles.d/<profilename>` contains a profile; a group of related profiles and packages that should be installed together.
- `hosts.d/<hostname>` contains one package for each host, and should pull in a list of profiles.
- Both profiles and hosts entries may specify their own "inline" packages as a convenience.
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.

View file

@ -1,29 +1,21 @@
#!/usr/bin/env python3
import argparse
from itertools import chain
import logging
import os
from pathlib import Path
import pickle
import re
import sys
from typing import NamedTuple
import click
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("-s", "--state-file", dest="statefile", default=".cram.log")
parser.add_argument("--optimize", dest="optimize", default=False, action="store_true")
parser.add_argument("--no-optimize", dest="optimize", action="store_false")
parser.add_argument("confdir", type=Path)
parser.add_argument("destdir", type=Path)
def stow(fs: Vfs, src_dir: Path, dest_dir: Path, skip=[]):
"""Recursively 'stow' (link) the contents of the source into the destination."""
@ -50,16 +42,31 @@ class PackageV0(NamedTuple):
root: Path
name: str
subpackages: bool = False
SPECIAL_FILES = ["BUILD", "PRE_INSTALL", "INSTALL", "POST_INSTALL", "REQUIRES"]
def requires(self):
"""Get the dependencies of this package."""
requiresf = self.root / "REQUIRES"
requires = []
# Listed dependencies
if requiresf.exists():
with open(requiresf) as fp:
return [l.strip() for l in fp]
return []
for l in fp:
l = l.strip()
l = re.sub(r"\s*#.*\n", "", l)
if l:
requires.append(l)
# Implicitly depended subpackages
if self.subpackages:
for p in self.root.glob("*"):
if p.is_dir():
requires.append(self.name + "/" + p.name)
return requires
def install(self, fs: Vfs, dest):
"""Install this package."""
@ -82,13 +89,31 @@ class PackageV0(NamedTuple):
fs.exec(self.root, ["bash", str(postf)])
def load_config(root: Path) -> dict:
"""Load the configured packages."""
packages = {str(p.relative_to(root)): PackageV0(p, str(p.relative_to(root)))
for p in (root / "packages.d").glob("*")}
# Add profiles, hosts which contain subpackages.
for mp_root in chain((root / "profiles.d").glob("*"),
(root / "hosts.d").glob("*")):
# First find all subpackages
for p in mp_root.glob("*", ):
if p.is_dir():
packages[str(p.relative_to(root))] = PackageV0(p, str(p.relative_to(root)))
# Register the metapackages themselves
packages[str(mp_root.relative_to(root))] = PackageV0(mp_root, str(mp_root.relative_to(root)), True)
return packages
def build_fs(root: Path, dest: Path) -> Vfs:
"""Build a VFS by configuring dest from the given config root."""
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("*"))}
packages = load_config(root)
hostname = os.uname()[1]
@ -125,8 +150,11 @@ def load_fs(statefile: Path) -> Vfs:
oldfs = Vfs()
if statefile.exists():
log.debug("Loading statefile %s", statefile)
with open(statefile, "rb") as fp:
oldfs._log = pickle.load(fp)
else:
log.warning("No previous statefile %s", statefile)
return oldfs
@ -146,6 +174,15 @@ def simplify(old_fs: Vfs, new_fs: Vfs) -> Vfs:
new_fs._log.remove(txn)
old_fs._log.remove(txn)
return new_fs
def scrub(old_fs: Vfs, new_fs: Vfs) -> Vfs:
"""Try to eliminate files which were previously installed but are no longer used."""
old_fs = old_fs.copy()
new_fs = new_fs.copy()
# Look for files in the old log which are no longer present in the new log
for txn in old_fs._log:
if txn[0] == "link" and txn not in new_fs._log:
@ -157,36 +194,71 @@ def simplify(old_fs: Vfs, new_fs: Vfs) -> Vfs:
return new_fs
def main():
@click.group()
def cli():
pass
@cli.command()
@click.option("--execute/--dry-run", default=False)
@click.option("--state-file", default=".cram.log")
@click.option("--optimize/--no-optimize", default=False)
@click.argument("confdir", type=Path)
@click.argument("destdir", type=Path)
def apply(confdir, destdir, state_file, execute, optimize):
"""The entry point of cram."""
opts, args = parser.parse_known_args()
# Resolve the two input paths to absolutes
root = confdir.resolve()
dest = destdir.resolve()
statef = root / state_file
new_fs = build_fs(root, dest)
old_fs = load_fs(statef)
# Middleware processing of the resulting filesystem(s)
executable_fs = scrub(old_fs, new_fs)
if optimize:
executable_fs = simplify(old_fs, new_fs)
# Dump the new state.
# Note that we dump the UNOPTIMIZED state, because we want to simplify relative complete states.
if execute:
executable_fs.execute()
with open(statef, "wb") as fp:
pickle.dump(new_fs._log, fp)
else:
for e in executable_fs._log:
print("-", e)
@cli.command()
@click.option("--state-file", default=".cram.log")
@click.argument("confdir", type=Path)
def show(confdir, state_file):
root = confdir.resolve()
statef = root / state_file
fs = load_fs(statef)
for e in fs._log:
print(*e)
@cli.command()
@click.argument("confdir", type=Path)
def list(confdir):
packages = load_config(confdir)
for pname in sorted(packages.keys()):
p = packages[pname]
print(f"{pname}:")
for d in p.requires():
print(f" - {d}")
if __name__ == "__main__" or 1:
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Resolve the two input paths to absolutes
root = opts.confdir.resolve()
dest = opts.destdir.resolve()
statef = root / opts.statefile
new_fs = build_fs(root, dest)
old_fs = load_fs(statef)
if opts.optimize:
fast_fs = simplify(old_fs, new_fs)
fast_fs.execute(opts.execute)
else:
new_fs.execute(opts.execute)
# Dump the new state.
# Note that we dump the UNOPTIMIZED state, because we want to simplify relative complete states.
if opts.execute:
with open(statef, "wb") as fp:
pickle.dump(new_fs._log, fp)
if __name__ == "__main__" or 1:
main()
cli()