Teach cram about profile and host subpackages

This commit is contained in:
Reid 'arrdem' McKenzie 2021-10-31 10:59:27 -06:00
parent 9ac3f56b5b
commit 6f05542142
3 changed files with 116 additions and 42 deletions

View file

@ -6,6 +6,7 @@ zapp_binary(
], ],
deps = [ deps = [
"//projects/vfs", "//projects/vfs",
py_requirement("click"),
py_requirement("toposort"), 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. Configuration should be left to profiles.
- `profiles.d/<profilename>` contains a profile; a group of related profiles and packages that should be installed together. - `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. - `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. 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 #!/usr/bin/env python3
import argparse
from itertools import chain from itertools import chain
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import pickle import pickle
import re
import sys import sys
from typing import NamedTuple from typing import NamedTuple
import click
from toposort import toposort_flatten from toposort import toposort_flatten
from vfs import Vfs from vfs import Vfs
log = logging.getLogger(__name__) 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=[]): def stow(fs: Vfs, src_dir: Path, dest_dir: Path, skip=[]):
"""Recursively 'stow' (link) the contents of the source into the destination.""" """Recursively 'stow' (link) the contents of the source into the destination."""
@ -50,16 +42,31 @@ class PackageV0(NamedTuple):
root: Path root: Path
name: str name: str
subpackages: bool = False
SPECIAL_FILES = ["BUILD", "PRE_INSTALL", "INSTALL", "POST_INSTALL", "REQUIRES"] SPECIAL_FILES = ["BUILD", "PRE_INSTALL", "INSTALL", "POST_INSTALL", "REQUIRES"]
def requires(self): def requires(self):
"""Get the dependencies of this package.""" """Get the dependencies of this package."""
requiresf = self.root / "REQUIRES" requiresf = self.root / "REQUIRES"
requires = []
# Listed dependencies
if requiresf.exists(): if requiresf.exists():
with open(requiresf) as fp: with open(requiresf) as fp:
return [l.strip() for l in fp] for l in fp:
return [] 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): def install(self, fs: Vfs, dest):
"""Install this package.""" """Install this package."""
@ -82,13 +89,31 @@ class PackageV0(NamedTuple):
fs.exec(self.root, ["bash", str(postf)]) 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: def build_fs(root: Path, dest: Path) -> Vfs:
"""Build a VFS by configuring dest from the given config root.""" """Build a VFS by configuring dest from the given config root."""
packages = {str(p.relative_to(root)): PackageV0(p, str(p)) packages = load_config(root)
for p in chain((root / "packages.d").glob("*"),
(root / "profiles.d").glob("*"),
(root / "hosts.d").glob("*"))}
hostname = os.uname()[1] hostname = os.uname()[1]
@ -125,8 +150,11 @@ def load_fs(statefile: Path) -> Vfs:
oldfs = Vfs() oldfs = Vfs()
if statefile.exists(): if statefile.exists():
log.debug("Loading statefile %s", statefile)
with open(statefile, "rb") as fp: with open(statefile, "rb") as fp:
oldfs._log = pickle.load(fp) oldfs._log = pickle.load(fp)
else:
log.warning("No previous statefile %s", statefile)
return oldfs return oldfs
@ -146,6 +174,15 @@ def simplify(old_fs: Vfs, new_fs: Vfs) -> Vfs:
new_fs._log.remove(txn) new_fs._log.remove(txn)
old_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 # Look for files in the old log which are no longer present in the new log
for txn in old_fs._log: for txn in old_fs._log:
if txn[0] == "link" and txn not in new_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 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.""" """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( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
) )
# Resolve the two input paths to absolutes cli()
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()