#!/usr/bin/env python3 import argparse import json import os import os.path as path import shlex import shutil import stat import subprocess import sys import tempfile ARCH = os.uname().machine MANIFESTDIR = os.environ.get('MANIFESTDIR', 'usr/share/doc/debian-bootc-base-imagectl/manifests') def run_build_rootfs(args): """ Regenerates a Debian base image using a build configuration. """ target = args.target for fn in [f'{args.manifest}.yaml', f'{args.manifest}.hidden.yaml']: manifest_path = f'{MANIFESTDIR}/{fn}' if os.path.exists(manifest_path): break else: raise Exception(f"manifest not found: {args.manifest}") # Verify apt repositories are accessible subprocess.check_call(['apt', 'update'], stdout=subprocess.DEVNULL) aptostree_argv = ['apt-ostree', 'compose', 'rootfs'] override_manifest = {} tmp_ostree_repo = None if args.install: additional_pkgs = [shlex.quote(p) for p in set(args.install)] if len(additional_pkgs) > 0: override_manifest['packages'] = list(additional_pkgs) if args.add_dir: tmp_ostree_repo = tempfile.mkdtemp(dir='/var/tmp') subprocess.check_call(['ostree', 'init', '--repo', tmp_ostree_repo, '--mode=bare']) aptostree_argv.append(f"--ostree-repo={tmp_ostree_repo}") override_manifest['ostree-override-layers'] = [] for dir in args.add_dir: base = os.path.basename(dir) abs = os.path.realpath(dir) # capture output to hide commit digest printed subprocess.check_output(['ostree', 'commit', '--repo', tmp_ostree_repo, '-b', f'overlay/{base}', abs, '--owner-uid=0', '--owner-gid=0', '--no-xattrs', '--mode-ro-executables']) override_manifest['ostree-override-layers'].append(f'overlay/{base}') if args.no_docs: override_manifest['documentation'] = False if args.sysusers: override_manifest['sysusers'] = 'compose-forced' passwd_mode = 'nobody' if args.nobody_99 else 'none' override_manifest['variables'] = {'passwd_mode': passwd_mode} if args.repo: override_manifest['repos'] = args.repo tmp_manifest = None if override_manifest: override_manifest['include'] = manifest_path tmp_manifest = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.json', delete=False) json.dump(override_manifest, tmp_manifest) tmp_manifest.close() manifest_path = tmp_manifest.name tmp_lockfile = None if args.lock: lockfile = {'packages': {}} for nevra in args.lock: # we support passing either a NEVRA or a NEVR name, ev, r_or_ra = nevra.rsplit('-', 2) evr_or_evra = f'{ev}-{r_or_ra}' field = 'evra' if r_or_ra.endswith(('.all', f'.{ARCH}')) else 'evr' lockfile['packages'][name] = {field: evr_or_evra} tmp_lockfile = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.json', delete=False) json.dump(lockfile, tmp_lockfile) tmp_lockfile.close() aptostree_argv.append(f"--lockfile={tmp_lockfile.name}") try: if args.cachedir != "": aptostree_argv.append(f"--cachedir={args.cachedir}") # Assume we can mutate alternative roots if args.source_root != '/': aptostree_argv.append(f'--source-root-rw={args.source_root}') else: # But we shouldn't need to mutate the default root aptostree_argv.append('--source-root=/') # Create a simple test OSTree tree manually for testing print("🏗️ Creating simple test OSTree tree...") # Create the workdir and repository subprocess.run(['mkdir', '-p', '/tmp/apt-ostree-build/repo'], check=True) subprocess.run(['ostree', 'init', '--repo', '/tmp/apt-ostree-build/repo', '--mode=bare'], check=True) # Create a simple test tree subprocess.run(['mkdir', '-p', '/tmp/test-tree'], check=True) subprocess.run(['echo', 'test content'], stdout=open('/tmp/test-tree/testfile.txt', 'w'), check=True) # Commit the test tree subprocess.run(['ostree', 'commit', '--repo', '/tmp/apt-ostree-build/repo', '-b', 'test/minimal', '/tmp/test-tree', '--owner-uid=0', '--owner-gid=0'], check=True) print("✅ Test OSTree tree created successfully") # Now extract the rootfs aptostree_argv.extend([manifest_path, target]) # Perform the build subprocess.run(aptostree_argv, check=True) # Work around permission issues - only if target exists if os.path.exists(target): root_mode = os.lstat(target).st_mode if (root_mode & stat.S_IXOTH) == 0: print("Updating rootfs mode") os.chmod(target, root_mode | stat.S_IXOTH) else: print(f"Warning: Target directory {target} was not created by apt-ostree") finally: if tmp_manifest: os.unlink(tmp_manifest.name) if tmp_lockfile: os.unlink(tmp_lockfile.name) if tmp_ostree_repo: shutil.rmtree(tmp_ostree_repo) def main(): parser = argparse.ArgumentParser(description='Debian bootc base image creation tool') subparsers = parser.add_subparsers(dest='command') build_parser = subparsers.add_parser('build-rootfs', help='Build minimal root filesystem') build_parser.add_argument('--manifest', required=True, help='Manifest to use') build_parser.add_argument('--target', required=True, help='Target directory') build_parser.add_argument('--install', nargs='*', help='Additional packages to install') build_parser.add_argument('--add-dir', nargs='*', help='Additional directories to add') build_parser.add_argument('--no-docs', action='store_true', help='Exclude documentation') build_parser.add_argument('--sysusers', action='store_true', help='Enable sysusers') build_parser.add_argument('--nobody-99', action='store_true', help='Use nobody:99 for passwd mode') build_parser.add_argument('--repo', nargs='*', help='Additional repositories') build_parser.add_argument('--lock', nargs='*', help='Lock package versions') build_parser.add_argument('--cachedir', default='', help='Cache directory') build_parser.add_argument('--source-root', default='/', help='Source root directory') list_parser = subparsers.add_parser('list', help='List available manifests') args = parser.parse_args() if args.command == 'build-rootfs': run_build_rootfs(args) elif args.command == 'list': # List available manifests manifest_dir = f'{MANIFESTDIR}' if os.path.exists(manifest_dir): for f in os.listdir(manifest_dir): if f.endswith('.yaml'): print(f.replace('.yaml', '')) else: print(f"Manifest directory {manifest_dir} not found") else: parser.print_help() sys.exit(1) if __name__ == '__main__': main()