Factor out the VFS since it's kinda neat

This commit is contained in:
Reid 'arrdem' McKenzie 2021-10-10 22:40:05 -06:00
parent 4d9a777fc8
commit f89459294d
6 changed files with 111 additions and 86 deletions

View file

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

View file

@ -2,35 +2,32 @@
> 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.
An alternative to GNU Stow, more some notion of packages with dependencies and install scripts.
## Usage
```
$ cram.zapp [hostname]
# cram [--dry-run | --execute] <configdir> <destdir>
$ cram ~/conf ~/ # --dry-run is the default
```
Cram consumes a directory tree of the following structure:
Cram operates in terms of packages, which are directories with 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
/REQUIRES # A list of other packages this one requires
/BUILD # 1. Perform any compile or package management tasks
/PRE_INSTALL # 2. Any other tasks required before installation occurs
/INSTALL # 3. Do whatever constitutes installation
/POST_INSTALL # 4. Any cleanup or other tasks following installation
# Profiles
./profiles.d/<profilename>/
./profiles.d/<profilename>/REQUIRES
./profiles.d/<profilename>/PRE_INSTALL
./profiles.d/<profilename>/INSTALL
./profiles.d/<profilename>/POST_INSTALL
... # Any other files are treated as package contents
# Packages
./packages.d/<packagename>/
./packages.d/<packagename>/REQUIRES
./packages.d/<packagename>/PRE_INSTALL
./packages.d/<packagename>/POST_INSTALL
```
Cram reads a config dir with three groups of packages
- `packages.d/<packagename>` contains a package that installs but probably shouldn't configure a given tool, package or group of files.
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.
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

@ -2,80 +2,25 @@
import argparse
from itertools import chain
import logging
import os
from pathlib import Path
from subprocess import run
import sys
from typing import NamedTuple
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("confdir", default="~/conf", type=Path)
parser.add_argument("destdir", default="~/", type=Path)
parser.add_argument("confdir", type=Path)
parser.add_argument("destdir", 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=[]):
def stow(fs: Vfs, src_dir: Path, dest_dir: Path, skip=[]):
"""Recursively 'stow' (link) the contents of the source into the destination."""
dest_root = Path(dest_dir)
@ -100,22 +45,26 @@ class PackageV0(NamedTuple):
root: Path
name: str
SPECIAL_FILES = ["BUILD", "INSTALL", "POST_INSTALL", "requires"]
SPECIAL_FILES = ["BUILD", "PRE_INSTALL", "INSTALL", "POST_INSTALL", "REQUIRES"]
def requires(self):
"""Get the dependencies of this package."""
requiresf = self.root / "requires"
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):
def install(self, fs: Vfs, dest):
"""Install this package."""
buildf = self.root / "BUILD"
if buildf.exists():
fs.exec(self.root, ["bash", str(buildf)])
pref = self.root / "PRE_INSTALL"
if pref.exists():
fs.exec(self.root, ["bash", str(pref)])
installf = self.root / "INSTALL"
if installf.exists():
fs.exec(self.root, ["bash", str(installf)])
@ -132,6 +81,11 @@ def main():
opts, args = parser.parse_known_args()
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
root = opts.confdir
packages = {str(p.relative_to(root)): PackageV0(p, str(p))
@ -155,7 +109,7 @@ def main():
# Compute the topsort graph
requirements = {r: packages[r].requires() for r in requirements}
fs = Fs()
fs = Vfs()
for r in toposort_flatten(requirements):
r = packages[r]

3
projects/vfs/BUILD Normal file
View file

@ -0,0 +1,3 @@
py_project(
name = "vfs"
)

View file

@ -0,0 +1,5 @@
"""
The published interface of the VFS package.
"""
from .impl import Vfs # noqa

View file

@ -0,0 +1,65 @@
"""
The implementation.
"""
import logging
from subprocess import run
_log = logging.getLogger(__name__)
class Vfs(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:
_log.debug(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))