Blob Blame History Raw
#!/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)