diff --git a/README.md b/README.md index e6a0e00..cf42755 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,20 @@ The recommended and official way to run `image-build` is using the `ghcr.io/open To build an image using the container, the config file needs to be mapped into the container, as well as the FUSE filesystem device: ``` -podman run \ - --rm \ +podman run --rm \ --device /dev/fuse \ --userns keep-id:uid=1002,gid=1002 \ -v /path/to/config.yaml:/home/builder/config.yaml \ + --network host \ + --cap-add=SYS_ADMIN \ + --cap-add=SETUID \ + --cap-add=SETGID \ + --security-opt seccomp=unconfined \ + --security-opt label=disable \ + --userns=keep-id \ + -v /opt/workdir/images/test-rocky-9.5.yaml:/home/builder/config.yaml \ ghcr.io/openchami/image-build:latest \ - image-build --config config.yaml + image-build --config config.yaml --log-level DEBUG ``` If you are building EL9 images, use the `ghcr.io/openchami/image-build-el9:latest` image. diff --git a/dockerfiles/dnf/Dockerfile b/dockerfiles/dnf/Dockerfile index 361aa6d..fddf134 100644 --- a/dockerfiles/dnf/Dockerfile +++ b/dockerfiles/dnf/Dockerfile @@ -21,23 +21,20 @@ RUN pip3.11 install -r /requirements.txt COPY src/ /usr/local/bin/ RUN chmod -R 0755 /usr/local/bin/ +COPY entrypoint.sh /entrypoint.sh +RUN chmod 0755 /entrypoint.sh + # Allow non-root to run buildah commands RUN setcap cap_setuid=ep "$(command -v newuidmap)" && \ setcap cap_setgid=ep "$(command -v newgidmap)" &&\ chmod 0755 "$(command -v newuidmap)" && \ chmod 0755 "$(command -v newgidmap)" && \ - rpm --restore shadow-utils && \ - echo "builder:2000:50000" > /etc/subuid && \ - echo "builder:2000:50000" > /etc/subgid + rpm --restore shadow-utils # Create local user for rootless image builds -RUN useradd --uid 1002 builder && \ +RUN useradd --uid 1000 builder && \ chown -R builder /home/builder -# Make builder the default user when running container -USER builder -WORKDIR /home/builder - ENV BUILDAH_ISOLATION=chroot -ENTRYPOINT ["/usr/bin/buildah", "unshare"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/dockerfiles/dnf/Dockerfile.minimal b/dockerfiles/dnf/Dockerfile.minimal index 955cad8..d686705 100644 --- a/dockerfiles/dnf/Dockerfile.minimal +++ b/dockerfiles/dnf/Dockerfile.minimal @@ -15,10 +15,10 @@ RUN microdnf install -y \ microdnf clean all # Create local user for rootless image builds -RUN echo "builder:x:1002:1002::/home/builder:/bin/bash" >> /etc/passwd && \ - echo "builder:x:1002:" >> /etc/group && \ +RUN echo "builder:x:1000:1000::/home/builder:/bin/bash" >> /etc/passwd && \ + echo "builder:x:1000:" >> /etc/group && \ mkdir -p /home/builder && \ - chown -R 1002:1002 /home/builder + chown -R 1000:1000 /home/builder # Add our custom scripts COPY src/ /usr/local/bin/ @@ -28,20 +28,16 @@ RUN chmod -R 0755 /usr/local/bin/ RUN setcap cap_setuid=ep "$(command -v newuidmap)" && \ setcap cap_setgid=ep "$(command -v newgidmap)" && \ chmod 0755 "$(command -v newuidmap)" && \ - chmod 0755 "$(command -v newgidmap)" && \ - echo "builder:2000:50000" > /etc/subuid && \ - echo "builder:2000:50000" > /etc/subgid + chmod 0755 "$(command -v newgidmap)" # Set up environment variables ENV BUILDAH_ISOLATION=chroot # Switch to non-root user -USER builder # Verify Python functionality RUN python3.11 -m pip install --no-cache-dir --upgrade pip && \ python3.11 -m pip install --no-cache-dir PyYAML ansible==11.1.0 ansible-base ansible-bender boto3 dnspython requests jinja2_ansible_filters -WORKDIR /home/builder # Default entrypoint -ENTRYPOINT ["/usr/bin/buildah", "unshare"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..827e062 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +if [[ -w /etc/subuid && -w /etc/subgid ]]; then + echo "builder:1001:${USERNS_RANGE:-65536}" > /etc/subuid + echo "builder:1001:${USERNS_RANGE:-65536}" > /etc/subgid +fi + +# Make sure builder owns its homedir (optional if baked into image) +chown -R builder /home/builder || true + +# Run buildah directly +exec su builder -c "buildah unshare $*" diff --git a/src/arguments.py b/src/arguments.py index a96ef91..82e23ae 100644 --- a/src/arguments.py +++ b/src/arguments.py @@ -22,6 +22,7 @@ def process_args(terminal_args, config_options): processed_args['pkg_man'] = terminal_args.pkg_man or config_options.get('pkg_manager') if not processed_args['pkg_man']: raise ValueError("'pkg_man' required when 'layer_type' is base") + processed_args['gpgcheck'] = terminal_args.gpgcheck or config_options.get('gpgcheck', True) elif processed_args['layer_type'] == "ansible": processed_args['ansible_groups'] = terminal_args.group_list or config_options.get('groups', []) processed_args['ansible_pb'] = terminal_args.pb or config_options.get('playbooks', []) diff --git a/src/image-build b/src/image-build index 2351aea..da50852 100755 --- a/src/image-build +++ b/src/image-build @@ -35,6 +35,7 @@ def main(): parser.add_argument('--config', type=str, required=True, help='Configuration file is required') parser.add_argument('--repo', type=str, required=False) parser.add_argument('--pkg-manager', dest="pkg_man", type=str, required=False) + parser.add_argument('--gpgcheck', dest="gpgcheck", type=bool, required=False) parser.add_argument('--groups', dest='group_list', action='store', nargs='+', type=str, default=[], help='List of groups') parser.add_argument('--vars', dest='vars', action='store', nargs='+', type=str, default=[], help='List of variables') parser.add_argument('--pb', type=str) @@ -56,7 +57,7 @@ def main(): level = getattr(logging, args['log_level'].upper(), 10) logging.basicConfig(format='%(levelname)s - %(message)s',level=level) - print_args(args) + print_args(args) except argparse.ArgumentError as e: print(f"Argument error: {e}") diff --git a/src/installer.py b/src/installer.py index 524389e..e287945 100644 --- a/src/installer.py +++ b/src/installer.py @@ -7,10 +7,11 @@ from utils import cmd class Installer: - def __init__(self, pkg_man, cname, mname): + def __init__(self, pkg_man, cname, mname, gpgcheck=True): self.pkg_man = pkg_man self.cname = cname self.mname = mname + self.gpgcheck = gpgcheck # Create temporary directory for logs, cache, etc. for package manager os.makedirs(os.path.join(mname, "tmp"), exist_ok=True) @@ -21,7 +22,7 @@ def __init__(self, pkg_man, cname, mname): # DNF complains if the log directory is not present os.makedirs(os.path.join(self.tdir, "dnf/log")) - def install_repos(self, repos, repo_dest, proxy): + def install_scratch_repos(self, repos, repo_dest, proxy): # check if there are repos passed for install if len(repos) == 0: logging.info("REPOS: no repos passed to install\n") @@ -92,7 +93,7 @@ def install_repos(self, repos, repo_dest, proxy): if rc != 0: raise Exception("Failed to install gpg key for", r['alias'], "at URL", r['gpg']) - def install_base_packages(self, packages, registry_loc, proxy): + def install_scratch_packages(self, packages, registry_loc, proxy): # check if there are packages to install if len(packages) == 0: logging.warn("PACKAGES: no packages passed to install\n") @@ -134,19 +135,7 @@ def install_base_packages(self, packages, registry_loc, proxy): if rc == 107: logging.warn("one or more RPM postscripts failed to run") - def remove_base_packages(self, remove_packages): - # check if there are packages to remove - if len(remove_packages) == 0: - logging.warn("REMOVE PACKAGES: no package passed to remove\n") - return - - logging.info(f"REMOVE PACKAGES: removing these packages from container {self.cname}") - logging.info("\n".join(remove_packages)) - for p in remove_packages: - args = [self.cname, '--', 'rpm', '-e', '--nodeps', p] - cmd(["buildah","run"] + args) - - def install_base_package_groups(self, package_groups, registry_loc, proxy): + def install_scratch_package_groups(self, package_groups, registry_loc, proxy): # check if there are packages groups to install if len(package_groups) == 0: logging.warn("PACKAGE GROUPS: no package groups passed to install\n") @@ -175,7 +164,7 @@ def install_base_package_groups(self, package_groups, registry_loc, proxy): if rc == 104: raise Exception("Installing base packages failed") - def install_base_modules(self, modules, registry_loc, proxy): + def install_scratch_modules(self, modules, registry_loc, proxy): # check if there are modules groups to install if len(modules) == 0: logging.warn("PACKAGE MODULES: no modules passed to install\n") @@ -205,8 +194,97 @@ def install_base_modules(self, modules, registry_loc, proxy): if rc != 0: raise Exception("Failed to run module cmd", mod_cmd, ' '.join(mod_list)) + def install_repos(self, repos, proxy): + # check if there are repos passed for install + if len(repos) == 0: + logging.info("REPOS: no repos passed to install\n") + return + + logging.info(f"REPOS: Installing these repos to {self.cname}") + for r in repos: + logging.info(r['alias'] + ': ' + r['url']) + if self.pkg_man == "zypper": + if 'priority' in r: + priority = r['priority'] + else: + priority = 99 + rargs = ' addrepo -f -p ' + priority + ' ' + r['url'] + ' ' + r['alias'] + elif self.pkg_man == "dnf": + rargs = ' config-manager --save --add-repo ' + r['url'] + + args = [self.cname, '--', 'bash', '-c', self.pkg_man + rargs] + rc = cmd(["buildah","run"] + args) + if rc != 0: + raise Exception("Failed to install repo", r['alias'], r['url']) + # Set Proxy if using DNF + if proxy != "": + if r['url'].endswith('.repo'): + repo_name = r['url'].split('/')[-1].split('.repo')[0] + "*" + elif r['url'].startswith('https'): + repo_name = r['url'].split('https://')[1].replace('/','_') + elif r['url'].startswith('http'): + repo_name = r['url'].split('http://')[1].replace('/','_') + pargs = ' config-manager --save --setopt=*.proxy= ' + proxy + ' ' + repo_name + + args = [self.cname, '--', 'bash', '-c', self.pkg_man + pargs] + rc = cmd(["buildah","run"] + args) + if rc != 0: + raise Exception("Failed to set proxy for repo", r['alias'], r['url'], proxy) + + if "gpg" in r: + # Using rpm apparently works for both Yum- and Zypper-based distros. + gargs = [self.cname, '--', 'bash', '-c', 'rpm --import ' + r['gpg']] + if proxy != "": + arg_env = os.environ.copy() + arg_env['https_proxy'] = proxy + rc = cmd(["buildah","run"] + gargs) + if rc != 0: + raise Exception("Failed to install gpg key for", r['alias'], "at URL", r['gpg']) + + def install_packages(self, packages): + if len(packages) == 0: + logging.warn("PACKAGE GROUPS: no package groups passed to install\n") + return + logging.info(f"PACKAGES: Installing these packages to {self.cname}") + logging.info("\n".join(packages)) + args = [self.cname, '--', 'bash', '-c'] + pkg_cmd = [self.pkg_man] + if self.gpgcheck is not True: + if self.pkg_man == 'dnf': + pkg_cmd.append('--nogpgcheck') + elif self.pkg_man == 'zypper': + pkg_cmd.append('--no-gpg-checks') + args.append(" ".join(pkg_cmd + [ 'install', '-y'] + packages)) + cmd(["buildah","run"] + args) + + def install_package_groups(self, package_groups): + if len(package_groups) == 0: + logging.warn("PACKAGE GROUPS: no package groups passed to install\n") + return + logging.info(f"PACKAGES: Installing these package groups to {self.cname}") + logging.info("\n".join(package_groups)) + args = [self.cname, '--', 'bash', '-c'] + pkg_cmd = [self.pkg_man, 'groupinstall', '-y'] + if self.pkg_man == "zypper": + logging.warn("zypper does not support package groups") + if self.gpgcheck is not True: + pkg_cmd.append('--nogpgcheck') + args.append(" ".join(pkg_cmd + [f'"{pg}"' for pg in package_groups])) + cmd(["buildah","run"] + args) + + def remove_packages(self, remove_packages): + # check if there are packages to remove + if len(remove_packages) == 0: + logging.warn("REMOVE PACKAGES: no package passed to remove\n") + return + + logging.info(f"REMOVE PACKAGES: removing these packages from container {self.cname}") + logging.info("\n".join(remove_packages)) + for p in remove_packages: + args = [self.cname, '--', 'rpm', '-e', '--nodeps', p] + cmd(["buildah","run"] + args) - def install_base_commands(self, commands): + def install_commands(self, commands): # check if there are commands to install if len(commands) == 0: logging.warn("COMMANDS: no commands passed to run\n") @@ -228,9 +306,9 @@ def install_base_commands(self, commands): loglevel = logging.error else: loglevel = logging.error - out = cmd(build_cmd + args, stderr_handler=loglevel) + cmd(["buildah","run"] + args, stderr_handler=loglevel) - def install_base_copyfiles(self, copyfiles): + def install_copyfiles(self, copyfiles): if len(copyfiles) == 0: logging.warn("COPYFILES: no files to copy\n") return @@ -242,4 +320,4 @@ def install_base_copyfiles(self, copyfiles): args.extend(o.split()) logging.info(f['src'] + ' -> ' + f['dest']) args += [ self.cname, f['src'], f['dest'] ] - out=cmd(["buildah","copy"] + args) + cmd(["buildah","copy"] + args) diff --git a/src/layer.py b/src/layer.py index 30b2eb6..7e772a5 100644 --- a/src/layer.py +++ b/src/layer.py @@ -16,37 +16,50 @@ def __init__(self, args, image_config): self.image_config = image_config self.logger = logging.getLogger(__name__) - def buildah_handler(line): - out.append(line) - return out - def _build_base(self, repos, modules, packages, package_groups, remove_packages, commands, copyfiles, oscap_options): + # Set local variables dt_string = datetime.now().strftime("%Y%m%d%H%M%S") + parent = self.args['parent'] + container = self.args['name'] + registry_opts_pull = self.args['registry_opts_pull'] + package_manager = self.args['pkg_man'] + if 'gpgcheck' in self.args: + gpgcheck = self.args['gpgcheck'] + else: + gpgcheck = True + if 'proxy' in self.args: + proxy = self.args['proxy'] + else: + proxy = "" # container and mount name def buildah_handler(line): out.append(line) + # Create a new container from parent out = [] - cmd(["buildah", "from"] + self.args['registry_opts_pull'] + ["--name", self.args['name']+ dt_string, self.args['parent']], stdout_handler = buildah_handler) + cmd(["buildah", "from"] + registry_opts_pull + ["--name", container + dt_string, parent], stdout_handler = buildah_handler) cname = out[0] - out = [] - cmd(["buildah", "mount"] + [cname], stdout_handler = buildah_handler) - mname = out[0] - - self.logger.info(f"Container: {cname} mounted at {mname}") + # Only mount when doing a scratch install + if parent == "scratch": + out = [] + cmd(["buildah", "mount"] + [cname], stdout_handler = buildah_handler) + mname = out[0] + self.logger.info(f"Container: {cname} mounted at {mname}") + else: + mname = "" - if self.args['pkg_man'] == "zypper": + if package_manager == "zypper": repo_dest = "/etc/zypp/repos.d" - elif self.args['pkg_man'] == "dnf": + elif package_manager == "dnf": repo_dest = "/etc/yum.repos.d" else: self.logger.error("unsupported package manager") inst = None try: - inst = installer.Installer(self.args['pkg_man'], cname, mname) + inst = installer.Installer(package_manager, cname, mname, gpgcheck) except Exception as e: self.logger.error(f"Error preparing installer: {e}") cmd(["buildah","rm"] + [cname]) @@ -57,8 +70,11 @@ def buildah_handler(line): sys.exit("Exiting now ...") # Install Repos - try: - inst.install_repos(repos, repo_dest, self.args['proxy']) + try: + if parent == "scratch": + inst.install_scratch_repos(repos, repo_dest, proxy) + else: + inst.install_repos(repos, proxy) except Exception as e: self.logger.error(f"Error installing repos: {e}") cmd(["buildah","rm"] + [cname]) @@ -70,14 +86,18 @@ def buildah_handler(line): # Install Packages try: - # Enable modules - inst.install_base_modules(modules, repo_dest, self.args['proxy']) - # Base Package Groups - inst.install_base_package_groups(package_groups, repo_dest, self.args['proxy']) - # Packages - inst.install_base_packages(packages, repo_dest, self.args['proxy']) + if parent == "scratch": + # Enable modules + inst.install_scratch_modules(modules, repo_dest, self.args['proxy']) + # Base Package Groups + inst.install_scratch_package_groups(package_groups, repo_dest, proxy) + # Packages + inst.install_scratch_packages(packages, repo_dest, proxy) + else: + inst.install_package_groups(package_groups) + inst.install_packages(packages) # Remove Packages - inst.remove_base_packages(remove_packages) + inst.remove_packages(remove_packages) except Exception as e: self.logger.error(f"Error installing packages: {e}") cmd(["buildah","rm"] + [cname]) @@ -89,7 +109,7 @@ def buildah_handler(line): # Copy Files try: - inst.install_base_copyfiles(copyfiles) + inst.install_copyfiles(copyfiles) except Exception as e: self.logger.error(f"Error running commands: {e}") cmd(["buildah","rm"] + [cname]) @@ -101,7 +121,7 @@ def buildah_handler(line): # Run Commands try: - inst.install_base_commands(commands) + inst.install_commands(commands) if os.path.islink(mname + '/etc/resolv.conf'): self.logger.info("removing resolv.conf link (this link breaks running a container)") os.unlink(mname + '/etc/resolv.conf') @@ -184,4 +204,3 @@ def build_layer(self): # Publish the layer self.logger.info("Publishing Layer") publish(cname, self.args) - diff --git a/src/publish.py b/src/publish.py index 3c862e1..ffff8ee 100644 --- a/src/publish.py +++ b/src/publish.py @@ -121,7 +121,6 @@ def squash_image(mname, tmpdir): # print(process.stdout) def s3_push(cname, layer_name, credentials, publish_tags, s3_prefix, s3_bucket): - def buildah_handler(line): out.append(line) out = [] @@ -177,7 +176,6 @@ def buildah_handler(line): push_file(tmpdir + '/rootfs', image_name, s3, s3_bucket) def registry_push(layer_name, registry_opts, publish_tags, registry_endpoint): - image_name = layer_name+':'+publish_tags print("pushing layer " + layer_name + " to " + registry_endpoint +'/'+image_name) args = registry_opts + [image_name, registry_endpoint +'/'+image_name] diff --git a/src/utils.py b/src/utils.py index e9f290b..4d063f4 100644 --- a/src/utils.py +++ b/src/utils.py @@ -6,7 +6,7 @@ from ansible.executor.playbook_executor import PlaybookExecutor from ansible.playbook import Playbook from ansible.vars.manager import VariableManager -from ansible.config.manager import ConfigManager, Setting +from ansible.config.manager import ConfigManager from ansible.cli.config import ConfigCLI from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode diff --git a/tests/dnf/rocky9_scratch.yaml b/tests/dnf/rocky9_scratch.yaml new file mode 100644 index 0000000..b7a3961 --- /dev/null +++ b/tests/dnf/rocky9_scratch.yaml @@ -0,0 +1,25 @@ +# build a test rocky9 based image from scratch + +options: + layer_type: 'base' + name: 'rocky-base' + publish_tags: '9.5' + pkg_manager: 'dnf' + parent: 'scratch' + publish_local: true + +repos: + - alias: 'Rocky_9_BaseOS' + url: 'https://dl.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os/' + gpg: 'https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9' + - alias: 'Rocky_9_AppStream' + url: 'https://dl.rockylinux.org/pub/rocky/9/AppStream/x86_64/os/' + gpg: 'https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9' + +package_groups: + - 'Minimal Install' + +packages: + - kernel + - wget + - dracut-live diff --git a/tests/zypper/suse_bci_base.yaml b/tests/zypper/suse_bci_base.yaml new file mode 100644 index 0000000..da4044a --- /dev/null +++ b/tests/zypper/suse_bci_base.yaml @@ -0,0 +1,15 @@ +# Build a a layer importing from bci-base +options: + layer_type: 'base' + name: 'suse-base' + publish_tags: + - '15.7' + pkg_manager: 'zypper' + parent: 'registry.suse.com/bci/bci-base:15.7' + publish_local: true + +packages: + - cloud-init + - python3 + - vim + - chrony