#!/usr/bin/python3
"""
Initialize the sysroot and pull and deploy an OStree commit
Initializes a clean ostree based system root, pulls the given `commit` and
creates a deployment from it using `osname` as the new stateroot (see [1]).
Since OStree internally uses a hardlink farm to create the file system tree
for the deployment from the commit data, the mountpoints for the final image
need to be supplied via the `mounts` option, as hardlinks must not span
across file systems and therefore the boundaries need to be known when doing
the deployment.
Creating a deployment also entails generating the Boot Loader Specification
entries to boot the system, which contain this the kernel command line.
The `rootfs` option can be used to indicate the root file system, containing
the sysroot and the deployments. Additional kernel options can be passed via
`kernel_opts`.
[1] https://ostree.readthedocs.io/en/latest/manual/deployment/
"""
import contextlib
import json
import os
import sys
import subprocess
import osbuild.sources
from osbuild.util import selinux
SCHEMA = """
"required": ["commit", "osname"],
"properties": {
"commit": {
"description": "checksum of the OSTree commit",
"type": "string"
},
"mounts": {
"description": "Mount points of the final file system",
"type": "array",
"items": {
"description": "Description of one mount point",
"type": "object",
"required": ["path"],
"properties": {
"path": {
"description": "The path of the mount point",
"type": "string"
},
"mode": {
"description": "The mode of the mount point",
"type": "integer",
"default": 493
}
}
}
},
"osname": {
"description": "Name of the stateroot to be used in the deployment",
"type": "string"
},
"kernel_opts": {
"description": "Additional kernel command line options",
"type": "array",
"items": {
"description": "A single kernel command line option",
"type": "string"
}
},
"ref": {
"description": "OStree ref to create and use for deployment",
"type": "string"
},
"remotes": {
"description": "Configure remotes for the system repository",
"type": "array",
"items": {
"description": "Description of a remote",
"type": "object",
"required": ["name", "url"],
"properties": {
"name": {
"description": "Identifier for the remote",
"type": "string"
},
"url": {
"description": "URL of the remote",
"type": "string"
},
"branches": {
"type": "array",
"items": {
"description": "Configured branches for the remote",
"type": "string"
}
},
"gpgkeys": {
"type": "array",
"items": {
"description": "GPG keys for the remote to verify commits",
"type": "string"
}
}
}
}
},
"rootfs": {
"description": "Identifier to locate the root file system",
"type": "object",
"oneOf": [{
"required": ["uuid"]
}, {
"required": ["label"]
}],
"properties": {
"label": {
"description": "Identify the root file system by label",
"type": "string"
},
"uuid": {
"description": "Identify the root file system by UUID",
"type": "string"
}
}
},
"populate_var": {
"description": "Populate $stateroot/var via systemd-tmpfiles",
"type": "boolean",
"default": false
}
}
"""
def ostree(*args, _input=None, **kwargs):
args = list(args) + [f'--{k}={v}' for k, v in kwargs.items()]
print("ostree " + " ".join(args), file=sys.stderr)
subprocess.run(["ostree"] + args,
encoding="utf-8",
stdout=sys.stderr,
input=_input,
check=True)
class MountGuard(contextlib.AbstractContextManager):
def __init__(self):
self.mounts = []
def mount(self, source, target, bind=True, ro=False, mode="0755"):
options = []
if bind:
options += ["bind"]
if ro:
options += ["ro"]
if mode:
options += [mode]
args = ["--make-private"]
if options:
args += ["-o", ",".join(options)]
subprocess.run(["mount"] + args + [source, target], check=True)
self.mounts += [{"source": source, "target": target}]
subprocess.run(["mount"] + args + [source, target], check=True)
def unmount(self):
while self.mounts:
mount = self.mounts.pop() # FILO: get the last mount
target = mount["target"]
subprocess.run(["umount", "--lazy", target],
check=True)
def __exit__(self, exc_type, exc_val, exc_tb):
self.unmount()
def make_fs_identifier(desc):
for key in ["uuid", "label"]:
val = desc.get(key)
if val:
return f"{key.upper()}={val}"
raise ValueError("unknown rootfs type")
def populate_var(sysroot):
# Like anaconda[1] and Fedora CoreOS dracut[2]
# [1] pyanaconda/payload/rpmostreepayload.py
# [2] ignition-ostree-populate-var.sh
for target in ('lib', 'log'):
os.makedirs(f"{sysroot}/var/{target}", exist_ok=True)
for target in ('home', 'roothome', 'lib/rpm', 'opt', 'srv',
'usrlocal', 'mnt', 'media', 'spool', 'spool/mail'):
if os.path.exists(f"{sysroot}/var/{target}"):
continue
res = subprocess.run(["systemd-tmpfiles", "--create", "--boot",
"--root=" + sysroot,
"--prefix=/var/" + target],
encoding="utf-8",
stdout=sys.stderr,
check=False)
# According to systemd-tmpfiles(8), the return values are:
# 0 → success
# 65 → so some lines had to be ignored, but no other errors
# 73 → configuration ok, but could not be created
# 1 → other error
if res.returncode not in [0, 65]:
raise RuntimeError(f"Failed to provision /var/{target}")
# pylint: disable=too-many-statements
def main(tree, sources, options):
commit = options["commit"]
osname = options["osname"]
rootfs = options.get("rootfs")
mounts = options.get("mounts", [])
kopts = options.get("kernel_opts", [])
ref = options.get("ref", commit)
remotes = options.get("remotes", [])
pop_var = options.get("populate_var", False)
ostree("admin", "init-fs", "--modern", tree,
sysroot=tree)
print(f"Fetching ostree commit {commit}")
osbuild.sources.get("org.osbuild.ostree", [commit])
source_repo = f"{sources}/org.osbuild.ostree/repo"
ostree("pull-local", source_repo, commit,
repo=f"{tree}/ostree/repo")
if ref != commit:
ostree("refs", "--create", ref, commit,
repo=f"{tree}/ostree/repo")
ostree("admin", "os-init", osname, sysroot=tree)
# this created a state root at `osname`
stateroot = f"{tree}/ostree/deploy/{osname}"
kargs = []
if rootfs:
rootfs_id = make_fs_identifier(rootfs)
kargs += [f"--karg=root={rootfs_id}"]
for opt in kopts:
kargs += [f"--karg-append={opt}"]
with MountGuard() as mounter:
for mount in mounts:
path = mount["path"].lstrip("/")
path = os.path.join(tree, path)
os.makedirs(path, exist_ok=True)
os.chmod(path, mount.get("mode", 0o755))
mounter.mount(path, path)
ostree("admin", "deploy", ref,
*kargs,
sysroot=tree,
os=osname)
# now that we have a deployment, we do have a sysroot
sysroot = f"{stateroot}/deploy/{commit}.0"
if pop_var:
populate_var(stateroot)
ostree("config", "set", "sysroot.readonly", "true",
repo=f"{tree}/ostree/repo")
# deploying a tree creates new files that need to be properly
# labeled for SELinux. In theory, ostree will take care of
# this by loading the SELinux config from the deployment and
# then applying the labels; but it does so conditionally on
# is_selinux_enabled(2), which in our container is FALSE
# Therefore we have to do the same dance as ostree does, at
# least for now, and manually re-label the affected paths
se_policy = None
for p in ["etc/selinux", "usr/etc/selinux"]:
se_home = os.path.join(sysroot, p)
cfgfile = os.path.join(se_home, "config")
if not os.path.isfile(cfgfile):
continue
with open(cfgfile, 'r') as f:
cfg = selinux.parse_config(f)
se_policy = selinux.config_get_policy(cfg)
if se_policy:
spec = f"{se_home}/{se_policy}/contexts/files/file_contexts"
# kernel, initramfs & BLS config snippets were
# written to {tree}/boot
selinux.setfiles(spec, tree, "/boot")
# various config files will be created as a result
# of the 3-way configuration merge, see ostree(3)
selinux.setfiles(spec, sysroot, "/etc")
# if we populated /var, we need to fix its labels
selinux.setfiles(spec, stateroot, "/var")
for remote in remotes:
name = remote["name"]
url = remote["url"]
branches = remote.get("branches", [])
gpgkeys = remote.get("gpgkeys", [])
extra_args = []
if not gpgkeys:
extra_args += ["--no-gpg-verify"]
ostree("remote", "add",
"--if-not-exists",
*extra_args,
name, url,
*branches,
repo=f"{tree}/ostree/repo")
for key in gpgkeys:
ostree("remote", "gpg-import", "--stdin", name,
repo=f"{tree}/ostree/repo",
_input=key)
if __name__ == '__main__':
stage_args = json.load(sys.stdin)
r = main(stage_args["tree"],
stage_args["sources"],
stage_args["options"])
sys.exit(r)