Factor out the VFS since it's kinda neat
This commit is contained in:
parent
64e7de4321
commit
97c0a10e79
6 changed files with 111 additions and 86 deletions
|
@ -5,6 +5,7 @@ zapp_binary(
|
||||||
"src/python"
|
"src/python"
|
||||||
],
|
],
|
||||||
deps = [
|
deps = [
|
||||||
|
"//projects/vfs",
|
||||||
py_requirement("toposort"),
|
py_requirement("toposort"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
> 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
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
$ cram.zapp [hostname]
|
# cram [--dry-run | --execute] <configdir> <destdir>
|
||||||
|
$ cram ~/conf ~/ # --dry-run is the default
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cram operates in terms of packages, which are directories with the following structure -
|
||||||
Cram consumes a directory tree of the following structure:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# Hosts
|
/REQUIRES # A list of other packages this one requires
|
||||||
./hosts.d/<hostname>/
|
/BUILD # 1. Perform any compile or package management tasks
|
||||||
./hosts.d/<hostname>/REQUIRES
|
/PRE_INSTALL # 2. Any other tasks required before installation occurs
|
||||||
./hosts.d/<hostname>/PRE_INSTALL
|
/INSTALL # 3. Do whatever constitutes installation
|
||||||
./hosts.d/<hostname>/INSTALL
|
/POST_INSTALL # 4. Any cleanup or other tasks following installation
|
||||||
./hosts.d/<hostname>/POST_INSTALL
|
|
||||||
|
|
||||||
# Profiles
|
... # Any other files are treated as package contents
|
||||||
./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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
|
@ -2,80 +2,25 @@
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import run
|
|
||||||
import sys
|
import sys
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from toposort import toposort_flatten
|
from toposort import toposort_flatten
|
||||||
|
from vfs import Vfs
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("-x", "--execute", dest="execute", action="store_true", default=False)
|
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("-d", "--dry-run", dest="execute", action="store_false")
|
||||||
parser.add_argument("confdir", default="~/conf", type=Path)
|
parser.add_argument("confdir", type=Path)
|
||||||
parser.add_argument("destdir", default="~/", type=Path)
|
parser.add_argument("destdir", type=Path)
|
||||||
|
|
||||||
|
|
||||||
class Fs(object):
|
def stow(fs: Vfs, src_dir: Path, dest_dir: Path, skip=[]):
|
||||||
"""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."""
|
"""Recursively 'stow' (link) the contents of the source into the destination."""
|
||||||
|
|
||||||
dest_root = Path(dest_dir)
|
dest_root = Path(dest_dir)
|
||||||
|
@ -100,22 +45,26 @@ class PackageV0(NamedTuple):
|
||||||
root: Path
|
root: Path
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
SPECIAL_FILES = ["BUILD", "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"
|
||||||
if requiresf.exists():
|
if requiresf.exists():
|
||||||
with open(requiresf) as fp:
|
with open(requiresf) as fp:
|
||||||
return [l.strip() for l in fp]
|
return [l.strip() for l in fp]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def install(self, fs, dest):
|
def install(self, fs: Vfs, dest):
|
||||||
"""Install this package."""
|
"""Install this package."""
|
||||||
buildf = self.root / "BUILD"
|
buildf = self.root / "BUILD"
|
||||||
if buildf.exists():
|
if buildf.exists():
|
||||||
fs.exec(self.root, ["bash", str(buildf)])
|
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"
|
installf = self.root / "INSTALL"
|
||||||
if installf.exists():
|
if installf.exists():
|
||||||
fs.exec(self.root, ["bash", str(installf)])
|
fs.exec(self.root, ["bash", str(installf)])
|
||||||
|
@ -132,6 +81,11 @@ def main():
|
||||||
|
|
||||||
opts, args = parser.parse_known_args()
|
opts, args = parser.parse_known_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
root = opts.confdir
|
root = opts.confdir
|
||||||
|
|
||||||
packages = {str(p.relative_to(root)): PackageV0(p, str(p))
|
packages = {str(p.relative_to(root)): PackageV0(p, str(p))
|
||||||
|
@ -155,7 +109,7 @@ def main():
|
||||||
|
|
||||||
# Compute the topsort graph
|
# Compute the topsort graph
|
||||||
requirements = {r: packages[r].requires() for r in requirements}
|
requirements = {r: packages[r].requires() for r in requirements}
|
||||||
fs = Fs()
|
fs = Vfs()
|
||||||
|
|
||||||
for r in toposort_flatten(requirements):
|
for r in toposort_flatten(requirements):
|
||||||
r = packages[r]
|
r = packages[r]
|
||||||
|
|
3
projects/vfs/BUILD
Normal file
3
projects/vfs/BUILD
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
py_project(
|
||||||
|
name = "vfs"
|
||||||
|
)
|
5
projects/vfs/src/python/vfs/__init__.py
Normal file
5
projects/vfs/src/python/vfs/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
"""
|
||||||
|
The published interface of the VFS package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .impl import Vfs # noqa
|
65
projects/vfs/src/python/vfs/impl.py
Normal file
65
projects/vfs/src/python/vfs/impl.py
Normal 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))
|
Loading…
Reference in a new issue