Teach cram about profile and host subpackages
This commit is contained in:
parent
9ac3f56b5b
commit
6f05542142
3 changed files with 116 additions and 42 deletions
|
@ -6,6 +6,7 @@ zapp_binary(
|
||||||
],
|
],
|
||||||
deps = [
|
deps = [
|
||||||
"//projects/vfs",
|
"//projects/vfs",
|
||||||
|
py_requirement("click"),
|
||||||
py_requirement("toposort"),
|
py_requirement("toposort"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
Loading…
Reference in a new issue