def findkernels(root="/", kdir="boot"): # To find possible flavors, awk '/BuildKernel/ { print $4 }' kernel.spec flavors = ('debug', 'PAE', 'PAEdebug', 'smp', 'xen', 'lpae') kre = re.compile(r"vmlinuz-(?P<version>.+?\.(?P<arch>[a-z0-9_]+)" r"(.(?P<flavor>{0}))?)$".format("|".join(flavors))) kernels = [] bootfiles = os.listdir(joinpaths(root, kdir)) for f in bootfiles: match = kre.match(f) if match: kernel = DataHolder(path=joinpaths(kdir, f)) kernel.update(match.groupdict()) # sets version, arch, flavor kernels.append(kernel) # look for associated initrd/initramfs/etc. for kernel in kernels: for f in bootfiles: if f.endswith('-'+kernel.version+'.img'): imgtype, _rest = f.split('-',1) # special backwards-compat case if imgtype == 'initramfs': imgtype = 'initrd' kernel[imgtype] = DataHolder(path=joinpaths(kdir, f)) logger.debug("kernels=%s", kernels) return kernels
def squashfs_args_test(self): """Test squashfs_args results""" test_arches = { "x86_64": ("xz", ["-Xbcj", "x86"]), "ppc64le": ("xz", ["-Xbcj", "powerpc"]), "s390x": ("xz", []), "ia64": ("xz", []), "aarch64": ("xz", []) } for arch in test_arches: opts = DataHolder(compression=None, compress_args=[], arch=arch) self.assertEqual(squashfs_args(opts), test_arches[arch], (opts, squashfs_args(opts))) opts = DataHolder(compression="lzma", compress_args=[], arch="x86_64") self.assertEqual(squashfs_args(opts), ("lzma", []), (opts, squashfs_args(opts))) opts = DataHolder(compression="xz", compress_args=["-X32767"], arch="x86_64") self.assertEqual(squashfs_args(opts), ("xz", ["-X32767"]), (opts, squashfs_args(opts))) opts = DataHolder(compression="xz", compress_args=["-X32767", "-Xbcj x86"], arch="x86_64") self.assertEqual(squashfs_args(opts), ("xz", ["-X32767", "-Xbcj", "x86"]), (opts, squashfs_args(opts)))
def make_runtime(opts, mount_dir, work_dir, size=None): """ Make the squashfs image from a directory :param opts: options passed to livemedia-creator :type opts: argparse options :param str mount_dir: Directory tree to compress :param str work_dir: Output compressed image to work_dir+images/install.img :param int size: Size of disk image, in GiB :returns: rc of squashfs creation :rtype: int """ kernel_arch = get_arch(mount_dir) # Fake dnf object fake_dbo = FakeDNF(conf=DataHolder(installroot=mount_dir)) # Fake arch with only basearch set arch = ArchData(kernel_arch) # TODO: Need to get release info from someplace... product = DataHolder(name=opts.project, version=opts.releasever, release="", variant="", bugurl="", isfinal=False) rb = RuntimeBuilder(product, arch, fake_dbo) compression, compressargs = squashfs_args(opts) if opts.squashfs_only: log.info("Creating a squashfs only runtime") return rb.create_squashfs_runtime(joinpaths(work_dir, RUNTIME), size=size, compression=compression, compressargs=compressargs) else: log.info("Creating a squashfs+ext4 runtime") return rb.create_ext4_runtime(joinpaths(work_dir, RUNTIME), size=size, compression=compression, compressargs=compressargs)
def make_runtime(opts, mount_dir, work_dir, size=None): """ Make the squashfs image from a directory :param opts: options passed to livemedia-creator :type opts: argparse options :param str mount_dir: Directory tree to compress :param str work_dir: Output compressed image to work_dir+images/install.img :param int size: Size of disk image, in GiB """ kernel_arch = get_arch(mount_dir) # Fake dnf object fake_dbo = FakeDNF(conf=DataHolder(installroot=mount_dir)) # Fake arch with only basearch set arch = ArchData(kernel_arch) # TODO: Need to get release info from someplace... product = DataHolder(name=opts.project, version=opts.releasever, release="", variant="", bugurl="", isfinal=False) # This is a mounted image partition, cannot hardlink to it, so just use it # symlink mount_dir/images to work_dir/images so we don't run out of space os.makedirs(joinpaths(work_dir, "images")) rb = RuntimeBuilder(product, arch, fake_dbo) compression, compressargs = squashfs_args(opts) log.info("Creating runtime") rb.create_ext4_runtime(joinpaths(work_dir, RUNTIME), size=size, compression=compression, compressargs=compressargs)
def findkernels(root="/", kdir="boot"): # To find possible flavors, awk '/BuildKernel/ { print $4 }' kernel.spec flavors = ('debug', 'PAE', 'PAEdebug', 'smp', 'xen', 'lpae') kre = re.compile(r"vmlinuz-(?P<version>.+?\.(?P<arch>[a-z0-9_]+)" r"(.(?P<flavor>{0}))?)$".format("|".join(flavors))) kernels = [] bootfiles = os.listdir(joinpaths(root, kdir)) for f in bootfiles: match = kre.match(f) if match: kernel = DataHolder(path=joinpaths(kdir, f)) kernel.update(match.groupdict()) # sets version, arch, flavor kernels.append(kernel) # look for associated initrd/initramfs/etc. for kernel in kernels: for f in bootfiles: if f.endswith('-' + kernel.version + '.img'): imgtype, _rest = f.split('-', 1) # special backwards-compat case if imgtype == 'initramfs': imgtype = 'initrd' kernel[imgtype] = DataHolder(path=joinpaths(kdir, f)) logger.debug("kernels=%s", kernels) return kernels
def test_make_livecd_dracut(self): """Test the make_livecd function with dracut options""" with tempfile.TemporaryDirectory(prefix="lorax.test.") as tmpdir: # Make a fake kernel and initrd mkFakeBoot(joinpaths(tmpdir, "mount_dir")) os.makedirs(joinpaths(tmpdir, "mount_dir/tmp/config_files")) lorax_templates = os.path.abspath(find_templates("./share/")) with mock.patch('pylorax.treebuilder.TreeBuilder.build'): with mock.patch( 'pylorax.treebuilder.TreeBuilder.rebuild_initrds' ) as ri: # Test with no dracut args opts = DataHolder(project="Fedora", releasever="32", lorax_templates=lorax_templates, volid=None, domacboot=False, extra_boot_args="", dracut_args=None, dracut_conf=None) make_livecd(opts, joinpaths(tmpdir, "mount_dir"), joinpaths(tmpdir, "work_dir")) ri.assert_called_with(add_args=DRACUT_DEFAULT) # Test with --dracut-arg opts = DataHolder( project="Fedora", releasever="32", lorax_templates=lorax_templates, volid=None, domacboot=False, extra_boot_args="", dracut_args=[ "--xz", "--omit plymouth", "--add livenet dmsquash-live dmsquash-live-ntfs" ], dracut_conf=None) make_livecd(opts, joinpaths(tmpdir, "mount_dir"), joinpaths(tmpdir, "work_dir")) ri.assert_called_with(add_args=[ "--xz", "--omit", "plymouth", "--add", "livenet dmsquash-live dmsquash-live-ntfs" ]) # Test with --dracut-conf opts = DataHolder( project="Fedora", releasever="32", lorax_templates=lorax_templates, volid=None, domacboot=False, extra_boot_args="", dracut_args=None, dracut_conf="/var/tmp/project/lmc-dracut.conf") make_livecd(opts, joinpaths(tmpdir, "mount_dir"), joinpaths(tmpdir, "work_dir")) ri.assert_called_with(add_args=[ "--conf", "/var/tmp/project/lmc-dracut.conf" ])
def __init__(self, inroot, outroot, dbo=None, fatalerrors=True, templatedir=None, defaults=None): self.inroot = inroot self.outroot = outroot self.dbo = dbo builtins = DataHolder(exists=lambda p: rexists(p, root=inroot), glob=lambda g: list(rglob(g, root=inroot))) self.results = DataHolder(treeinfo=dict()) # just treeinfo for now super(LoraxTemplateRunner, self).__init__(fatalerrors, templatedir, defaults, builtins)
def __init__(self, inroot, outroot, dbo=None, fatalerrors=True, templatedir=None, defaults=None): self.inroot = inroot self.outroot = outroot self.dbo = dbo self.fatalerrors = fatalerrors self.templatedir = templatedir or "/usr/share/lorax" self.templatefile = None # some builtin methods self.builtins = DataHolder(exists=lambda p: rexists(p, root=inroot), glob=lambda g: list(rglob(g, root=inroot))) self.defaults = defaults or {} self.results = DataHolder(treeinfo=dict()) # just treeinfo for now
def test_dracut_args(self): """Test dracut_args results""" # Use default args opts = DataHolder(dracut_args=None, dracut_conf=None) self.assertEqual(dracut_args(opts), DRACUT_DEFAULT) # Use a config file from --dracut-conf opts = DataHolder(dracut_args=None, dracut_conf="/var/tmp/project/lmc-dracut.conf") self.assertEqual(dracut_args(opts), ["--conf", "/var/tmp/project/lmc-dracut.conf"]) # Use --dracut-arg opts = DataHolder(dracut_args=["--xz", "--omit plymouth", "--add livenet dmsquash-live dmsquash-live-ntfs"], dracut_conf=None) self.assertEqual(dracut_args(opts), ["--xz", "--omit", "plymouth", "--add", "livenet dmsquash-live dmsquash-live-ntfs"])
def make_runtime_squashfs_ext4_test(self): """Test making a runtime squashfs+ext4 only image""" with tempfile.TemporaryDirectory(prefix="lorax.test.") as work_dir: with tempfile.TemporaryDirectory( prefix="lorax.test.root.") as mount_dir: # Make a fake kernel and initrd mkFakeBoot(mount_dir) opts = DataHolder(project="Fedora", releasever="devel", compression="xz", compress_args=[], arch="x86_64", squashfs_only=False) make_runtime(opts, mount_dir, work_dir) # Make sure it made an install.img self.assertTrue( os.path.exists(joinpaths(work_dir, "images/install.img"))) # Make sure it looks like a squashfs filesystem file_details = get_file_magic( joinpaths(work_dir, "images/install.img")) self.assertTrue("Squashfs" in file_details) # Make sure there is a rootfs.img inside the squashfs cmd = [ "unsquashfs", "-n", "-l", joinpaths(work_dir, "images/install.img") ] results = runcmd_output(cmd) self.assertTrue("rootfs.img" in results)
def make_livecd(opts, mount_dir, work_dir): """ Take the content from the disk image and make a livecd out of it :param opts: options passed to livemedia-creator :type opts: argparse options :param str mount_dir: Directory tree to compress :param str work_dir: Output compressed image to work_dir+images/install.img This uses wwood's squashfs live initramfs method: * put the real / into LiveOS/rootfs.img * make a squashfs of the LiveOS/rootfs.img tree * This is loaded by dracut when the cmdline is passed to the kernel: root=live:CDLABEL=<volid> rd.live.image """ kernel_arch = get_arch(mount_dir) arch = ArchData(kernel_arch) # TODO: Need to get release info from someplace... product = DataHolder(name=opts.project, version=opts.releasever, release="", variant="", bugurl="", isfinal=False) # Link /images to work_dir/images to make the templates happy if os.path.islink(joinpaths(mount_dir, "images")): os.unlink(joinpaths(mount_dir, "images")) execWithRedirect("/bin/ln", ["-s", joinpaths(work_dir, "images"), joinpaths(mount_dir, "images")]) # The templates expect the config files to be in /tmp/config_files # I think these should be release specific, not from lorax, but for now configdir = joinpaths(opts.lorax_templates,"live/config_files/") configdir_path = "tmp/config_files" fullpath = joinpaths(mount_dir, configdir_path) if os.path.exists(fullpath): remove(fullpath) copytree(configdir, fullpath) isolabel = opts.volid or "{0.name}-{0.version}-{1.basearch}".format(product, arch) if len(isolabel) > 32: isolabel = isolabel[:32] log.warning("Truncating isolabel to 32 chars: %s", isolabel) tb = TreeBuilder(product=product, arch=arch, domacboot=opts.domacboot, inroot=mount_dir, outroot=work_dir, runtime=RUNTIME, isolabel=isolabel, templatedir=joinpaths(opts.lorax_templates,"live/"), extra_boot_args=opts.extra_boot_args) log.info("Rebuilding initrds") if not opts.dracut_args: dracut_args = DRACUT_DEFAULT else: dracut_args = [] for arg in opts.dracut_args: dracut_args += arg.split(" ", 1) log.info("dracut args = %s", dracut_args) tb.rebuild_initrds(add_args=dracut_args) log.info("Building boot.iso") tb.build() return work_dir
def __init__(self, product, arch, dbo, templatedir=None, installpkgs=None, add_templates=None, add_template_vars=None): root = dbo.conf.installroot # use a copy of product so we can modify it locally product = product.copy() product.name = product.name.lower() self.vars = DataHolder(arch=arch, product=product, dbo=dbo, root=root, basearch=arch.basearch, libdir=arch.libdir) self.dbo = dbo self._runner = LoraxTemplateRunner(inroot=root, outroot=root, dbo=dbo, templatedir=templatedir) self.add_templates = add_templates or [] self.add_template_vars = add_template_vars or {} self._installpkgs = installpkgs or [] self._runner.defaults = self.vars self.dbo.reset()
def get_extra_pkgs(dbo, share_dir, compose_type): """Return extra packages needed for the output type :param dbo: dnf base object :type dbo: dnf.Base :param share_dir: Path to the top level share directory :type share_dir: str :param compose_type: The type of output to create from the recipe :type compose_type: str :returns: List of package names (name only, not NEVRA) :rtype: list Currently this is only needed by live-iso, it reads ./live/live-install.tmpl and processes only the installpkg lines. It lists the packages needed to complete creation of the iso using the templates such as x86.tmpl Keep in mind that the live-install.tmpl is shared between livemedia-creator and lorax-composer, even though the results are applied differently. """ if compose_type != "live-iso": return [] # get the arch information to pass to the runner arch = ArchData(get_buildarch(dbo)) defaults = DataHolder(basearch=arch.basearch) templatedir = joinpaths(find_templates(share_dir), "live") runner = LiveTemplateRunner(dbo, templatedir=templatedir, defaults=defaults) runner.run("live-install.tmpl") log.debug("extra pkgs = %s", runner.pkgs) return runner.pkgnames
def test_no_network(self): """Test a kickstart with missing network command""" opts = DataHolder(no_virt=True, make_fsimage=False, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("url --url=http://dl.fedoraproject.com\n" "part / --size=4096\n" "shutdown\n") errors = check_kickstart(ks, opts) self.assertTrue("The kickstart must activate networking" in errors[0])
def test_disk_size_align(self): """Test aligning the disk size""" opts = DataHolder(no_virt=True, make_fsimage=False, make_iso=False, make_pxe_live=False, image_size_align=1024) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("url --url=http://dl.fedoraproject.com\n" "network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "part / --size=4096\n" "shutdown\n") self.assertEqual(calculate_disk_size(opts, ks), 5120)
def test_good_ks_virt(self): """Test a good kickstart with virt""" opts = DataHolder(no_virt=False, make_fsimage=False, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("url --url=http://dl.fedoraproject.com\n" "network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "part / --size=4096\n" "shutdown\n") self.assertEqual(check_kickstart(ks, opts), [])
def test_shutdown_virt(self): """Test a kickstart with reboot instead of shutdown""" opts = DataHolder(no_virt=False, make_fsimage=True, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("url --url=http://dl.fedoraproject.com\n" "network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "part / --size=4096\n" "reboot\n") errors = check_kickstart(ks, opts) self.assertTrue("must include shutdown when using virt" in errors[0])
def test_autopart(self): """Test a kickstart with autopart""" opts = DataHolder(no_virt=True, make_fsimage=True, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("url --url=http://dl.fedoraproject.com\n" "network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "autopart\n" "shutdown\n") errors = check_kickstart(ks, opts) self.assertTrue("Filesystem images must use a single" in errors[0])
def test_nomethod_novirt(self): """Test a kickstart with repo and no url""" opts = DataHolder(no_virt=True, make_fsimage=False, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString("network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "part / --size=4096\n" "shutdown\n") errors = check_kickstart(ks, opts) self.assertTrue("Only url, nfs and ostreesetup" in errors[0]) self.assertTrue("repo can only be used with the url" in errors[1])
def get_branding(self, skip, product): """Select the branding from the available 'system-release' packages The *best* way to control this is to have a single package in the repo provide 'system-release' When there are more than 1 package it will: - Make a list of the available packages - If variant is set look for a package ending with lower(variant) and use that - If there are one or more non-generic packages, use the first one after sorting Returns the package names of the system-release and release logos package """ if skip: return DataHolder(release=None, logos=None) release = None q = self.dbo.sack.query() a = q.available() pkgs = sorted([p.name for p in a.filter(provides='system-release') if not p.name.startswith("generic")]) if not pkgs: logger.error("No system-release packages found, could not get the release") return DataHolder(release=None, logos=None) logger.debug("system-release packages: %s", pkgs) if product.variant: variant = [p for p in pkgs if p.endswith("-"+product.variant.lower())] if variant: release = variant[0] if not release: release = pkgs[0] # release logger.info('got release: %s', release) # logos uses the basename from release (fedora, redhat, centos, ...) logos, _suffix = release.split('-', 1) return DataHolder(release=release, logos=logos+"-logos")
def __init__(self, product, arch, inroot, outroot, runtime, isolabel, domacboot=True, doupgrade=True, templatedir=None, add_templates=None, add_template_vars=None, workdir=None): # NOTE: if you pass an arg named "runtime" to a mako template it'll # clobber some mako internal variables - hence "runtime_img". self.vars = DataHolder(arch=arch, product=product, runtime_img=runtime, runtime_base=basename(runtime), inroot=inroot, outroot=outroot, basearch=arch.basearch, libdir=arch.libdir, isolabel=isolabel, udev=udev_escape, domacboot=domacboot, doupgrade=doupgrade, workdir=workdir, lower=string_lower) self._runner = LoraxTemplateRunner(inroot, outroot, templatedir=templatedir) self._runner.defaults = self.vars self.add_templates = add_templates or [] self.add_template_vars = add_template_vars or {} self.templatedir = templatedir self.treeinfo_data = None
def make_appliance(disk_img, name, template, outfile, networks=None, ram=1024, vcpus=1, arch=None, title="Linux", project="Linux", releasever="29"): """ Generate an appliance description file :param str disk_img: Full path of the disk image :param str name: Name of the appliance, passed to the template :param str template: Full path of Mako template :param str outfile: Full path of file to write, using template :param list networks: List of networks(str) from the kickstart :param int ram: Ram, in MiB, passed to template. Default is 1024 :param int vcpus: CPUs, passed to template. Default is 1 :param str arch: CPU architecture. Default is 'x86_64' :param str title: Title, passed to template. Default is 'Linux' :param str project: Project, passed to template. Default is 'Linux' :param str releasever: Release version, passed to template. Default is 29 """ if not (disk_img and template and outfile): return None log.info("Creating appliance definition using %s", template) if not arch: arch = "x86_64" log.info("Calculating SHA256 checksum of %s", disk_img) sha256 = hashlib.sha256() with open(disk_img, "rb") as f: while True: data = f.read(1024**2) if not data: break sha256.update(data) log.info("SHA256 of %s is %s", disk_img, sha256.hexdigest()) disk_info = DataHolder(name=os.path.basename(disk_img), format="raw", checksum_type="sha256", checksum=sha256.hexdigest()) try: result = Template(filename=template).render(disks=[disk_info], name=name, arch=arch, memory=ram, vcpus=vcpus, networks=networks, title=title, project=project, releasever=releasever) except Exception: log.error(text_error_template().render()) raise with open(outfile, "w") as f: f.write(result)
def displaymode_test(self): """Test a kickstart with displaymode set""" opts = DataHolder(no_virt=True, make_fsimage=False, make_pxe_live=False) ks_version = makeVersion() ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) ks.readKickstartFromString( "url --url=http://dl.fedoraproject.com\n" "network --bootproto=dhcp --activate\n" "repo --name=other --baseurl=http://dl.fedoraproject.com\n" "part / --size=4096\n" "shutdown\n" "graphical\n") errors = check_kickstart(ks, opts) self.assertTrue("must not set a display mode" in errors[0])
def setUpClass(self): self.maxDiff = None self.config = dict() repo_dir = tempfile.mkdtemp(prefix="lorax.test.repo.") self.config["REPO_DIR"] = repo_dir self.config["COMPOSER_CFG"] = configure(root_dir=repo_dir, test_config=True) lifted.config.configure(self.config["COMPOSER_CFG"]) os.makedirs(joinpaths(self.config["COMPOSER_CFG"].get("composer", "share_dir"), "composer")) errors = make_queue_dirs(self.config["COMPOSER_CFG"], os.getgid()) if errors: raise RuntimeError("\n".join(errors)) lib_dir = self.config["COMPOSER_CFG"].get("composer", "lib_dir") share_dir = self.config["COMPOSER_CFG"].get("composer", "share_dir") tmp = self.config["COMPOSER_CFG"].get("composer", "tmp") self.monitor_cfg = DataHolder(composer_dir=lib_dir, share_dir=share_dir, uid=0, gid=0, tmp=tmp)
def start_queue_monitor(cfg, uid, gid): """Start the queue monitor as a mp process :param cfg: Configuration settings :type cfg: ComposerConfig :param uid: User ID that owns the queue :type uid: int :param gid: Group ID that owns the queue :type gid: int :returns: None """ lib_dir = cfg.get("composer", "lib_dir") share_dir = cfg.get("composer", "share_dir") tmp = cfg.get("composer", "tmp") monitor_cfg = DataHolder(cfg=cfg, composer_dir=lib_dir, share_dir=share_dir, uid=uid, gid=gid, tmp=tmp) p = mp.Process(target=monitor, args=(monitor_cfg,)) p.daemon = True p.start()
def make_squashfs_test(self): """Test making a squashfs image""" with tempfile.TemporaryDirectory(prefix="lorax.test.") as work_dir: with tempfile.NamedTemporaryFile( prefix="lorax.test.disk.") as disk_img: # Make a small ext4 disk image mksparse(disk_img.name, 42 * 1024**2) runcmd([ "mkfs.ext4", "-L", "Anaconda", "-b", "4096", "-m", "0", disk_img.name ]) opts = DataHolder(compression="xz", arch="x86_64") make_squashfs(opts, disk_img.name, work_dir) # Make sure it made an install.img self.assertTrue( os.path.exists(joinpaths(work_dir, "images/install.img"))) # Make sure it looks like a squashfs filesystem file_details = get_file_magic( joinpaths(work_dir, "images/install.img")) self.assertTrue("Squashfs" in file_details)
def make_compose(cfg, results_dir): """Run anaconda with the final-kickstart.ks from results_dir :param cfg: Configuration settings :type cfg: DataHolder :param results_dir: The directory containing the metadata and results for the build :type results_dir: str :returns: Nothing :raises: May raise various exceptions This takes the final-kickstart.ks, and the settings in config.toml and runs Anaconda in no-virt mode (directly on the host operating system). Exceptions should be caught at the higer level. If there is a failure, the build artifacts will be cleaned up, and any logs will be moved into logs/anaconda/ and their ownership will be set to the user from the cfg object. """ # Check on the ks's presence ks_path = joinpaths(results_dir, "final-kickstart.ks") if not os.path.exists(ks_path): raise RuntimeError("Missing kickstart file at %s" % ks_path) # The anaconda logs are copied into ./anaconda/ in this directory log_dir = joinpaths(results_dir, "logs/") if not os.path.exists(log_dir): os.makedirs(log_dir) # Load the compose configuration cfg_path = joinpaths(results_dir, "config.toml") if not os.path.exists(cfg_path): raise RuntimeError("Missing config.toml for %s" % results_dir) cfg_dict = toml.loads(open(cfg_path, "r").read()) # The keys in cfg_dict correspond to the arguments setup in livemedia-creator # keys that define what to build should be setup in compose_args, and keys with # defaults should be setup here. # Make sure that image_name contains no path components cfg_dict["image_name"] = os.path.basename(cfg_dict["image_name"]) # Only support novirt installation, set some other defaults cfg_dict["no_virt"] = True cfg_dict["disk_image"] = None cfg_dict["fs_image"] = None cfg_dict["keep_image"] = False cfg_dict["domacboot"] = False cfg_dict["anaconda_args"] = "" cfg_dict["proxy"] = "" cfg_dict["armplatform"] = "" cfg_dict["squashfs_args"] = None cfg_dict["lorax_templates"] = find_templates(cfg.share_dir) cfg_dict["tmp"] = cfg.tmp cfg_dict["dracut_args"] = None # Use default args for dracut # TODO How to support other arches? cfg_dict["arch"] = None # Compose things in a temporary directory inside the results directory cfg_dict["result_dir"] = joinpaths(results_dir, "compose") os.makedirs(cfg_dict["result_dir"]) install_cfg = DataHolder(**cfg_dict) # Some kludges for the 99-copy-logs %post, failure in it will crash the build for f in ["/tmp/NOSAVE_INPUT_KS", "/tmp/NOSAVE_LOGS"]: open(f, "w") # Placing a CANCEL file in the results directory will make execWithRedirect send anaconda a SIGTERM def cancel_build(): return os.path.exists(joinpaths(results_dir, "CANCEL")) log.debug("cfg = %s", install_cfg) try: test_path = joinpaths(results_dir, "TEST") if os.path.exists(test_path): # Pretend to run the compose time.sleep(10) try: test_mode = int(open(test_path, "r").read()) except Exception: test_mode = 1 if test_mode == 1: raise RuntimeError("TESTING FAILED compose") else: open(joinpaths(results_dir, install_cfg.image_name), "w").write("TEST IMAGE") else: run_creator(install_cfg, callback_func=cancel_build) # Extract the results of the compose into results_dir and cleanup the compose directory move_compose_results(install_cfg, results_dir) finally: # Make sure any remaining temporary directories are removed (eg. if there was an exception) for d in glob(joinpaths(cfg.tmp, "lmc-*")): if os.path.isdir(d): shutil.rmtree(d) elif os.path.isfile(d): os.unlink(d) # Make sure that everything under the results directory is owned by the user user = pwd.getpwuid(cfg.uid).pw_name group = grp.getgrgid(cfg.gid).gr_name log.debug("Install finished, chowning results to %s:%s", user, group) subprocess.call(["chown", "-R", "%s:%s" % (user, group), results_dir])
def run(self, dbo, product, version, release, variant="", bugurl="", isfinal=False, workdir=None, outputdir=None, buildarch=None, volid=None, domacboot=True, doupgrade=True, remove_temp=False, installpkgs=None, excludepkgs=None, size=2, add_templates=None, add_template_vars=None, add_arch_templates=None, add_arch_template_vars=None, verify=True): assert self._configured installpkgs = installpkgs or [] excludepkgs = excludepkgs or [] if domacboot: try: runcmd(["rpm", "-q", "hfsplus-tools"]) except CalledProcessError: logger.critical( "you need to install hfsplus-tools to create mac images") sys.exit(1) # set up work directory self.workdir = workdir or tempfile.mkdtemp(prefix="pylorax.work.") if not os.path.isdir(self.workdir): os.makedirs(self.workdir) # set up log directory logdir = self.conf.get("lorax", "logdir") if not os.path.isdir(logdir): os.makedirs(logdir) self.init_stream_logging() self.init_file_logging(logdir) logger.debug("version is %s", vernum) logger.debug("using work directory %s", self.workdir) logger.debug("using log directory %s", logdir) # set up output directory self.outputdir = outputdir or tempfile.mkdtemp(prefix="pylorax.out.") if not os.path.isdir(self.outputdir): os.makedirs(self.outputdir) logger.debug("using output directory %s", self.outputdir) # do we have root privileges? logger.info("checking for root privileges") if not os.geteuid() == 0: logger.critical("no root privileges") sys.exit(1) # is selinux disabled? # With selinux in enforcing mode the rpcbind package required for # dracut nfs module, which is in turn required by anaconda module, # will not get installed, because it's preinstall scriptlet fails, # resulting in an incomplete initial ramdisk image. # The reason is that the scriptlet runs tools from the shadow-utils # package in chroot, particularly groupadd and useradd to add the # required rpc group and rpc user. This operation fails, because # the selinux context on files in the chroot, that the shadow-utils # tools need to access (/etc/group, /etc/passwd, /etc/shadow etc.), # is wrong and selinux therefore disallows access to these files. logger.info("checking the selinux mode") if selinux.is_selinux_enabled() and selinux.security_getenforce(): logger.critical("selinux must be disabled or in Permissive mode") sys.exit(1) # do we have a proper dnf base object? logger.info("checking dnf base object") if not isinstance(dbo, dnf.Base): logger.critical("no dnf base object") sys.exit(1) self.inroot = dbo.conf.installroot logger.debug("using install root: %s", self.inroot) if not buildarch: buildarch = get_buildarch(dbo) logger.info("setting up build architecture") self.arch = ArchData(buildarch) for attr in ('buildarch', 'basearch', 'libdir'): logger.debug("self.arch.%s = %s", attr, getattr(self.arch, attr)) logger.info("setting up build parameters") self.product = DataHolder(name=product, version=version, release=release, variant=variant, bugurl=bugurl, isfinal=isfinal) logger.debug("product data: %s", self.product) # NOTE: if you change isolabel, you need to change pungi to match, or # the pungi images won't boot. isolabel = volid or "%s-%s-%s" % ( self.product.name, self.product.version, self.arch.basearch) if len(isolabel) > 32: logger.fatal("the volume id cannot be longer than 32 characters") sys.exit(1) # NOTE: rb.root = dbo.conf.installroot (== self.inroot) rb = RuntimeBuilder(product=self.product, arch=self.arch, dbo=dbo, templatedir=self.templatedir, installpkgs=installpkgs, excludepkgs=excludepkgs, add_templates=add_templates, add_template_vars=add_template_vars) logger.info("installing runtime packages") rb.install() # write .buildstamp buildstamp = BuildStamp(self.product.name, self.product.version, self.product.bugurl, self.product.isfinal, self.arch.buildarch, self.product.variant) buildstamp.write(joinpaths(self.inroot, ".buildstamp")) if self.debug: rb.writepkglists(joinpaths(logdir, "pkglists")) rb.writepkgsizes(joinpaths(logdir, "original-pkgsizes.txt")) logger.info("doing post-install configuration") rb.postinstall() # write .discinfo discinfo = DiscInfo(self.product.release, self.arch.basearch) discinfo.write(joinpaths(self.outputdir, ".discinfo")) logger.info("backing up installroot") installroot = joinpaths(self.workdir, "installroot") linktree(self.inroot, installroot) logger.info("generating kernel module metadata") rb.generate_module_data() logger.info("cleaning unneeded files") rb.cleanup() if verify: logger.info("verifying the installroot") if not rb.verify(): sys.exit(1) else: logger.info("Skipping verify") if self.debug: rb.writepkgsizes(joinpaths(logdir, "final-pkgsizes.txt")) logger.info("creating the runtime image") runtime = "images/install.img" compression = self.conf.get("compression", "type") compressargs = self.conf.get("compression", "args").split() # pylint: disable=no-member if self.conf.getboolean("compression", "bcj"): if self.arch.bcj: compressargs += ["-Xbcj", self.arch.bcj] else: logger.info("no BCJ filter for arch %s", self.arch.basearch) rb.create_runtime(joinpaths(installroot, runtime), compression=compression, compressargs=compressargs, size=size) rb.finished() logger.info("preparing to build output tree and boot images") treebuilder = TreeBuilder(product=self.product, arch=self.arch, inroot=installroot, outroot=self.outputdir, runtime=runtime, isolabel=isolabel, domacboot=domacboot, doupgrade=doupgrade, templatedir=self.templatedir, add_templates=add_arch_templates, add_template_vars=add_arch_template_vars, workdir=self.workdir) logger.info("rebuilding initramfs images") dracut_args = [ "--xz", "--install", "/.buildstamp", "--no-early-microcode", "--add", "fips" ] anaconda_args = dracut_args + [ "--add", "anaconda pollcdrom qemu qemu-net" ] # ppc64 cannot boot an initrd > 32MiB so remove some drivers if self.arch.basearch in ("ppc64", "ppc64le"): dracut_args.extend(["--omit-drivers", REMOVE_PPC64_DRIVERS]) # Only omit dracut modules from the initrd so that they're kept for # upgrade.img anaconda_args.extend(["--omit", REMOVE_PPC64_MODULES]) treebuilder.rebuild_initrds(add_args=anaconda_args) logger.info("populating output tree and building boot images") treebuilder.build() # write .treeinfo file and we're done treeinfo = TreeInfo(self.product.name, self.product.version, self.product.variant, self.arch.basearch) for section, data in treebuilder.treeinfo_data.items(): treeinfo.add_section(section, data) treeinfo.write(joinpaths(self.outputdir, ".treeinfo")) # cleanup if remove_temp: remove(self.workdir)
def __init__(self, commit, timestamp, message, revision=None): DataHolder.__init__(self, commit = commit, timestamp = timestamp, message = message, revision = revision)
class LoraxTemplateRunner(object): ''' This class parses and executes Lorax templates. Sample usage: # install a bunch of packages runner = LoraxTemplateRunner(inroot=rundir, outroot=rundir, dbo=dnf_obj) runner.run("install-packages.ltmpl") # modify a runtime dir runner = LoraxTemplateRunner(inroot=rundir, outroot=newrun) runner.run("runtime-transmogrify.ltmpl") NOTES: * Parsing procedure is roughly: 1. Mako template expansion (on the whole file) 2. For each line of the result, a. Whitespace splitting (using shlex.split()) b. Brace expansion (using brace_expand()) c. If the first token is the name of a function, call that function with the rest of the line as arguments * Parsing and execution are *separate* passes - so you can't use the result of a command in an %if statement (or any other control statements)! * Commands that run external programs (systemctl, gconfset) currently use the *host*'s copy of that program, which may cause problems if there's a big enough difference between the host and the image you're modifying. * The commands are not executed under a real chroot, so absolute symlinks will point *outside* the inroot/outroot. Be careful with symlinks! ADDING NEW COMMANDS: * Each template command is just a method of the LoraxTemplateRunner object - so adding a new command is as easy as adding a new function. * Each function gets arguments that correspond to the rest of the tokens on that line (after word splitting and brace expansion) * Commands should raise exceptions for errors - don't use sys.exit() ''' def __init__(self, inroot, outroot, dbo=None, fatalerrors=True, templatedir=None, defaults=None): self.inroot = inroot self.outroot = outroot self.dbo = dbo self.fatalerrors = fatalerrors self.templatedir = templatedir or "/usr/share/lorax" self.templatefile = None # some builtin methods self.builtins = DataHolder(exists=lambda p: rexists(p, root=inroot), glob=lambda g: list(rglob(g, root=inroot))) self.defaults = defaults or {} self.results = DataHolder(treeinfo=dict()) # just treeinfo for now # TODO: set up custom logger with a filter to add line info def _out(self, path): return joinpaths(self.outroot, path) def _in(self, path): return joinpaths(self.inroot, path) def _filelist(self, *pkgs): """ Return the list of files in the packages """ pkglist = [] for pkg_glob in pkgs: pkglist += list(self.dbo.sack.query().installed().filter(name__glob=pkg_glob)) # dnf/hawkey doesn't make any distinction between file, dir or ghost like yum did # so only return the files. return set(f for pkg in pkglist for f in pkg.files if not os.path.isdir(self._out(f))) def _getsize(self, *files): return sum(os.path.getsize(self._out(f)) for f in files if os.path.isfile(self._out(f))) def run(self, templatefile, **variables): for k,v in list(self.defaults.items()) + list(self.builtins.items()): variables.setdefault(k,v) logger.debug("executing %s with variables=%s", templatefile, variables) self.templatefile = templatefile t = LoraxTemplate(directories=[self.templatedir]) commands = t.parse(templatefile, variables) self._run(commands) def _run(self, parsed_template): logger.info("running %s", self.templatefile) for (num, line) in enumerate(parsed_template,1): logger.debug("template line %i: %s", num, " ".join(line)) skiperror = False (cmd, args) = (line[0], line[1:]) # Following Makefile convention, if the command is prefixed with # a dash ('-'), we'll ignore any errors on that line. if cmd.startswith('-'): cmd = cmd[1:] skiperror = True try: # grab the method named in cmd and pass it the given arguments f = getattr(self, cmd, None) if cmd[0] == '_' or cmd == 'run' or not isinstance(f, collections.Callable): raise ValueError("unknown command %s" % cmd) f(*args) except Exception: # pylint: disable=broad-except if skiperror: logger.debug("ignoring error") continue logger.error("template command error in %s:", self.templatefile) logger.error(" %s", " ".join(line)) # format the exception traceback exclines = traceback.format_exception(*sys.exc_info()) # skip the bit about "ltmpl.py, in _run()" - we know that exclines.pop(1) # log the "ErrorType: this is what happened" line logger.error(" " + exclines[-1].strip()) # and log the entire traceback to the debug log for line in ''.join(exclines).splitlines(): logger.debug(" " + line) if self.fatalerrors: raise def install(self, srcglob, dest): ''' install SRC DEST Copy the given file (or files, if a glob is used) from the input tree to the given destination in the output tree. The path to DEST must exist in the output tree. If DEST is a directory, SRC will be copied into that directory. If DEST doesn't exist, SRC will be copied to a file with that name, assuming the rest of the path exists. This is pretty much like how the 'cp' command works. Examples: install usr/share/myconfig/grub.conf /boot install /usr/share/myconfig/grub.conf.in /boot/grub.conf ''' for src in rglob(self._in(srcglob), fatal=True): cpfile(src, self._out(dest)) def installimg(self, srcdir, destfile): ''' installimg SRCDIR DESTFILE Create a compressed cpio archive of the contents of SRCDIR and place it in DESTFILE. If SRCDIR doesn't exist or is empty nothing is created. Examples: installimg ${LORAXDIR}/product/ images/product.img installimg ${LORAXDIR}/updates/ images/updates.img ''' if not os.path.isdir(self._in(srcdir)) or not os.listdir(self._in(srcdir)): return logger.info("Creating image file %s from contents of %s", self._out(destfile), self._in(srcdir)) mkcpio(self._in(srcdir), self._out(destfile)) def mkdir(self, *dirs): ''' mkdir DIR [DIR ...] Create the named DIR(s). Will create leading directories as needed. Example: mkdir /images ''' for d in dirs: d = self._out(d) if not isdir(d): os.makedirs(d) def replace(self, pat, repl, *fileglobs): ''' replace PATTERN REPLACEMENT FILEGLOB [FILEGLOB ...] Find-and-replace the given PATTERN (Python-style regex) with the given REPLACEMENT string for each of the files listed. Example: replace @VERSION@ ${product.version} /boot/grub.conf /boot/isolinux.cfg ''' match = False for g in fileglobs: for f in rglob(self._out(g)): match = True replace(f, pat, repl) if not match: raise IOError("no files matched %s" % " ".join(fileglobs)) def append(self, filename, data): ''' append FILE STRING Append STRING (followed by a newline character) to FILE. Python character escape sequences ('\\n', '\\t', etc.) will be converted to the appropriate characters. Examples: append /etc/depmod.d/dd.conf "search updates built-in" append /etc/resolv.conf "" ''' with open(self._out(filename), "a") as fobj: fobj.write(bytes(data, "utf8").decode('unicode_escape')+"\n") def treeinfo(self, section, key, *valuetoks): ''' treeinfo SECTION KEY ARG [ARG ...] Add an item to the treeinfo data store. The given SECTION will have a new item added where KEY = ARG ARG ... Example: treeinfo images-${kernel.arch} boot.iso images/boot.iso ''' if section not in self.results.treeinfo: self.results.treeinfo[section] = dict() self.results.treeinfo[section][key] = " ".join(valuetoks) def installkernel(self, section, src, dest): ''' installkernel SECTION SRC DEST Install the kernel from SRC in the input tree to DEST in the output tree, and then add an item to the treeinfo data store, in the named SECTION, where "kernel" = DEST. Equivalent to: install SRC DEST treeinfo SECTION kernel DEST ''' self.install(src, dest) self.treeinfo(section, "kernel", dest) def installinitrd(self, section, src, dest): ''' installinitrd SECTION SRC DEST Same as installkernel, but for "initrd". ''' self.install(src, dest) self.chmod(dest, '644') self.treeinfo(section, "initrd", dest) def installupgradeinitrd(self, section, src, dest): ''' installupgradeinitrd SECTION SRC DEST Same as installkernel, but for "upgrade". ''' self.install(src, dest) self.chmod(dest, '644') self.treeinfo(section, "upgrade", dest) def hardlink(self, src, dest): ''' hardlink SRC DEST Create a hardlink at DEST which is linked to SRC. ''' if isdir(self._out(dest)): dest = joinpaths(dest, basename(src)) os.link(self._out(src), self._out(dest)) def symlink(self, target, dest): ''' symlink SRC DEST Create a symlink at DEST which points to SRC. ''' if rexists(self._out(dest)): self.remove(dest) os.symlink(target, self._out(dest)) def copy(self, src, dest): ''' copy SRC DEST Copy SRC to DEST. If DEST is a directory, SRC will be copied inside it. If DEST doesn't exist, SRC will be copied to a file with that name, if the path leading to it exists. ''' cpfile(self._out(src), self._out(dest)) def move(self, src, dest): ''' move SRC DEST Move SRC to DEST. ''' mvfile(self._out(src), self._out(dest)) def remove(self, *fileglobs): ''' remove FILEGLOB [FILEGLOB ...] Remove all the named files or directories. Will *not* raise exceptions if the file(s) are not found. ''' for g in fileglobs: for f in rglob(self._out(g)): remove(f) logger.debug("removed %s", f) def chmod(self, fileglob, mode): ''' chmod FILEGLOB OCTALMODE Change the mode of all the files matching FILEGLOB to OCTALMODE. ''' for f in rglob(self._out(fileglob), fatal=True): os.chmod(f, int(mode,8)) # TODO: do we need a new command for gsettings? def gconfset(self, path, keytype, value, outfile=None): ''' gconfset PATH KEYTYPE VALUE [OUTFILE] Set the given gconf PATH, with type KEYTYPE, to the given value. OUTFILE defaults to /etc/gconf/gconf.xml.defaults if not given. Example: gconfset /apps/metacity/general/num_workspaces int 1 ''' if outfile is None: outfile = self._out("etc/gconf/gconf.xml.defaults") cmd = ["gconftool-2", "--direct", "--config-source=xml:readwrite:%s" % outfile, "--set", "--type", keytype, path, value] runcmd(cmd) def log(self, msg): ''' log MESSAGE Emit the given log message. Be sure to put it in quotes! Example: log "Reticulating splines, please wait..." ''' logger.info(msg) # TODO: add ssh-keygen, mkisofs(?), find, and other useful commands def runcmd(self, *cmdlist): ''' runcmd CMD [ARG ...] Run the given command with the given arguments. NOTE: All paths given MUST be COMPLETE, ABSOLUTE PATHS to the file or files mentioned. ${root}/${inroot}/${outroot} are good for constructing these paths. FURTHER NOTE: Please use this command only as a last resort! Whenever possible, you should use the existing template commands. If the existing commands don't do what you need, fix them! Examples: (this should be replaced with a "find" function) runcmd find ${root} -name "*.pyo" -type f -delete %for f in find(root, name="*.pyo"): remove ${f} %endfor ''' cmd = cmdlist logger.debug('running command: %s', cmd) if cmd[0].startswith("--chdir="): logger.error("--chdir is no longer supported for runcmd.") raise ValueError("--chdir is no longer supported for runcmd.") try: stdout = runcmd_output(cmd) if stdout: logger.debug('command output:\n%s', stdout) logger.debug("command finished successfully") except CalledProcessError as e: if e.output: logger.debug('command output:\n%s', e.output) logger.debug('command returned failure (%d)', e.returncode) raise def installpkg(self, *pkgs): ''' installpkg [--required] PKGGLOB [PKGGLOB ...] Request installation of all packages matching the given globs. Note that this is just a *request* - nothing is *actually* installed until the 'run_pkg_transaction' command is given. ''' required = False if pkgs[0] == '--required': pkgs = pkgs[1:] required = True for p in pkgs: try: self.dbo.install(p) except Exception as e: # pylint: disable=broad-except # FIXME: save exception and re-raise after the loop finishes logger.error("installpkg %s failed: %s", p, str(e)) if required: raise def removepkg(self, *pkgs): ''' removepkg PKGGLOB [PKGGLOB...] Delete the named package(s). IMPLEMENTATION NOTES: RPM scriptlets (%preun/%postun) are *not* run. Files are deleted, but directories are left behind. ''' for p in pkgs: filepaths = [f.lstrip('/') for f in self._filelist(p)] # TODO: also remove directories that aren't owned by anything else if filepaths: logger.debug("removepkg %s: %ikb", p, self._getsize(*filepaths)/1024) self.remove(*filepaths) else: logger.debug("removepkg %s: no files to remove!", p) def get_token_checked(self, process, token_queue): """Try to get token from queue checking that process is still alive""" try: # wait at most a minute for the token (token, msg) = token_queue.get(timeout=60) except queue.Empty: if process.is_alive(): try: # process still alive, give it 2 minutes more (token, msg) = token_queue.get(timeout=120) except queue.Empty: # waited for 3 minutes and got nothing raise Exception("The transaction process got stuck somewhere (no message from it in 3 minutes)") else: raise Exception("The transaction process has ended abruptly") return (token, msg) def run_pkg_transaction(self): ''' run_pkg_transaction Actually install all the packages requested by previous 'installpkg' commands. ''' def do_transaction(base, token_queue): try: display = LoraxRpmCallback(token_queue) base.do_transaction(display=display) except BaseException as e: logger.error("The transaction process has ended abruptly: %s", e) token_queue.put(('quit', str(e))) try: logger.info("Checking dependencies") self.dbo.resolve() except dnf.exceptions.DepsolveError as e: logger.error("Dependency check failed: %s", e) raise logger.info("%d packages selected", len(self.dbo.transaction)) if len(self.dbo.transaction) == 0: raise Exception("No packages in transaction") pkgs_to_download = self.dbo.transaction.install_set logger.info("Downloading packages") progress = LoraxDownloadCallback() try: self.dbo.download_packages(pkgs_to_download, progress) except dnf.exceptions.DownloadError as e: logger.error("Failed to download the following packages: %s", e) raise logger.info("Preparing transaction from installation source") token_queue = multiprocessing.Queue() msgout = output.LoraxOutput() process = multiprocessing.Process(target=do_transaction, args=(self.dbo, token_queue)) process.start() (token, msg) = self.get_token_checked(process, token_queue) while token not in ('post', 'quit'): if token == 'install': logging.info("%s", msg) msgout.writeline(msg) (token, msg) = self.get_token_checked(process, token_queue) if token == 'quit': logger.error("Transaction failed.") raise Exception("Transaction failed") logger.info("Performing post-installation setup tasks") process.join() # Reset the package sack to pick up the installed packages self.dbo.reset(repos=False) self.dbo.fill_sack(load_system_repo=True, load_available_repos=False) # At this point dnf should know about the installed files. Double check that it really does. if len(self._filelist("anaconda-core")) == 0: raise Exception("Failed to reset dbo to installed package set") def removefrom(self, pkg, *globs): ''' removefrom PKGGLOB [--allbut] FILEGLOB [FILEGLOB...] Remove all files matching the given file globs from the package (or packages) named. If '--allbut' is used, all the files from the given package(s) will be removed *except* the ones which match the file globs. Examples: removefrom usbutils /usr/bin/* removefrom xfsprogs --allbut /sbin/* ''' cmd = "%s %s" % (pkg, " ".join(globs)) # save for later logging keepmatches = False if globs[0] == '--allbut': keepmatches = True globs = globs[1:] # get pkg filelist and find files that match the globs filelist = self._filelist(pkg) matches = set() for g in globs: globs_re = re.compile(fnmatch.translate(g)) m = [f for f in filelist if globs_re.match(f)] if m: matches.update(m) else: logger.debug("removefrom %s %s: no files matched!", pkg, g) # are we removing the matches, or keeping only the matches? if keepmatches: remove_files = filelist.difference(matches) else: remove_files = matches # remove the files if remove_files: logger.debug("removefrom %s: removed %i/%i files, %ikb/%ikb", cmd, len(remove_files), len(filelist), self._getsize(*remove_files)/1024, self._getsize(*filelist)/1024) self.remove(*remove_files) else: logger.debug("removefrom %s: no files to remove!", cmd) def removekmod(self, *globs): ''' removekmod GLOB [GLOB...] [--allbut] KEEPGLOB [KEEPGLOB...] Remove all files and directories matching the given file globs from the kernel modules directory. If '--allbut' is used, all the files from the modules will be removed *except* the ones which match the file globs. There must be at least one initial GLOB to search and one KEEPGLOB to keep. The KEEPGLOB is expanded to be *KEEPGLOB* so that it will match anywhere in the path. This only removes files from under /lib/modules/*/kernel/ Examples: removekmod sound drivers/media drivers/hwmon drivers/video removekmod drivers/char --allbut virtio_console hw_random ''' cmd = " ".join(globs) if "--allbut" in globs: idx = globs.index("--allbut") if idx == 0: raise ValueError("removekmod needs at least one GLOB before --allbut") # Apply keepglobs anywhere they appear in the path keepglobs = globs[idx+1:] if len(keepglobs) == 0: raise ValueError("removekmod needs at least one GLOB after --allbut") globs = globs[:idx] else: # Nothing to keep keepglobs = [] filelist = set() for g in globs: for top_dir in rglob(self._out("/lib/modules/*/kernel/"+g)): for root, _dirs, files in os.walk(top_dir): filelist.update(root+"/"+f for f in files) # Remove anything matching keepglobs from the list matches = set() for g in keepglobs: globs_re = re.compile(fnmatch.translate("*"+g+"*")) m = [f for f in filelist if globs_re.match(f)] if m: matches.update(m) else: logger.debug("removekmod %s: no files matched!", g) remove_files = filelist.difference(matches) if remove_files: logger.debug("removekmod: removing %d files", len(remove_files)) list(remove(f) for f in remove_files) else: logger.debug("removekmod %s: no files to remove!", cmd) def createaddrsize(self, addr, src, dest): ''' createaddrsize INITRD_ADDRESS INITRD ADDRSIZE Create the initrd.addrsize file required in LPAR boot process. Examples: createaddrsize ${INITRD_ADDRESS} ${outroot}/${BOOTDIR}/initrd.img ${outroot}/${BOOTDIR}/initrd.addrsize ''' addrsize = open(dest, "wb") addrsize_data = struct.pack(">iiii", 0, int(addr, 16), 0, os.stat(src).st_size) addrsize.write(addrsize_data) addrsize.close() def systemctl(self, cmd, *units): ''' systemctl [enable|disable|mask] UNIT [UNIT...] Enable, disable, or mask the given systemd units. Examples: systemctl disable lvm2-monitor.service systemctl mask fedora-storage-init.service fedora-configure.service ''' if cmd not in ('enable', 'disable', 'mask'): raise ValueError('unsupported systemctl cmd: %s' % cmd) if not units: logger.debug("systemctl: no units given for %s, ignoring", cmd) return self.mkdir("/run/systemd/system") # XXX workaround for systemctl bug systemctl = ('systemctl', '--root', self.outroot, '--no-reload', '--quiet', cmd) # XXX for some reason 'systemctl enable/disable' always returns 1 try: cmd = systemctl + units runcmd(cmd) except CalledProcessError: pass
class LoraxTemplateRunner(object): ''' This class parses and executes Lorax templates. Sample usage: # install a bunch of packages runner = LoraxTemplateRunner(inroot=rundir, outroot=rundir, dbo=dnf_obj) runner.run("install-packages.ltmpl") # modify a runtime dir runner = LoraxTemplateRunner(inroot=rundir, outroot=newrun) runner.run("runtime-transmogrify.ltmpl") NOTES: * Parsing procedure is roughly: 1. Mako template expansion (on the whole file) 2. For each line of the result, a. Whitespace splitting (using shlex.split()) b. Brace expansion (using brace_expand()) c. If the first token is the name of a function, call that function with the rest of the line as arguments * Parsing and execution are *separate* passes - so you can't use the result of a command in an %if statement (or any other control statements)! * Commands that run external programs (systemctl, gconfset) currently use the *host*'s copy of that program, which may cause problems if there's a big enough difference between the host and the image you're modifying. * The commands are not executed under a real chroot, so absolute symlinks will point *outside* the inroot/outroot. Be careful with symlinks! ADDING NEW COMMANDS: * Each template command is just a method of the LoraxTemplateRunner object - so adding a new command is as easy as adding a new function. * Each function gets arguments that correspond to the rest of the tokens on that line (after word splitting and brace expansion) * Commands should raise exceptions for errors - don't use sys.exit() ''' def __init__(self, inroot, outroot, dbo=None, fatalerrors=True, templatedir=None, defaults=None): self.inroot = inroot self.outroot = outroot self.dbo = dbo self.fatalerrors = fatalerrors self.templatedir = templatedir or "/usr/share/lorax" self.templatefile = None # some builtin methods self.builtins = DataHolder(exists=lambda p: rexists(p, root=inroot), glob=lambda g: list(rglob(g, root=inroot))) self.defaults = defaults or {} self.results = DataHolder(treeinfo=dict()) # just treeinfo for now # TODO: set up custom logger with a filter to add line info def _out(self, path): return joinpaths(self.outroot, path) def _in(self, path): return joinpaths(self.inroot, path) def _filelist(self, *pkgs): """ Return the list of files in the packages """ pkglist = [] for pkg_glob in pkgs: pkglist += list( self.dbo.sack.query().installed().filter(name__glob=pkg_glob)) # dnf/hawkey doesn't make any distinction between file, dir or ghost like yum did # so only return the files. return set(f for pkg in pkglist for f in pkg.files if not os.path.isdir(self._out(f))) def _getsize(self, *files): return sum( os.path.getsize(self._out(f)) for f in files if os.path.isfile(self._out(f))) def _write_debuginfo_log(self): """ Write a list of debuginfo packages to /root/debug-pkgs.log If lorax is called with a debug repo find the corresponding debuginfo package names and write them to /root/debubg-pkgs.log on the boot.iso """ for repo in self.dbo.repos: repo = self.dbo.repos[repo] if any(True for url in repo.baseurl if "debug" in url): break if repo.metalink and "debug" in repo.metalink: break if repo.mirrorlist and "debug" in repo.mirrorlist: break else: # No debug repos return available = self.dbo.sack.query().available() debug_pkgs = [] for p in list(self.dbo.transaction.install_set): if available.filter(name=p.name + "-debuginfo"): debug_pkgs += [ "{0.name}-debuginfo-{0.epoch}:{0.version}-{0.release}". format(p) ] os.makedirs(self._out("root/"), exist_ok=True) with open(self._out("root/debug-pkgs.log"), "w") as f: for pkg in debug_pkgs: f.write("%s\n" % pkg) def run(self, templatefile, **variables): for k, v in list(self.defaults.items()) + list(self.builtins.items()): variables.setdefault(k, v) logger.debug("executing %s with variables=%s", templatefile, variables) self.templatefile = templatefile t = LoraxTemplate(directories=[self.templatedir]) commands = t.parse(templatefile, variables) self._run(commands) def _run(self, parsed_template): logger.info("running %s", self.templatefile) for (num, line) in enumerate(parsed_template, 1): logger.debug("template line %i: %s", num, " ".join(line)) skiperror = False (cmd, args) = (line[0], line[1:]) # Following Makefile convention, if the command is prefixed with # a dash ('-'), we'll ignore any errors on that line. if cmd.startswith('-'): cmd = cmd[1:] skiperror = True try: # grab the method named in cmd and pass it the given arguments f = getattr(self, cmd, None) if cmd[0] == '_' or cmd == 'run' or not isinstance( f, collections.Callable): raise ValueError("unknown command %s" % cmd) f(*args) except Exception: # pylint: disable=broad-except if skiperror: logger.debug("ignoring error") continue logger.error("template command error in %s:", self.templatefile) logger.error(" %s", " ".join(line)) # format the exception traceback exclines = traceback.format_exception(*sys.exc_info()) # skip the bit about "ltmpl.py, in _run()" - we know that exclines.pop(1) # log the "ErrorType: this is what happened" line logger.error(" " + exclines[-1].strip()) # and log the entire traceback to the debug log for _line in ''.join(exclines).splitlines(): logger.debug(" " + _line) if self.fatalerrors: raise def install(self, srcglob, dest): ''' install SRC DEST Copy the given file (or files, if a glob is used) from the input tree to the given destination in the output tree. The path to DEST must exist in the output tree. If DEST is a directory, SRC will be copied into that directory. If DEST doesn't exist, SRC will be copied to a file with that name, assuming the rest of the path exists. This is pretty much like how the 'cp' command works. Examples: install usr/share/myconfig/grub.conf /boot install /usr/share/myconfig/grub.conf.in /boot/grub.conf ''' for src in rglob(self._in(srcglob), fatal=True): try: cpfile(src, self._out(dest)) except shutil.Error as e: logger.error(e) def installimg(self, *args): ''' installimg [--xz|--gzip|--bzip2|--lzma] [-ARG|--ARG=OPTION] SRCDIR DESTFILE Create a compressed cpio archive of the contents of SRCDIR and place it in DESTFILE. If SRCDIR doesn't exist or is empty nothing is created. Examples: installimg ${LORAXDIR}/product/ images/product.img installimg ${LORAXDIR}/updates/ images/updates.img installimg --xz -6 ${LORAXDIR}/updates/ images/updates.img installimg --xz -9 --memlimit-compress=3700MiB ${LORAXDIR}/updates/ images/updates.img Optionally use a different compression type and override the default args passed to it. The default is xz -9 ''' COMPRESSORS = ("--xz", "--gzip", "--bzip2", "--lzma") if len(args) < 2: raise ValueError("Not enough args for installimg.") srcdir = args[-2] destfile = args[-1] if not os.path.isdir(self._in(srcdir)) or not os.listdir( self._in(srcdir)): return compression = "xz" compressargs = [] if args[0] in COMPRESSORS: compression = args[0][2:] for arg in args[1:-2]: if arg.startswith('-'): compressargs.append(arg) else: raise ValueError("Argument is missing -") logger.info("Creating image file %s from contents of %s", self._out(destfile), self._in(srcdir)) logger.debug("Using %s %s compression", compression, compressargs or "") mkcpio(self._in(srcdir), self._out(destfile), compression=compression, compressargs=compressargs) def mkdir(self, *dirs): ''' mkdir DIR [DIR ...] Create the named DIR(s). Will create leading directories as needed. Example: mkdir /images ''' for d in dirs: d = self._out(d) if not isdir(d): os.makedirs(d) def replace(self, pat, repl, *fileglobs): ''' replace PATTERN REPLACEMENT FILEGLOB [FILEGLOB ...] Find-and-replace the given PATTERN (Python-style regex) with the given REPLACEMENT string for each of the files listed. Example: replace @VERSION@ ${product.version} /boot/grub.conf /boot/isolinux.cfg ''' match = False for g in fileglobs: for f in rglob(self._out(g)): match = True replace(f, pat, repl) if not match: raise IOError("no files matched %s" % " ".join(fileglobs)) def append(self, filename, data): ''' append FILE STRING Append STRING (followed by a newline character) to FILE. Python character escape sequences ('\\n', '\\t', etc.) will be converted to the appropriate characters. Examples: append /etc/depmod.d/dd.conf "search updates built-in" append /etc/resolv.conf "" ''' with open(self._out(filename), "a") as fobj: fobj.write(bytes(data, "utf8").decode('unicode_escape') + "\n") def treeinfo(self, section, key, *valuetoks): ''' treeinfo SECTION KEY ARG [ARG ...] Add an item to the treeinfo data store. The given SECTION will have a new item added where KEY = ARG ARG ... Example: treeinfo images-${kernel.arch} boot.iso images/boot.iso ''' if section not in self.results.treeinfo: self.results.treeinfo[section] = dict() self.results.treeinfo[section][key] = " ".join(valuetoks) def installkernel(self, section, src, dest): ''' installkernel SECTION SRC DEST Install the kernel from SRC in the input tree to DEST in the output tree, and then add an item to the treeinfo data store, in the named SECTION, where "kernel" = DEST. Equivalent to: install SRC DEST treeinfo SECTION kernel DEST ''' self.install(src, dest) self.treeinfo(section, "kernel", dest) def installinitrd(self, section, src, dest): ''' installinitrd SECTION SRC DEST Same as installkernel, but for "initrd". ''' self.install(src, dest) self.chmod(dest, '644') self.treeinfo(section, "initrd", dest) def installupgradeinitrd(self, section, src, dest): ''' installupgradeinitrd SECTION SRC DEST Same as installkernel, but for "upgrade". ''' self.install(src, dest) self.chmod(dest, '644') self.treeinfo(section, "upgrade", dest) def hardlink(self, src, dest): ''' hardlink SRC DEST Create a hardlink at DEST which is linked to SRC. ''' if isdir(self._out(dest)): dest = joinpaths(dest, basename(src)) os.link(self._out(src), self._out(dest)) def symlink(self, target, dest): ''' symlink SRC DEST Create a symlink at DEST which points to SRC. ''' if rexists(self._out(dest)): self.remove(dest) os.symlink(target, self._out(dest)) def copy(self, src, dest): ''' copy SRC DEST Copy SRC to DEST. If DEST is a directory, SRC will be copied inside it. If DEST doesn't exist, SRC will be copied to a file with that name, if the path leading to it exists. ''' try: cpfile(self._out(src), self._out(dest)) except shutil.Error as e: logger.error(e) def move(self, src, dest): ''' move SRC DEST Move SRC to DEST. ''' mvfile(self._out(src), self._out(dest)) def remove(self, *fileglobs): ''' remove FILEGLOB [FILEGLOB ...] Remove all the named files or directories. Will *not* raise exceptions if the file(s) are not found. ''' for g in fileglobs: for f in rglob(self._out(g)): remove(f) logger.debug("removed %s", f) def chmod(self, fileglob, mode): ''' chmod FILEGLOB OCTALMODE Change the mode of all the files matching FILEGLOB to OCTALMODE. ''' for f in rglob(self._out(fileglob), fatal=True): os.chmod(f, int(mode, 8)) # TODO: do we need a new command for gsettings? def gconfset(self, path, keytype, value, outfile=None): ''' gconfset PATH KEYTYPE VALUE [OUTFILE] Set the given gconf PATH, with type KEYTYPE, to the given value. OUTFILE defaults to /etc/gconf/gconf.xml.defaults if not given. Example: gconfset /apps/metacity/general/num_workspaces int 1 ''' if outfile is None: outfile = self._out("etc/gconf/gconf.xml.defaults") cmd = [ "gconftool-2", "--direct", "--config-source=xml:readwrite:%s" % outfile, "--set", "--type", keytype, path, value ] runcmd(cmd) def log(self, msg): ''' log MESSAGE Emit the given log message. Be sure to put it in quotes! Example: log "Reticulating splines, please wait..." ''' logger.info(msg) # TODO: add ssh-keygen, mkisofs(?), find, and other useful commands def runcmd(self, *cmdlist): ''' runcmd CMD [ARG ...] Run the given command with the given arguments. NOTE: All paths given MUST be COMPLETE, ABSOLUTE PATHS to the file or files mentioned. ${root}/${inroot}/${outroot} are good for constructing these paths. FURTHER NOTE: Please use this command only as a last resort! Whenever possible, you should use the existing template commands. If the existing commands don't do what you need, fix them! Examples: (this should be replaced with a "find" function) runcmd find ${root} -name "*.pyo" -type f -delete %for f in find(root, name="*.pyo"): remove ${f} %endfor ''' cmd = cmdlist logger.debug('running command: %s', cmd) if cmd[0].startswith("--chdir="): logger.error("--chdir is no longer supported for runcmd.") raise ValueError("--chdir is no longer supported for runcmd.") try: stdout = runcmd_output(cmd) if stdout: logger.debug('command output:\n%s', stdout) logger.debug("command finished successfully") except CalledProcessError as e: if e.output: logger.error('command output:\n%s', e.output) logger.error('command returned failure (%d)', e.returncode) raise def installpkg(self, *pkgs): ''' installpkg [--required|--optional] [--except PKGGLOB [--except PKGGLOB ...]] PKGGLOB [PKGGLOB ...] Request installation of all packages matching the given globs. Note that this is just a *request* - nothing is *actually* installed until the 'run_pkg_transaction' command is given. --required is now the default. If the PKGGLOB can be missing pass --optional ''' if pkgs[0] == '--optional': pkgs = pkgs[1:] required = False elif pkgs[0] == '--required': pkgs = pkgs[1:] required = True else: required = True excludes = [] while '--except' in pkgs: idx = pkgs.index('--except') if len(pkgs) == idx + 1: raise ValueError("installpkg needs an argument after --except") excludes.append(pkgs[idx + 1]) pkgs = pkgs[:idx] + pkgs[idx + 2:] errors = False for p in pkgs: try: # Start by using Subject to generate a package query, which will # give us a query object similar to what dbo.install would select, # minus the handling for multilib. This query may contain # multiple arches. Pull the package names out of that, filter any # that match the excludes patterns, and pass those names back to # dbo.install to do the actual, arch and version and multilib # aware, package selction. # dnf queries don't have a concept of negative globs which is why # the filtering is done the hard way. pkgnames = { pkg.name for pkg in dnf.subject.Subject(p).get_best_query( self.dbo.sack) } if not pkgnames: raise dnf.exceptions.PackageNotFoundError( "no package matched", p) for exclude in excludes: pkgnames = { pkgname for pkgname in pkgnames if not fnmatch.fnmatch(pkgname, exclude) } # Sort the results so that we have consistent results pkgnames = sorted(pkgnames) # If the request is a glob, expand it in the log if any(g for g in ['*', '?', '.'] if g in p): logger.info("installpkg: %s expands to %s", p, ",".join(pkgnames)) for pkgname in pkgnames: try: self.dbo.install(pkgname) except Exception as e: # pylint: disable=broad-except if required: raise # Not required, log it and continue processing pkgs logger.error("installpkg %s failed: %s", pkgname, str(e)) except Exception as e: # pylint: disable=broad-except logger.error("installpkg %s failed: %s", p, str(e)) errors = True if errors and required: raise Exception("Required installpkg failed.") def removepkg(self, *pkgs): ''' removepkg PKGGLOB [PKGGLOB...] Delete the named package(s). IMPLEMENTATION NOTES: RPM scriptlets (%preun/%postun) are *not* run. Files are deleted, but directories are left behind. ''' for p in pkgs: filepaths = [f.lstrip('/') for f in self._filelist(p)] # TODO: also remove directories that aren't owned by anything else if filepaths: logger.debug("removepkg %s: %ikb", p, self._getsize(*filepaths) / 1024) self.remove(*filepaths) else: logger.debug("removepkg %s: no files to remove!", p) def run_pkg_transaction(self): ''' run_pkg_transaction Actually install all the packages requested by previous 'installpkg' commands. ''' try: logger.info("Checking dependencies") self.dbo.resolve() except dnf.exceptions.DepsolveError as e: logger.error("Dependency check failed: %s", e) raise logger.info("%d packages selected", len(self.dbo.transaction)) if len(self.dbo.transaction) == 0: raise Exception("No packages in transaction") # If a debug repo has been included, write out a list of debuginfo packages self._write_debuginfo_log() pkgs_to_download = self.dbo.transaction.install_set logger.info("Downloading packages") progress = LoraxDownloadCallback() try: self.dbo.download_packages(pkgs_to_download, progress) except dnf.exceptions.DownloadError as e: logger.error("Failed to download the following packages: %s", e) raise logger.info("Preparing transaction from installation source") try: display = LoraxRpmCallback() self.dbo.do_transaction(display=display) except BaseException as e: logger.error("The transaction process has ended abruptly: %s", e) raise # Reset the package sack to pick up the installed packages self.dbo.reset(repos=False) self.dbo.fill_sack(load_system_repo=True, load_available_repos=False) # At this point dnf should know about the installed files. Double check that it really does. if len(self._filelist("anaconda-core")) == 0: raise Exception("Failed to reset dbo to installed package set") def removefrom(self, pkg, *globs): ''' removefrom PKGGLOB [--allbut] FILEGLOB [FILEGLOB...] Remove all files matching the given file globs from the package (or packages) named. If '--allbut' is used, all the files from the given package(s) will be removed *except* the ones which match the file globs. Examples: removefrom usbutils /usr/bin/* removefrom xfsprogs --allbut /sbin/* ''' cmd = "%s %s" % (pkg, " ".join(globs)) # save for later logging keepmatches = False if globs[0] == '--allbut': keepmatches = True globs = globs[1:] # get pkg filelist and find files that match the globs filelist = self._filelist(pkg) matches = set() for g in globs: globs_re = re.compile(fnmatch.translate(g)) m = [f for f in filelist if globs_re.match(f)] if m: matches.update(m) else: logger.debug("removefrom %s %s: no files matched!", pkg, g) # are we removing the matches, or keeping only the matches? if keepmatches: remove_files = filelist.difference(matches) else: remove_files = matches # remove the files if remove_files: logger.debug("removefrom %s: removed %i/%i files, %ikb/%ikb", cmd, len(remove_files), len(filelist), self._getsize(*remove_files) / 1024, self._getsize(*filelist) / 1024) self.remove(*remove_files) else: logger.debug("removefrom %s: no files to remove!", cmd) def removekmod(self, *globs): ''' removekmod GLOB [GLOB...] [--allbut] KEEPGLOB [KEEPGLOB...] Remove all files and directories matching the given file globs from the kernel modules directory. If '--allbut' is used, all the files from the modules will be removed *except* the ones which match the file globs. There must be at least one initial GLOB to search and one KEEPGLOB to keep. The KEEPGLOB is expanded to be *KEEPGLOB* so that it will match anywhere in the path. This only removes files from under /lib/modules/*/kernel/ Examples: removekmod sound drivers/media drivers/hwmon drivers/video removekmod drivers/char --allbut virtio_console hw_random ''' cmd = " ".join(globs) if "--allbut" in globs: idx = globs.index("--allbut") if idx == 0: raise ValueError( "removekmod needs at least one GLOB before --allbut") # Apply keepglobs anywhere they appear in the path keepglobs = globs[idx + 1:] if len(keepglobs) == 0: raise ValueError( "removekmod needs at least one GLOB after --allbut") globs = globs[:idx] else: # Nothing to keep keepglobs = [] filelist = set() for g in globs: for top_dir in rglob(self._out("/lib/modules/*/kernel/" + g)): for root, _dirs, files in os.walk(top_dir): filelist.update(root + "/" + f for f in files) # Remove anything matching keepglobs from the list matches = set() for g in keepglobs: globs_re = re.compile(fnmatch.translate("*" + g + "*")) m = [f for f in filelist if globs_re.match(f)] if m: matches.update(m) else: logger.debug("removekmod %s: no files matched!", g) remove_files = filelist.difference(matches) if remove_files: logger.debug("removekmod: removing %d files", len(remove_files)) list(remove(f) for f in remove_files) else: logger.debug("removekmod %s: no files to remove!", cmd) def createaddrsize(self, addr, src, dest): ''' createaddrsize INITRD_ADDRESS INITRD ADDRSIZE Create the initrd.addrsize file required in LPAR boot process. Examples: createaddrsize ${INITRD_ADDRESS} ${outroot}/${BOOTDIR}/initrd.img ${outroot}/${BOOTDIR}/initrd.addrsize ''' addrsize = open(dest, "wb") addrsize_data = struct.pack(">iiii", 0, int(addr, 16), 0, os.stat(src).st_size) addrsize.write(addrsize_data) addrsize.close() def systemctl(self, cmd, *units): ''' systemctl [enable|disable|mask] UNIT [UNIT...] Enable, disable, or mask the given systemd units. Examples: systemctl disable lvm2-monitor.service systemctl mask fedora-storage-init.service fedora-configure.service ''' if cmd not in ('enable', 'disable', 'mask'): raise ValueError('unsupported systemctl cmd: %s' % cmd) if not units: logger.debug("systemctl: no units given for %s, ignoring", cmd) return self.mkdir("/run/systemd/system") # XXX workaround for systemctl bug systemctl = ['systemctl', '--root', self.outroot, '--no-reload', cmd] # When a unit doesn't exist systemd aborts the command. Run them one at a time. # XXX for some reason 'systemctl enable/disable' always returns 1 for unit in units: try: cmd = systemctl + [unit] runcmd(cmd) except CalledProcessError: pass
def fakednf_test(self): """Test FakeDNF class""" fake_dbo = FakeDNF(conf=DataHolder( installroot="/a/fake/install/root/")) self.assertEqual(fake_dbo.conf.installroot, "/a/fake/install/root/")