diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99730a1..e0c6572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Install lint tools run: make lint-install @@ -49,7 +47,7 @@ jobs: - name: Check amd64 kernel cache id: amd64-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | mkosi.output/kernel/${{ env.KERNEL_VERSION }}/amd64 @@ -58,7 +56,7 @@ jobs: - name: Check arm64 kernel cache id: arm64-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | mkosi.output/kernel/${{ env.KERNEL_VERSION }}/arm64 @@ -118,7 +116,7 @@ jobs: docker tag "${REMOTE}:${HASH}-${{ matrix.arch }}" "${{ env.BUILDER_IMAGE }}" echo "built=false" >> "$GITHUB_OUTPUT" else - docker build -t "${{ env.BUILDER_IMAGE }}:${HASH}" -t "${{ env.BUILDER_IMAGE }}" . + docker buildx build --progress=plain -t "${{ env.BUILDER_IMAGE }}:${HASH}" -t "${{ env.BUILDER_IMAGE }}" . echo "built=true" >> "$GITHUB_OUTPUT" fi @@ -136,31 +134,31 @@ jobs: - name: Restore kernel cache id: kernel-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | mkosi.output/kernel/${{ env.KERNEL_VERSION }}/${{ matrix.arch }} key: ${{ steps.kernel-cache-key.outputs.key }} - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Build kernel - run: ./build.py kernel + run: uv run ./build.py kernel - name: Fix output file ownership run: sudo chown -R "$(id -u):$(id -g)" mkosi.output/ - name: Save kernel cache if: github.ref == 'refs/heads/main' && steps.kernel-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | mkosi.output/kernel/${{ env.KERNEL_VERSION }}/${{ matrix.arch }} key: ${{ steps.kernel-cache-key.outputs.key }} - name: Upload kernel artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: kernel-${{ matrix.arch }} path: | @@ -188,22 +186,22 @@ jobs: - name: Restore tools cache id: tools-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | mkosi.output/tools/${{ matrix.arch }}/usr/local/bin mkosi.output/tools/${{ matrix.arch }}/opt/cni key: tools-${{ matrix.arch }}-${{ hashFiles('captain/tools.py') }} - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Download tools - run: ./build.py tools + run: uv run ./build.py tools - name: Save tools cache if: github.ref == 'refs/heads/main' && steps.tools-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | mkosi.output/tools/${{ matrix.arch }}/usr/local/bin @@ -211,7 +209,7 @@ jobs: key: tools-${{ matrix.arch }}-${{ hashFiles('captain/tools.py') }} - name: Upload tools artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: tools-${{ matrix.arch }} path: | @@ -238,13 +236,13 @@ jobs: uses: actions/checkout@v6 - name: Download kernel artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: kernel-${{ matrix.arch }} path: mkosi.output/kernel/${{ env.KERNEL_VERSION }}/${{ matrix.arch }} - name: Download tools artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: tools-${{ matrix.arch }} path: mkosi.output/tools/${{ matrix.arch }} @@ -257,24 +255,24 @@ jobs: chmod +x mkosi.output/tools/${{ matrix.arch }}/opt/cni/bin/* - name: Refresh apt cache - run: sudo apt-get update + run: sudo apt-get -o "Dpkg::Use-Pty=0" update - name: setup-mkosi uses: systemd/mkosi@v26 - name: Install bubblewrap run: | - sudo apt-get update - sudo apt-get install -y bubblewrap + sudo apt-get -o "Dpkg::Use-Pty=0" update + sudo apt-get -o "Dpkg::Use-Pty=0" install -y bubblewrap - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Build initramfs - run: ./build.py initramfs + run: uv run ./build.py initramfs - name: Upload initramfs artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: initramfs-${{ matrix.arch }} path: out/ @@ -341,13 +339,13 @@ jobs: docker push "${REMOTE}:${HASH}-${{ matrix.arch }}" - name: Download kernel artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: kernel-${{ matrix.arch }} path: mkosi.output/kernel/${{ env.KERNEL_VERSION }}/${{ matrix.arch }} - name: Download initramfs artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: initramfs-${{ matrix.arch }} path: out @@ -358,14 +356,14 @@ jobs: cp "out/initramfs-${KERNEL_VERSION}-${{ matrix.output_arch }}" \ "mkosi.output/initramfs/${KERNEL_VERSION}/${{ matrix.arch }}/image.cpio.zst" - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Build ISO - run: ./build.py iso + run: uv run ./build.py iso - name: Upload ISO artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: iso-${{ matrix.arch }} path: out/captainos-${{ env.KERNEL_VERSION }}-${{ matrix.output_arch }}.iso @@ -395,25 +393,25 @@ jobs: run: cat .github/config.env >> "$GITHUB_ENV" - name: Download kernel artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: kernel-${{ matrix.target }} path: mkosi.output/kernel/${{ env.KERNEL_VERSION }}/${{ matrix.target }} - name: Download initramfs artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: initramfs-${{ matrix.target }} path: out - name: Download ISO artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: iso-${{ matrix.target }} path: out - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Log in to GHCR uses: docker/login-action@v3 @@ -423,7 +421,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Publish artifacts to GHCR - run: ./build.py release publish + run: uv run ./build.py release publish # ------------------------------------------------------------------- # Publish combined multi-arch image (reuses per-arch registry blobs) @@ -445,43 +443,43 @@ jobs: run: cat .github/config.env >> "$GITHUB_ENV" - name: Download kernel artifacts (amd64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: kernel-amd64 path: mkosi.output/kernel/${{ env.KERNEL_VERSION }}/amd64 - name: Download initramfs artifacts (amd64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: initramfs-amd64 path: out - name: Download ISO artifact (amd64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: iso-amd64 path: out - name: Download kernel artifacts (arm64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: kernel-arm64 path: mkosi.output/kernel/${{ env.KERNEL_VERSION }}/arm64 - name: Download initramfs artifacts (arm64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: initramfs-arm64 path: out - name: Download ISO artifact (arm64) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: iso-arm64 path: out - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Log in to GHCR uses: docker/login-action@v3 @@ -491,4 +489,4 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Publish combined image to GHCR - run: ./build.py release publish + run: uv run ./build.py release publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfaac90..c478a8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,13 +27,13 @@ jobs: - name: Load shared config run: cat .github/config.env >> "$GITHUB_ENV" - - name: Install Python dependencies - run: pip install -r requirements.txt + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Pull release artifacts (combined) env: VERSION_EXCLUDE: ${{ github.ref_name }} - run: ./build.py release pull --target combined --pull-output artifacts/combined + run: uv run ./build.py release pull --target combined --pull-output artifacts/combined - name: Create GitHub Release env: @@ -47,4 +47,4 @@ jobs: - name: Tag OCI artifacts with version env: VERSION_EXCLUDE: ${{ github.ref_name }} - run: ./build.py release tag ${{ github.ref_name }} + run: uv run ./build.py release tag ${{ github.ref_name }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3f0f3a..f285650 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ Please read and understand the DCO found [here](https://github.com/tinkerbell/or ## Environment Details -Building is handled by a Python script, please see the [build.py](build.py) for details. Only Python >= 3.10 and Docker are required. +Building is handled by a Python script, please see the [build.py](build.py) for details. Only `uv` (Python) and Docker are required. ## How to Submit Change Requests diff --git a/Dockerfile b/Dockerfile index 5837829..6750a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,7 @@ ARG MKOSI_VERSION=v26 ENV DEBIAN_FRONTEND=noninteractive # Install mkosi runtime dependencies and kernel build dependencies in one layer -RUN apt-get update && apt-get install -y --no-install-recommends \ - # mkosi runtime deps - python3 \ - python3-pip \ - python3-venv \ +RUN apt-get -o "Dpkg::Use-Pty=0" update && apt-get -o "Dpkg::Use-Pty=0" install -y --no-install-recommends \ apt \ dpkg \ debian-archive-keyring \ @@ -59,20 +55,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ grub-common \ && NATIVE_ARCH="$(dpkg --print-architecture)" \ && FOREIGN_ARCH=$([ "$NATIVE_ARCH" = "amd64" ] && echo "arm64" || echo "amd64") \ - && apt-get install -y --no-install-recommends "grub-efi-${NATIVE_ARCH}-bin" \ + && apt-get -o "Dpkg::Use-Pty=0" install -y --no-install-recommends "grub-efi-${NATIVE_ARCH}-bin" \ && dpkg --add-architecture "$FOREIGN_ARCH" \ - && apt-get update \ - && apt-get install -y --no-install-recommends "grub-efi-${FOREIGN_ARCH}-bin:${FOREIGN_ARCH}" \ + && apt-get -o "Dpkg::Use-Pty=0" update \ + && apt-get -o "Dpkg::Use-Pty=0" install -y --no-install-recommends "grub-efi-${FOREIGN_ARCH}-bin:${FOREIGN_ARCH}" \ && rm -rf /var/lib/apt/lists/* -# Install mkosi from GitHub (not on PyPI) -RUN pip3 install --break-system-packages \ - configargparse \ - "git+https://github.com/systemd/mkosi.git@${MKOSI_VERSION}" +# Install astral-sh's uv with a script - install to /usr for global access +RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/usr/bin" sh + +# Verify uv is functional +RUN uv --version + +# Install mkosi from GitHub (not on PyPI) via uv; symlink to /usr/bin for global access +RUN uv tool install "git+https://github.com/systemd/mkosi.git@${MKOSI_VERSION}" +RUN ln -sf ~/.local/bin/mkosi /usr/bin/mkosi # Verify mkosi is functional RUN mkosi --version +# Prime uv's cache with our pyproject.toml to speed up runtime +COPY pyproject.toml /tmp/pyproject.toml +COPY captain /tmp/captain +COPY build.py /tmp/build.py +WORKDIR /tmp +RUN uv --verbose run build.py --help + WORKDIR /work ENTRYPOINT ["mkosi"] CMD ["build"] diff --git a/Dockerfile.release b/Dockerfile.release index 9d4babd..b63204c 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -1,15 +1,15 @@ # Lightweight container for OCI release operations (publish, index, pull, tag). -# Contains buildah, skopeo, Python 3, git, and configargparse — nothing else. # # Usage: # docker build -f Dockerfile.release -t captainos-release . # docker run --rm -v $(pwd):/work captainos-release release publish -FROM python:3.12-slim +FROM debian:trixie -# Install buildah, skopeo, and git -RUN apt-get update && apt-get install -y --no-install-recommends \ +# Install buildah, skopeo, curl and git +RUN apt-get -o "Dpkg::Use-Pty=0" update && apt-get -o "Dpkg::Use-Pty=0" install -y --no-install-recommends \ buildah \ skopeo \ + curl \ git \ ca-certificates \ && rm -rf /var/lib/apt/lists/* \ @@ -28,10 +28,19 @@ RUN mkdir -p /usr/libexec/podman \ && printf '#!/bin/sh\nexit 0\n' > /usr/libexec/podman/netavark \ && chmod +x /usr/libexec/podman/netavark -# Install Python dependencies -COPY requirements.txt /tmp/requirements.txt -RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt +# Install astral-sh's uv with a script - install to /usr for global access +RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/usr/bin" sh + +# Verify uv is functional +RUN uv --version + +# Prime uv's cache with our pyproject.toml to speed up runtime +COPY pyproject.toml /tmp/pyproject.toml +COPY captain /tmp/captain +COPY build.py /tmp/build.py +WORKDIR /tmp +RUN uv --verbose run build.py --help WORKDIR /work -ENTRYPOINT ["python3", "/work/build.py"] +ENTRYPOINT ["/usr/bin/uv", "run", "/work/build.py"] CMD ["release", "--help"] diff --git a/Makefile b/Makefile index e9150bc..b5fa100 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ .PHONY: lint fmt lint-install lint-install: - pip install -r requirements.txt -r requirements-dev.txt + uv sync --extra dev lint: - ruff check . - ruff format --check . - pyright . + uv tool run ruff check . + uv tool run ruff format --check . + uv sync --extra dev + uv run pyright fmt: - ruff check --fix . - ruff format . + uv tool run ruff check --fix . + uv tool run ruff format . diff --git a/README.md b/README.md index e938c3f..00e2a5b 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,14 @@ The build has four stages: ## Usage -**Prerequisites:** Python >= 3.10, Docker, [configargparse](https://pypi.org/project/ConfigArgParse/) +**Prerequisites:** `uv` (Python), Docker. ```bash -pip install -r requirements.txt +# Install Astral's `uv` if you don't have it: https://docs.astral.sh/uv/getting-started/installation/ +curl -LsSf https://astral.sh/uv/install.sh | sh # then re-log in # Build with defaults (amd64, kernel 6.18.16) -./build.py --help +uv run ./build.py --help usage: build.py [flags] @@ -176,9 +177,9 @@ Each stage can be executed in one of three modes: ```bash . -├── build.py # Main build entry point (Python >= 3.10) +├── build.py # Main build entry point (Python >= 3.13; use `uv run build.py`) ├── captain/ # Build system package (stdlib only) -│ ├── __init__.py +│ ├── __init__.py # Package init incl logging │ ├── cli.py # CLI subcommands (argparse) │ ├── config.py # Configuration from environment │ ├── docker.py # Docker builder management @@ -190,7 +191,6 @@ Each stage can be executed in one of three modes: │ ├── skopeo.py # skopeo CLI wrapper (inspect/copy/export) │ ├── iso.py # ISO image assembly │ ├── qemu.py # QEMU boot testing -│ ├── log.py # Colored logging │ └── util.py # Shared helpers & arch mapping ├── Dockerfile # Builder container definition ├── Dockerfile.release # Lightweight container for OCI release ops diff --git a/build.py b/build.py index 85ae4e6..df5324f 100755 --- a/build.py +++ b/build.py @@ -1,20 +1,24 @@ #!/usr/bin/env python3 """CaptainOS build system entry point. -Requires: Python >= 3.10, Docker (unless all stages use native or skip) +Requires: Python >= 3.13, Rich, Docker (unless all stages use native or skip). +It is recommended to use Astral's uv to run this script, which will automatically + install dependencies in an isolated environment. +This will automatically use uv to re-launch itself when stages use Docker. """ import sys -if sys.version_info < (3, 10): # noqa: UP036 - print("ERROR: Python >= 3.10 is required.", file=sys.stderr) +if sys.version_info < (3, 13): + print("ERROR: Python >= 3.13 is required.", file=sys.stderr) sys.exit(1) try: from captain.cli import main except ImportError as exc: print(f"ERROR: {exc}", file=sys.stderr) - print("Install dependencies: pip install -r requirements.txt", file=sys.stderr) + uv_url = "https://docs.astral.sh/uv/getting-started/installation/" + print(f"Missing dependencies, use uv to run. See {uv_url}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": diff --git a/captain/__init__.py b/captain/__init__.py index 29fee9b..0c2b429 100644 --- a/captain/__init__.py +++ b/captain/__init__.py @@ -1 +1,53 @@ -# captain — CaptainOS build system +"""captain — CaptainOS build system. + +Logging is configured here so that every ``logging.getLogger(__name__)`` +call in submodules automatically inherits the Rich console handler. +""" + +from __future__ import annotations + +import logging +import os + +from rich.console import Console +from rich.logging import RichHandler +from rich.traceback import install as _install_rich_traceback + +# Rich console — writes to stderr so log output never pollutes piped stdout. +# If running under GHA, force colors. +if os.environ.get("GITHUB_ACTIONS", "") == "": + console = Console(stderr=True) +else: + console = Console(stderr=True, color_system="standard", width=160, highlight=False) + +# Install Rich traceback handler globally (once, at import time). +_install_rich_traceback(console=console, show_locals=True, width=None) + + +class _StageFormatter(logging.Formatter): + """Show the module path relative to the ``captain`` package as a prefix.""" + + def format(self, record: logging.LogRecord) -> str: + name = record.name + stage = name.split(".", 1)[1] if name.startswith("captain.") else name + record.__dict__["stage"] = stage + return super().format(record) + + +# Configure the ``captain`` logger hierarchy once. +_root = logging.getLogger("captain") + +if not _root.handlers: + _handler = RichHandler( + console=console, + show_time=False, + show_level=True, + show_path=False, + markup=False, + rich_tracebacks=True, + tracebacks_show_locals=True, + ) + _handler.setFormatter(_StageFormatter("[%(stage)s] %(message)s")) + _root.addHandler(_handler) + _root.setLevel(logging.DEBUG) + _root.propagate = False diff --git a/captain/artifacts.py b/captain/artifacts.py index f40158f..1a1c769 100644 --- a/captain/artifacts.py +++ b/captain/artifacts.py @@ -3,14 +3,14 @@ from __future__ import annotations import hashlib +import logging import shutil from pathlib import Path from captain.config import Config -from captain.log import StageLogger, for_stage from captain.util import ensure_dir -_default_log = for_stage("artifacts") +log = logging.getLogger(__name__) def _sha256(path: Path) -> str: @@ -31,9 +31,8 @@ def _human_size(size: int) -> str: return f"{size:.1f}T" -def collect_kernel(cfg: Config, logger: StageLogger | None = None) -> None: +def collect_kernel(cfg: Config) -> None: """Copy the kernel image from mkosi.output/kernel/{version}/{arch}/ to out/.""" - _log = logger or _default_log out = ensure_dir(cfg.output_dir) vmlinuz_dir = cfg.kernel_output vmlinuz_files = sorted(vmlinuz_dir.glob("vmlinuz-*")) if vmlinuz_dir.is_dir() else [] @@ -41,28 +40,26 @@ def collect_kernel(cfg: Config, logger: StageLogger | None = None) -> None: vmlinuz_src = vmlinuz_files[0] vmlinuz_dst = out / f"vmlinuz-{cfg.kernel_version}-{cfg.arch_info.output_arch}" shutil.copy2(vmlinuz_src, vmlinuz_dst) - _log.log(f"kernel: {vmlinuz_dst} ({_human_size(vmlinuz_dst.stat().st_size)})") + log.info("kernel: %s (%s)", vmlinuz_dst, _human_size(vmlinuz_dst.stat().st_size)) else: - _log.warn(f"No kernel image found in {cfg.kernel_output}") + log.warning("No kernel image found in %s", cfg.kernel_output) -def collect_initramfs(cfg: Config, logger: StageLogger | None = None) -> None: +def collect_initramfs(cfg: Config) -> None: """Copy the initramfs CPIO from mkosi.output/initramfs/{arch}/ to out/.""" - _log = logger or _default_log out = ensure_dir(cfg.output_dir) cpio_files = sorted(cfg.initramfs_output.glob("*.cpio*")) if cpio_files: initrd_src = cpio_files[0] initrd_dst = out / f"initramfs-{cfg.kernel_version}-{cfg.arch_info.output_arch}" shutil.copy2(initrd_src, initrd_dst) - _log.log(f"initramfs: {initrd_dst} ({_human_size(initrd_dst.stat().st_size)})") + log.info("initramfs: %s (%s)", initrd_dst, _human_size(initrd_dst.stat().st_size)) else: - _log.warn(f"No initramfs CPIO found in {cfg.initramfs_output}") + log.warning("No initramfs CPIO found in %s", cfg.initramfs_output) -def collect_iso(cfg: Config, logger: StageLogger | None = None) -> None: +def collect_iso(cfg: Config) -> None: """Copy the ISO image from mkosi.output/iso/{arch}/ to out/.""" - _log = logger or _default_log out = ensure_dir(cfg.output_dir) iso_dir = cfg.iso_output iso_files = sorted(iso_dir.glob("*.iso")) if iso_dir.is_dir() else [] @@ -70,13 +67,12 @@ def collect_iso(cfg: Config, logger: StageLogger | None = None) -> None: iso_src = iso_files[0] iso_dst = out / f"captainos-{cfg.kernel_version}-{cfg.arch_info.output_arch}.iso" shutil.copy2(iso_src, iso_dst) - _log.log(f"iso: {iso_dst} ({_human_size(iso_dst.stat().st_size)})") + log.info("iso: %s (%s)", iso_dst, _human_size(iso_dst.stat().st_size)) def collect_checksums( files: list[Path], output: Path, - logger: StageLogger | None = None, ) -> None: """Compute SHA-256 checksums for *files* and write them to *output*. @@ -87,11 +83,10 @@ def collect_checksums( Only the bare filename (no directory component) is recorded so that ``sha256sum -c`` works from the directory containing the files. """ - _log = logger or _default_log lines: list[str] = [] for path in files: if not path.is_file(): - _log.warn(f"Skipping missing file: {path}") + log.warning("Skipping missing file: %s", path) continue digest = _sha256(path) lines.append(f"{digest} {path.name}") @@ -99,24 +94,23 @@ def collect_checksums( content = "\n".join(lines) + "\n" output.parent.mkdir(parents=True, exist_ok=True) if output.is_file() and output.read_text() == content: - _log.log(f"Checksums unchanged: {output}") + log.info("Checksums unchanged: %s", output) else: output.write_text(content) - _log.log(f"Wrote checksums to {output}") + log.info("Wrote checksums to %s", output) for line in lines: - _log.log(f" {line}") + log.info(" %s", line) else: - # All specified files were missing or non-regular; no checksums written. - _log.warn( - f"No checksums were written for {len(files)} requested file(s); " - "no output checksum file was created." + log.warning( + "No checksums were written for %d requested file(s); " + "no output checksum file was created.", + len(files), ) -def collect(cfg: Config, logger: StageLogger | None = None) -> None: +def collect(cfg: Config) -> None: """Copy initramfs, kernel, and ISO images from mkosi.output/ to out/.""" - _log = logger or _default_log - _log.log("Collecting build artifacts...") - collect_initramfs(cfg, logger=_log) - collect_kernel(cfg, logger=_log) - collect_iso(cfg, logger=_log) + log.info("Collecting build artifacts...") + collect_initramfs(cfg) + collect_kernel(cfg) + collect_iso(cfg) diff --git a/captain/buildah.py b/captain/buildah.py index 9230641..cbc3110 100644 --- a/captain/buildah.py +++ b/captain/buildah.py @@ -12,30 +12,24 @@ from __future__ import annotations +import logging from pathlib import Path -from captain.log import StageLogger, for_stage from captain.util import run -_default_log = for_stage("buildah") +log = logging.getLogger(__name__) def from_image( image: str, *, platform: str | None = None, - logger: StageLogger | None = None, ) -> str: - """Create a working container from *image* (local ID or ``scratch``). - - Returns the container ID. - """ - _log = logger or _default_log cmd: list[str] = ["buildah", "from"] if platform: cmd += ["--platform", platform] cmd.append(image) - _log.log(f"buildah from {image}") + log.info("buildah from %s", image) result = run(cmd, capture=True) return result.stdout.strip() @@ -43,12 +37,8 @@ def from_image( def add( container: str, files: list[Path], - *, - logger: StageLogger | None = None, ) -> None: - """Add *files* into the root of *container*.""" - _log = logger or _default_log - _log.log(f"buildah add {container} ({len(files)} files)") + log.info("buildah add %s (%d files)", container, len(files)) cmd: list[str] = ["buildah", "add", container] cmd += [str(f) for f in files] cmd.append("/") @@ -62,10 +52,7 @@ def config( arch: str | None = None, annotations: dict[str, str] | None = None, labels: dict[str, str] | None = None, - logger: StageLogger | None = None, ) -> None: - """Set image metadata on *container*.""" - _log = logger or _default_log cmd: list[str] = ["buildah", "config"] if os: cmd += ["--os", os] @@ -76,7 +63,7 @@ def config( for key, value in (labels or {}).items(): cmd += ["--label", f"{key}={value}"] cmd.append(container) - _log.log(f"buildah config {container}") + log.info("buildah config %s", container) run(cmd) @@ -84,15 +71,8 @@ def commit( container: str, *, timestamp: int | None = None, - logger: StageLogger | None = None, ) -> str: - """Commit *container* to a local image and remove the container. - - *timestamp* sets the creation timestamp (epoch seconds) for - deterministic builds. Returns the image ID. - """ - _log = logger or _default_log - _log.log(f"buildah commit {container}") + log.info("buildah commit %s", container) cmd: list[str] = ["buildah", "commit", "--rm"] if timestamp is not None: cmd += ["--timestamp", str(timestamp)] @@ -104,30 +84,15 @@ def commit( def push( image_id: str, dest: str, - *, - logger: StageLogger | None = None, ) -> None: - """Push *image_id* to a remote registry. - - *dest* should be a fully-qualified image reference (without the - ``docker://`` transport prefix — it is added automatically). - """ - _log = logger or _default_log - _log.log(f"buildah push → {dest}") + log.info("buildah push → %s", dest) run(["buildah", "push", image_id, f"docker://{dest}"]) def manifest_create( ref: str, - *, - logger: StageLogger | None = None, ) -> str: - """Create a new manifest list named *ref*. - - Returns the manifest list ID. - """ - _log = logger or _default_log - _log.log(f"buildah manifest create {ref}") + log.info("buildah manifest create %s", ref) result = run(["buildah", "manifest", "create", ref], capture=True) return result.stdout.strip() @@ -138,38 +103,27 @@ def manifest_add( *, os: str | None = None, arch: str | None = None, - logger: StageLogger | None = None, ) -> None: - """Add *image* to a manifest list.""" - _log = logger or _default_log cmd: list[str] = ["buildah", "manifest", "add"] if os: cmd += ["--os", os] if arch: cmd += ["--arch", arch] cmd += [manifest, image] - _log.log(f"buildah manifest add {manifest} ← {image}") + log.info("buildah manifest add %s ← %s", manifest, image) run(cmd) def manifest_push( manifest: str, dest: str, - *, - logger: StageLogger | None = None, ) -> None: - """Push *manifest* list (with all referenced images) to *dest*.""" - _log = logger or _default_log - _log.log(f"buildah manifest push → {dest}") + log.info("buildah manifest push → %s", dest) run(["buildah", "manifest", "push", "--all", manifest, f"docker://{dest}"]) def rmi( image: str, - *, - logger: StageLogger | None = None, ) -> None: - """Remove a local image or manifest list.""" - _log = logger or _default_log - _log.log(f"buildah rmi {image}") + log.info("buildah rmi %s", image) run(["buildah", "rmi", image]) diff --git a/captain/cli/_commands.py b/captain/cli/_commands.py index 2789757..576b51f 100644 --- a/captain/cli/_commands.py +++ b/captain/cli/_commands.py @@ -2,12 +2,13 @@ from __future__ import annotations +import logging import shutil +import sys from pathlib import Path from captain import artifacts, docker, qemu from captain.config import Config -from captain.log import StageLogger, for_stage from captain.util import run from ._stages import ( @@ -17,21 +18,20 @@ _build_tools_stage, ) +log = logging.getLogger(__name__) + def _cmd_kernel(cfg: Config, _extra_args: list[str]) -> None: """Build only the kernel (no tools, no mkosi).""" - klog = for_stage("kernel") _build_kernel_stage(cfg) - # Copy vmlinuz to the standard out/ directory. - artifacts.collect_kernel(cfg, logger=klog) - klog.log("Kernel build stage complete!") + artifacts.collect_kernel(cfg) + log.info("Kernel build stage complete!") def _cmd_tools(cfg: Config, _extra_args: list[str]) -> None: """Download tools (containerd, runc, nerdctl, CNI plugins).""" _build_tools_stage(cfg) - tlog = for_stage("tools") - tlog.log("Tools stage complete!") + log.info("Tools stage complete!") def _check_kernel_modules(cfg: Config) -> None: @@ -42,80 +42,80 @@ def _check_kernel_modules(cfg: Config) -> None: issue) the build should fail immediately rather than silently producing an initramfs without modules. """ - ilog = for_stage("initramfs") modules_dir = cfg.modules_output / "usr" / "lib" / "modules" if not modules_dir.is_dir(): - ilog.err(f"Kernel modules directory not found: {modules_dir}") - ilog.err("Ensure the kernel build artifacts are downloaded correctly.") + log.error("Kernel modules directory not found: %s", modules_dir) + log.error("Ensure the kernel build artifacts are downloaded correctly.") raise SystemExit(1) # Check that at least one module version directory exists with modules version_dirs = [d for d in modules_dir.iterdir() if d.is_dir()] if not version_dirs: - ilog.err(f"No kernel version directories found in {modules_dir}") + log.error("No kernel version directories found in %s", modules_dir) raise SystemExit(1) # Search all version directories for at least one kernel module for version_dir in version_dirs: if any(version_dir.rglob("*.ko*")): - ilog.log(f"Kernel modules found in {version_dir} (version: {version_dir.name})") + log.info("Kernel modules found in %s (version: %s)", version_dir, version_dir.name) return searched = ", ".join(str(d) for d in version_dirs) - ilog.err("No kernel modules (.ko/.ko.zst) found in any kernel version directory.") - ilog.err(f"Searched directories: {searched}") + log.error("No kernel modules (.ko/.ko.zst) found in any kernel version directory.") + log.error("Searched directories: %s", searched) raise SystemExit(1) def _cmd_initramfs(cfg: Config, extra_args: list[str]) -> None: """Build only the initramfs via mkosi, then collect artifacts.""" - ilog = for_stage("initramfs") _check_kernel_modules(cfg) _build_mkosi_stage(cfg, extra_args) - artifacts.collect_initramfs(cfg, logger=ilog) - artifacts.collect_kernel(cfg, logger=ilog) - ilog.log("Initramfs build complete!") + artifacts.collect_initramfs(cfg) + artifacts.collect_kernel(cfg) + log.info("Initramfs build complete!") def _cmd_iso(cfg: Config, _extra_args: list[str]) -> None: """Build only the ISO image.""" - isolog = for_stage("iso") _build_iso_stage(cfg) - artifacts.collect_iso(cfg, logger=isolog) - isolog.log("ISO build complete!") + artifacts.collect_iso(cfg) + log.info("ISO build complete!") def _cmd_build(cfg: Config, extra_args: list[str]) -> None: """Full build: kernel → tools → initramfs → iso → artifacts.""" - blog = for_stage("build") _build_kernel_stage(cfg) _build_tools_stage(cfg) _build_mkosi_stage(cfg, extra_args) _build_iso_stage(cfg) - artifacts.collect(cfg, logger=blog) - blog.log("Build complete!") + artifacts.collect(cfg) + log.info("Build complete!") def _cmd_shell(cfg: Config, _extra_args: list[str]) -> None: """Interactive shell inside the builder container.""" - slog = for_stage("shell") - docker.build_builder(cfg, logger=slog) - slog.log("Entering builder shell (type 'exit' to leave)...") - docker.run_in_builder(cfg, "-it", "--entrypoint", "/bin/bash", cfg.builder_image) + docker.build_builder(cfg) + log.info("Entering builder shell (type 'exit' to leave)...") + docker.run_in_builder( + cfg, + *(["-it"] if sys.stdout.isatty() and sys.stdin.isatty() else []), + "--entrypoint", + "/bin/bash", + cfg.builder_image, + ) def _cmd_clean(cfg: Config, _extra_args: list[str], args: object = None) -> None: """Remove build artifacts for the selected kernel version, or all.""" - clog = for_stage("clean") clean_all = getattr(args, "clean_all", False) if clean_all: - _clean_all(cfg, clog) + _clean_all(cfg) else: - _clean_version(cfg, clog) + _clean_version(cfg) -def _clean_version(cfg: Config, clog: StageLogger) -> None: +def _clean_version(cfg: Config) -> None: """Remove build artifacts for a single kernel version.""" kver = cfg.kernel_version - clog.log(f"Cleaning build artifacts for kernel {kver} ({cfg.arch})...") + log.info("Cleaning build artifacts for kernel %s (%s)...", kver, cfg.arch) mkosi_output = cfg.mkosi_output # Version-specific directories under mkosi.output/{stage}/{version}/{arch} @@ -164,12 +164,12 @@ def _clean_version(cfg: Config, clog: StageLogger) -> None: for p in cfg.output_dir.glob(pattern): p.unlink(missing_ok=True) - clog.log(f"Clean complete for kernel {kver}.") + log.info("Clean complete for kernel %s.", kver) -def _clean_all(cfg: Config, clog: StageLogger) -> None: +def _clean_all(cfg: Config) -> None: """Remove all build artifacts (all kernel versions).""" - clog.log("Cleaning ALL build artifacts...") + log.info("Cleaning ALL build artifacts...") mkosi_output = cfg.mkosi_output mkosi_cache = cfg.project_dir / "mkosi.cache" @@ -210,18 +210,17 @@ def _clean_all(cfg: Config, clog: StageLogger) -> None: if cfg.output_dir.exists(): shutil.rmtree(cfg.output_dir) - clog.log("Clean complete.") + log.info("Clean complete.") def _cmd_summary(cfg: Config, _extra_args: list[str]) -> None: """Print mkosi configuration summary.""" - slog = for_stage("summary") tools_tree = str(cfg.tools_output) modules_tree = str(cfg.modules_output) output_dir = str(cfg.initramfs_output) match cfg.mkosi_mode: case "docker": - docker.build_builder(cfg, logger=slog) + docker.build_builder(cfg) container_tree = f"/work/mkosi.output/tools/{cfg.arch}" container_modules = f"/work/mkosi.output/kernel/{cfg.kernel_version}/{cfg.arch}/modules" container_outdir = f"/work/mkosi.output/initramfs/{cfg.kernel_version}/{cfg.arch}" @@ -231,7 +230,6 @@ def _cmd_summary(cfg: Config, _extra_args: list[str]) -> None: f"--extra-tree={container_modules}", f"--output-dir={container_outdir}", "summary", - logger=slog, ) case "native": run( @@ -246,25 +244,23 @@ def _cmd_summary(cfg: Config, _extra_args: list[str]) -> None: cwd=cfg.project_dir, ) case "skip": - slog.err("Cannot show mkosi summary when MKOSI_MODE=skip.") + log.error("Cannot show mkosi summary when MKOSI_MODE=skip.") raise SystemExit(1) def _cmd_checksums(cfg: Config, _extra_args: list[str], args: object = None) -> None: """Compute SHA-256 checksums for the specified files.""" - clog = for_stage("checksums") files = getattr(args, "files", None) or [] output = getattr(args, "output", None) if files: # Explicit mode: user provided specific files and output. if not output: - clog.err("--output is required when specifying files explicitly.") + log.error("--output is required when specifying files explicitly.") raise SystemExit(1) artifacts.collect_checksums( [Path(f) for f in files], Path(output), - logger=clog, ) else: # Default mode: produce checksums for the selected architecture. @@ -278,11 +274,11 @@ def _cmd_checksums(cfg: Config, _extra_args: list[str], args: object = None) -> ] existing = [f for f in arch_files if f.is_file()] if not existing: - clog.err(f"No artifacts found for {kver}-{oarch} in {out}") + log.error("No artifacts found for %s-%s in %s", kver, oarch, out) raise SystemExit(1) dest = Path(output) if output else out / f"sha256sums-{kver}-{oarch}.txt" - artifacts.collect_checksums(existing, dest, logger=clog) - clog.log("Checksums complete!") + artifacts.collect_checksums(existing, dest) + log.info("Checksums complete!") def _cmd_qemu_test(cfg: Config, _extra_args: list[str], args: object = None) -> None: diff --git a/captain/cli/_main.py b/captain/cli/_main.py index c9c7575..33ded5d 100644 --- a/captain/cli/_main.py +++ b/captain/cli/_main.py @@ -15,12 +15,12 @@ from __future__ import annotations +import logging import sys from pathlib import Path from captain import docker from captain.config import Config -from captain.log import for_stage from captain.util import run from ._commands import ( @@ -38,6 +38,8 @@ from ._parser import _build_parser, _extract_command from ._release import _cmd_release +log = logging.getLogger(__name__) + def main(project_dir: Path | None = None) -> None: """Main CLI entry point.""" @@ -105,13 +107,12 @@ def main(project_dir: Path | None = None) -> None: else: # Pass through to mkosi (shouldn't happen with _extract_command # but kept as a safety net). - mlog = for_stage("mkosi") tools_tree = str(cfg.tools_output) modules_tree = str(cfg.modules_output) output_dir = str(cfg.initramfs_output) match cfg.mkosi_mode: case "docker": - docker.build_builder(cfg, logger=mlog) + docker.build_builder(cfg) container_tree = f"/work/mkosi.output/tools/{cfg.arch}" container_modules = ( f"/work/mkosi.output/kernel/{cfg.kernel_version}/{cfg.arch}/modules" @@ -124,7 +125,6 @@ def main(project_dir: Path | None = None) -> None: f"--output-dir={container_outdir}", command, *extra, - logger=mlog, ) case "native": run( @@ -140,5 +140,5 @@ def main(project_dir: Path | None = None) -> None: cwd=cfg.project_dir, ) case "skip": - mlog.err(f"Cannot pass '{command}' to mkosi when MKOSI_MODE=skip.") + log.error("Cannot pass '%s' to mkosi when MKOSI_MODE=skip.", command) raise SystemExit(1) diff --git a/captain/cli/_release.py b/captain/cli/_release.py index 225612b..41f4700 100644 --- a/captain/cli/_release.py +++ b/captain/cli/_release.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import shutil import subprocess from pathlib import Path @@ -10,7 +11,6 @@ from captain import docker, oci from captain.config import Config -from captain.log import for_stage from captain.util import check_release_dependencies from ._parser import ( @@ -23,6 +23,8 @@ _HelpFormatter, ) +log = logging.getLogger(__name__) + _RELEASE_SUBCOMMANDS = ("publish", "pull", "tag") _RELEASE_SUBCMD_INFO: dict[str, tuple[str, list]] = { @@ -85,13 +87,12 @@ def _resolve_git_sha(args: object, project_dir: Path) -> str: def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> None: """OCI artifact operations: publish, pull, tag.""" - rlog = for_stage("release") # Peel the release subcommand from extra_args. if not extra_args: - rlog.err( - f"Missing release subcommand.\n" - f" usage: build.py release {{{','.join(_RELEASE_SUBCOMMANDS)}}}\n" + log.error( + "Missing release subcommand.\n usage: build.py release {%s}\n", + ",".join(_RELEASE_SUBCOMMANDS), ) raise SystemExit(2) @@ -99,8 +100,10 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non rest = extra_args[1:] if sub not in _RELEASE_SUBCOMMANDS: - rlog.err( - f"Unknown release subcommand '{sub}'.\n valid: {', '.join(_RELEASE_SUBCOMMANDS)}\n" + log.error( + "Unknown release subcommand '%s'.\n valid: %s\n", + sub, + ", ".join(_RELEASE_SUBCOMMANDS), ) raise SystemExit(2) @@ -110,21 +113,21 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non # --- validate required args early --------------------------------- if sub == "tag" and not rest: - rlog.err("Missing version argument.") + log.error("Missing version argument.") _print_release_subcmd_help(sub, exit_code=2) if sub == "pull" and not getattr(args, "pull_output", None): - rlog.err("--pull-output is required for 'release pull'.") + log.error("--pull-output is required for 'release pull'.") _print_release_subcmd_help(sub, exit_code=2) # --- skip --------------------------------------------------------- if cfg.release_mode == "skip": - rlog.log("RELEASE_MODE=skip — skipping release operation") + log.info("RELEASE_MODE=skip — skipping release operation") return # --- docker ------------------------------------------------------- if cfg.release_mode == "docker": - docker.build_release_image(cfg, logger=rlog) - rlog.log(f"Running release {sub} (docker)...") + docker.build_release_image(cfg) + log.info("Running release %s (docker)...", sub) # Forward release-specific env vars into the container. registry = getattr(args, "registry", "ghcr.io") repository = getattr(args, "repository", "tinkerbell/captain") @@ -163,8 +166,10 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non cfg, *env_args, "--entrypoint", - "python3", + "/usr/bin/uv", docker.RELEASE_IMAGE, + *(["--verbose"] if logging.getLogger().isEnabledFor(logging.DEBUG) else []), + "run", *inner_cmd, ) except subprocess.CalledProcessError as exc: @@ -173,15 +178,15 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non if pull_output: container_pull_output = f"/work/{pull_output.lstrip('/')}" paths_to_fix.append(container_pull_output) - docker.fix_docker_ownership(cfg, rlog, paths_to_fix) + docker.fix_docker_ownership(cfg, paths_to_fix) return # --- native ------------------------------------------------------- if cfg.release_mode == "native": missing = check_release_dependencies() if missing: - rlog.err(f"Missing release tools: {', '.join(missing)}") - rlog.err("Install them or set --release-mode=docker.") + log.error("Missing release tools: %s", ", ".join(missing)) + log.error("Install them or set --release-mode=docker.") raise SystemExit(1) # Common OCI parameters. registry = getattr(args, "registry", "ghcr.io") @@ -203,14 +208,13 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non tag=tag, sha=sha, force=force, - logger=rlog, ) elif sub == "pull": target = getattr(args, "target", None) or cfg.arch pull_output = getattr(args, "pull_output", None) if pull_output is None: - rlog.err("--pull-output is required for 'release pull'.") + log.error("--pull-output is required for 'release pull'.") raise SystemExit(2) oci.pull( registry=registry, @@ -219,7 +223,6 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non tag=tag, target=target, output_dir=Path(pull_output), - logger=rlog, ) elif sub == "tag": @@ -230,5 +233,4 @@ def _cmd_release(cfg: Config, extra_args: list[str], args: object = None) -> Non artifact_name=artifact_name, src_tag=tag, new_tag=version, - logger=rlog, ) diff --git a/captain/cli/_stages.py b/captain/cli/_stages.py index 4e359c2..358d299 100644 --- a/captain/cli/_stages.py +++ b/captain/cli/_stages.py @@ -2,19 +2,21 @@ from __future__ import annotations +import logging + from captain import docker, iso, kernel, tools from captain.config import Config -from captain.log import for_stage from captain.util import check_kernel_dependencies, check_mkosi_dependencies, run +log = logging.getLogger(__name__) + def _build_kernel_stage(cfg: Config) -> None: """Run the kernel build stage according to *cfg.kernel_mode*.""" - klog = for_stage("kernel") # --- skip --------------------------------------------------------- if cfg.kernel_mode == "skip": - klog.log("KERNEL_MODE=skip — skipping kernel build") + log.info("KERNEL_MODE=skip — skipping kernel build") return # --- idempotency -------------------------------------------------- @@ -23,37 +25,38 @@ def _build_kernel_stage(cfg: Config) -> None: has_vmlinuz = vmlinuz_dir.is_dir() and any(vmlinuz_dir.glob("vmlinuz-*")) if modules_dir.is_dir() and has_vmlinuz and not cfg.force_kernel: - klog.log("Kernel already built (use --force-kernel to rebuild)") + log.info("Kernel already built (use --force-kernel to rebuild)") return if modules_dir.is_dir() and not has_vmlinuz: - klog.warn("Modules exist but vmlinuz is missing — rebuilding kernel") + log.warning("Modules exist but vmlinuz is missing — rebuilding kernel") # --- native ------------------------------------------------------- if cfg.kernel_mode == "native": missing = check_kernel_dependencies(cfg.arch) if missing: - klog.err(f"Missing kernel build tools: {', '.join(missing)}") - klog.err("Install them or set --kernel-mode=docker.") + log.error("Missing kernel build tools: %s", ", ".join(missing)) + log.error("Install them or set --kernel-mode=docker.") raise SystemExit(1) - klog.log("Building kernel (native)...") + log.info("Building kernel (native)...") kernel.build(cfg) return # --- docker ------------------------------------------------------- - docker.build_builder(cfg, logger=klog) - klog.log("Building kernel (docker)...") + docker.build_builder(cfg) + log.info("Building kernel (docker)...") docker.run_in_builder( cfg, "--entrypoint", - "python3", + "/usr/bin/uv", cfg.builder_image, + *(["--verbose"] if logging.getLogger().isEnabledFor(logging.DEBUG) else []), + "run", "/work/build.py", "kernel", ) docker.fix_docker_ownership( cfg, - klog, [ f"/work/mkosi.output/kernel/{cfg.kernel_version}/{cfg.arch}", "/work/out", @@ -63,40 +66,40 @@ def _build_kernel_stage(cfg: Config) -> None: def _build_tools_stage(cfg: Config) -> None: """Run the tools download stage according to *cfg.tools_mode*.""" - tlog = for_stage("tools") # --- skip --------------------------------------------------------- if cfg.tools_mode == "skip": - tlog.log("TOOLS_MODE=skip — skipping tools download") + log.info("TOOLS_MODE=skip — skipping tools download") return # --- native ------------------------------------------------------- if cfg.tools_mode == "native": - tlog.log("Downloading tools (nerdctl, containerd, etc.)...") + log.info("Downloading tools (nerdctl, containerd, etc.)...") tools.download_all(cfg) return # --- docker ------------------------------------------------------- - docker.build_builder(cfg, logger=tlog) - tlog.log("Downloading tools (nerdctl, containerd, etc.)...") + docker.build_builder(cfg) + log.info("Downloading tools (nerdctl, containerd, etc.)...") docker.run_in_builder( cfg, "--entrypoint", - "python3", + "/usr/bin/uv", cfg.builder_image, + *(["--verbose"] if logging.getLogger().isEnabledFor(logging.DEBUG) else []), + "run", "/work/build.py", "tools", ) - docker.fix_docker_ownership(cfg, tlog, ["/work/mkosi.output"]) + docker.fix_docker_ownership(cfg, ["/work/mkosi.output"]) def _build_mkosi_stage(cfg: Config, extra_args: list[str]) -> None: """Run the mkosi image-assembly stage according to *cfg.mkosi_mode*.""" - ilog = for_stage("initramfs") # --- skip --------------------------------------------------------- if cfg.mkosi_mode == "skip": - ilog.log("MKOSI_MODE=skip — skipping image assembly") + log.info("MKOSI_MODE=skip — skipping image assembly") return mkosi_args = list(cfg.mkosi_args) + list(extra_args) @@ -105,10 +108,10 @@ def _build_mkosi_stage(cfg: Config, extra_args: list[str]) -> None: if cfg.mkosi_mode == "native": missing = check_mkosi_dependencies() if missing: - ilog.err(f"Missing mkosi tools: {', '.join(missing)}") - ilog.err("Install them or set --mkosi-mode=docker.") + log.error("Missing mkosi tools: %s", ", ".join(missing)) + log.error("Install them or set --mkosi-mode=docker.") raise SystemExit(1) - ilog.log("Building initrd with mkosi (native)...") + log.info("Building initrd with mkosi (native)...") tools_tree = str(cfg.tools_output) modules_tree = str(cfg.modules_output) output_dir = str(cfg.initramfs_output) @@ -127,8 +130,8 @@ def _build_mkosi_stage(cfg: Config, extra_args: list[str]) -> None: return # --- docker ------------------------------------------------------- - docker.build_builder(cfg, logger=ilog) - ilog.log("Building initrd with mkosi (docker)...") + docker.build_builder(cfg) + log.info("Building initrd with mkosi (docker)...") tools_tree = f"/work/mkosi.output/tools/{cfg.arch}" modules_tree = f"/work/mkosi.output/kernel/{cfg.kernel_version}/{cfg.arch}/modules" output_dir = f"/work/mkosi.output/initramfs/{cfg.kernel_version}/{cfg.arch}" @@ -137,13 +140,12 @@ def _build_mkosi_stage(cfg: Config, extra_args: list[str]) -> None: f"--extra-tree={tools_tree}", f"--extra-tree={modules_tree}", f"--output-dir={output_dir}", + "--package-cache-dir=/cache/packages", "build", *mkosi_args, - logger=ilog, ) docker.fix_docker_ownership( cfg, - ilog, [ f"/work/mkosi.output/initramfs/{cfg.kernel_version}/{cfg.arch}", "/work/out", @@ -153,39 +155,39 @@ def _build_mkosi_stage(cfg: Config, extra_args: list[str]) -> None: def _build_iso_stage(cfg: Config) -> None: """Run the ISO build stage according to *cfg.iso_mode*.""" - isolog = for_stage("iso") # --- skip --------------------------------------------------------- if cfg.iso_mode == "skip": - isolog.log("ISO_MODE=skip — skipping ISO build") + log.info("ISO_MODE=skip — skipping ISO build") return # --- idempotency -------------------------------------------------- iso_path = cfg.iso_output / f"captainos-{cfg.kernel_version}-{cfg.arch_info.output_arch}.iso" if iso_path.is_file() and not cfg.force_iso: - isolog.log(f"ISO already built: {iso_path} (use --force-iso to rebuild)") + log.info("ISO already built: %s (use --force-iso to rebuild)", iso_path) return # --- native ------------------------------------------------------- if cfg.iso_mode == "native": - isolog.log("Building ISO (native)...") + log.info("Building ISO (native)...") iso.build(cfg) return # --- docker ------------------------------------------------------- - docker.build_builder(cfg, logger=isolog) - isolog.log("Building ISO (docker)...") + docker.build_builder(cfg) + log.info("Building ISO (docker)...") docker.run_in_builder( cfg, "--entrypoint", - "python3", + "/usr/bin/uv", cfg.builder_image, + *(["--verbose"] if logging.getLogger().isEnabledFor(logging.DEBUG) else []), + "run", "/work/build.py", "iso", ) docker.fix_docker_ownership( cfg, - isolog, [ "/work/mkosi.output/iso", "/work/out", diff --git a/captain/config.py b/captain/config.py index 90cd118..a8ad061 100644 --- a/captain/config.py +++ b/captain/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import sys from dataclasses import dataclass, field @@ -10,6 +11,8 @@ from captain.util import ArchInfo, get_arch_info +log = logging.getLogger(__name__) + # Valid values for KERNEL_MODE and MKOSI_MODE. VALID_MODES = ("docker", "native", "skip") @@ -70,10 +73,7 @@ def __post_init__(self) -> None: ("RELEASE_MODE", self.release_mode), ): if value not in VALID_MODES: - print( - f"ERROR: {name}={value!r} is invalid. Valid values: {', '.join(VALID_MODES)}", - file=sys.stderr, - ) + log.error("%s=%r is invalid. Valid values: %s", name, value, ", ".join(VALID_MODES)) sys.exit(1) @property diff --git a/captain/docker.py b/captain/docker.py index f2dc03d..cdf2ddb 100644 --- a/captain/docker.py +++ b/captain/docker.py @@ -3,15 +3,16 @@ from __future__ import annotations import hashlib +import logging import os import platform +import sys from pathlib import Path from captain.config import Config -from captain.log import StageLogger, err, for_stage from captain.util import run -_default_log = for_stage("docker") +log = logging.getLogger(__name__) def _image_exists(image: str) -> bool: @@ -37,7 +38,7 @@ def _dockerfile_hash(cfg: Config) -> str: return hashlib.sha256(dockerfile.read_bytes()).hexdigest() -def build_builder(cfg: Config, logger: StageLogger | None = None) -> None: +def build_builder(cfg: Config) -> None: """Build the Docker builder image when the Dockerfile has changed. The image is tagged with a content hash of the Dockerfile so that @@ -46,23 +47,24 @@ def build_builder(cfg: Config, logger: StageLogger | None = None) -> None: ``docker/build-push-action`` step with ``load: true``), we skip the build entirely. Use ``NO_CACHE=1`` to force a full rebuild. """ - _log = logger or _default_log tag = _dockerfile_hash(cfg) tagged_image = f"{cfg.builder_image}:{tag}" if not cfg.no_cache and _image_exists(tagged_image): - _log.log(f"Docker image '{cfg.builder_image}' is up to date.") + log.info("Docker image '%s' is up to date.", cfg.builder_image) # Ensure the un-hashed tag exists so later docker-run calls that # reference cfg.builder_image (without the hash suffix) succeed. # This matters when the hashed tag was pre-loaded by CI. run(["docker", "tag", tagged_image, cfg.builder_image], check=False) return - _log.log(f"Building Docker image '{cfg.builder_image}'...") - cmd = ["docker", "build"] + log.info("Building Docker image '%s'...", cfg.builder_image) + cmd = ["docker", "buildx", "build"] if cfg.no_cache: cmd.append("--no-cache") - cmd.extend(["-t", tagged_image, "-t", cfg.builder_image, str(cfg.project_dir)]) + cmd.extend( + ["--progress=plain", "-t", tagged_image, "-t", cfg.builder_image, str(cfg.project_dir)] + ) run(cmd) @@ -75,21 +77,21 @@ def _release_dockerfile_hash(cfg: Config) -> str: return hashlib.sha256(dockerfile.read_bytes()).hexdigest() -def build_release_image(cfg: Config, logger: StageLogger | None = None) -> None: +def build_release_image(cfg: Config) -> None: """Build the release Docker image from ``Dockerfile.release``.""" - _log = logger or _default_log tag = _release_dockerfile_hash(cfg) tagged_image = f"{RELEASE_IMAGE}:{tag}" if not cfg.no_cache and _image_exists(tagged_image): - _log.log(f"Docker image '{RELEASE_IMAGE}' is up to date.") + log.info("Docker image '%s' is up to date.", RELEASE_IMAGE) run(["docker", "tag", tagged_image, RELEASE_IMAGE]) return - _log.log(f"Building Docker image '{RELEASE_IMAGE}'...") - cmd = ["docker", "build", "-f", str(cfg.project_dir / "Dockerfile.release")] + log.info("Building Docker image '%s'...", RELEASE_IMAGE) + cmd = ["docker", "buildx", "build", "-f", str(cfg.project_dir / "Dockerfile.release")] if cfg.no_cache: cmd.append("--no-cache") + cmd.extend(["--progress=plain"]) cmd.extend(["-t", tagged_image, "-t", RELEASE_IMAGE, str(cfg.project_dir)]) run(cmd) @@ -106,6 +108,9 @@ def run_in_release(cfg: Config, *extra_args: str) -> None: "--rm", # Buildah needs mount/remount capabilities for layer operations. "--privileged", + # interactive if running in a terminal + *(["-i"] if sys.stdout.isatty() and sys.stdin.isatty() else []), + "-t", # terminal "-v", f"{cfg.project_dir}:/work", "-w", @@ -118,6 +123,12 @@ def run_in_release(cfg: Config, *extra_args: str) -> None: # (no user namespaces needed — we only assemble scratch images). "-e", "BUILDAH_ISOLATION=chroot", + "-e", + f"TERM={os.environ.get('TERM', 'xterm-256color')}", + "-e", + f"COLUMNS={os.environ.get('COLUMNS', '200')}", + "-e", + f"GITHUB_ACTIONS={os.environ.get('GITHUB_ACTIONS', '')}", ] # Forward host registry credentials so buildah/skopeo can authenticate. # The caller sets these env vars on the host (e.g. via docker login or @@ -140,8 +151,9 @@ def run_in_builder(cfg: Config, *extra_args: str) -> None: "run", "--rm", "--privileged", - "-v", - f"{cfg.project_dir}:/work", + # interactive if running in a terminal + *(["-i"] if sys.stdout.isatty() and sys.stdin.isatty() else []), + "-t", # terminal "-w", "/work", "-e", @@ -154,8 +166,6 @@ def run_in_builder(cfg: Config, *extra_args: str) -> None: f"FORCE_KERNEL={int(cfg.force_kernel)}", "-e", f"FORCE_ISO={int(cfg.force_iso)}", - # Force all stage modes to native inside the container so - # build.py never tries to launch Docker recursively. "-e", "KERNEL_MODE=native", "-e", @@ -166,13 +176,35 @@ def run_in_builder(cfg: Config, *extra_args: str) -> None: "ISO_MODE=native", "-e", "RELEASE_MODE=native", + "-e", + f"TERM={os.environ.get('TERM', 'xterm-256color')}", + "-e", + f"COLUMNS={os.environ.get('COLUMNS', '200')}", + "-e", + f"GITHUB_ACTIONS={os.environ.get('GITHUB_ACTIONS', '')}", ] + docker_args += ["-v", f"{cfg.project_dir}/mkosi.output:/work/mkosi.output"] + docker_args += ["-v", f"{cfg.project_dir}/mkosi.extra:/work/mkosi.extra"] + docker_args += ["-v", f"{cfg.project_dir}/out:/work/out"] + + docker_args += ["-v", f"{cfg.project_dir}/mkosi.conf:/work/mkosi.conf"] + docker_args += ["-v", f"{cfg.project_dir}/mkosi.finalize:/work/mkosi.finalize"] + docker_args += ["-v", f"{cfg.project_dir}/mkosi.postinst:/work/mkosi.postinst"] + + docker_args += ["-v", f"{cfg.project_dir}/captain:/work/captain"] + docker_args += ["-v", f"{cfg.project_dir}/pyproject.toml:/work/pyproject.toml"] + docker_args += ["-v", f"{cfg.project_dir}/build.py:/work/build.py"] + + docker_args += ["-v", f"{cfg.project_dir}/kernel.configs:/work/kernel.configs"] + + docker_args += ["--mount", "type=volume,source=captain-cache-packages,target=/cache/packages"] + # Mount kernel source if provided if cfg.kernel_src is not None: kernel_src_path = Path(cfg.kernel_src).resolve() if not kernel_src_path.is_dir(): - err(f"KERNEL_SRC={cfg.kernel_src} does not exist") + log.error("KERNEL_SRC=%s does not exist", cfg.kernel_src) raise SystemExit(1) docker_args.extend(["-v", f"{kernel_src_path}:/work/kernel-src:ro"]) docker_args.extend(["-e", "KERNEL_SRC=/work/kernel-src"]) @@ -185,18 +217,19 @@ def run_in_builder(cfg: Config, *extra_args: str) -> None: else: kernel_cfg_path = kernel_cfg_path.resolve() if not kernel_cfg_path.is_file(): - err(f"KERNEL_CONFIG={cfg.kernel_config} does not exist") + log.error("KERNEL_CONFIG=%s does not exist", cfg.kernel_config) raise SystemExit(1) docker_args.extend(["-v", f"{kernel_cfg_path}:/work/kernel-config:ro"]) docker_args.extend(["-e", "KERNEL_CONFIG=/work/kernel-config"]) docker_args.extend(extra_args) + log.debug("Docker args (builder): %s", docker_args) run(docker_args) -def run_mkosi(cfg: Config, *mkosi_args: str, logger: StageLogger | None = None) -> None: +def run_mkosi(cfg: Config, *mkosi_args: str) -> None: """Run mkosi inside the builder container.""" - ensure_binfmt(cfg, logger=logger) + ensure_binfmt(cfg) run_in_builder( cfg, cfg.builder_image, @@ -205,10 +238,9 @@ def run_mkosi(cfg: Config, *mkosi_args: str, logger: StageLogger | None = None) ) -def ensure_binfmt(cfg: Config, logger: StageLogger | None = None) -> None: +def ensure_binfmt(cfg: Config) -> None: """Register binfmt_misc handlers if doing a cross-architecture build.""" - _log = logger or _default_log - host_arch = platform.machine() # e.g. "x86_64" or "aarch64" + host_arch = platform.machine() need_binfmt = False match (host_arch, cfg.arch): @@ -220,8 +252,10 @@ def ensure_binfmt(cfg: Config, logger: StageLogger | None = None) -> None: if not need_binfmt: return - _log.log( - f"Registering binfmt_misc handlers for cross-arch build ({host_arch} -> {cfg.arch})..." + log.info( + "Registering binfmt_misc handlers for cross-arch build (%s -> %s)...", + host_arch, + cfg.arch, ) result = run( [ @@ -237,11 +271,11 @@ def ensure_binfmt(cfg: Config, logger: StageLogger | None = None) -> None: capture=True, ) if result.returncode != 0: - _log.warn("Could not auto-register binfmt handlers.") - _log.warn("Run manually: docker run --privileged --rm tonistiigi/binfmt --install all") + log.warning("Could not auto-register binfmt handlers.") + log.warning("Run manually: docker run --privileged --rm tonistiigi/binfmt --install all") -def fix_docker_ownership(cfg: Config, logger, paths: list[str]) -> None: +def fix_docker_ownership(cfg: Config, paths: list[str]) -> None: """Fix ownership of Docker-created files (container runs as root). Spawns a lightweight container to ``chown -R`` the given paths @@ -254,10 +288,6 @@ def fix_docker_ownership(cfg: Config, logger, paths: list[str]) -> None: uid = os.getuid() gid = os.getgid() - # *paths* use the container mount prefix /work — translate to host. - # Check the path itself **and** every child — the top-level directory - # may already be owned by the host user while files inside it were - # created by the container (root). needs_fix: list[str] = [] for p in paths: host_path = Path(p.replace("/work", str(cfg.project_dir), 1)) @@ -278,7 +308,7 @@ def fix_docker_ownership(cfg: Config, logger, paths: list[str]) -> None: if not needs_fix: return - logger.log("Fixing ownership of Docker-created files...") + log.info("Fixing ownership of Docker-created files...") run( [ "docker", diff --git a/captain/iso.py b/captain/iso.py index 8275b32..5aa2044 100644 --- a/captain/iso.py +++ b/captain/iso.py @@ -2,15 +2,15 @@ from __future__ import annotations +import logging import shutil import textwrap from pathlib import Path from captain.config import Config -from captain.log import for_stage from captain.util import ensure_dir, run -_log = for_stage("iso") +log = logging.getLogger(__name__) # GRUB platform directory name per architecture. _GRUB_PLATFORM = { @@ -44,8 +44,8 @@ def _find_vmlinuz(cfg: Config) -> Path: vmlinuz_dir = cfg.kernel_output candidates = sorted(vmlinuz_dir.glob("vmlinuz-*")) if vmlinuz_dir.is_dir() else [] if not candidates: - _log.err(f"No vmlinuz found in {vmlinuz_dir}") - _log.err("Build the kernel first: ./build.py kernel") + log.error("No vmlinuz found in %s", vmlinuz_dir) + log.error("Build the kernel first: ./build.py kernel") raise SystemExit(1) return candidates[0] @@ -54,8 +54,8 @@ def _find_initramfs(cfg: Config) -> Path: """Locate the initramfs CPIO image.""" cpio_files = sorted(cfg.initramfs_output.glob("*.cpio*")) if not cpio_files: - _log.err(f"No initramfs CPIO found in {cfg.initramfs_output}") - _log.err("Build the initramfs first: ./build.py initramfs") + log.error("No initramfs CPIO found in %s", cfg.initramfs_output) + log.error("Build the initramfs first: ./build.py initramfs") raise SystemExit(1) return cpio_files[0] @@ -80,10 +80,9 @@ def build(cfg: Config) -> None: grub_platform = _GRUB_PLATFORM.get(cfg.arch) if grub_platform is None: - _log.err(f"Unsupported architecture for ISO build: {cfg.arch}") + log.error("Unsupported architecture for ISO build: %s", cfg.arch) raise SystemExit(1) - # Prepare staging directory staging = cfg.iso_staging if staging.exists(): shutil.rmtree(staging) @@ -91,23 +90,20 @@ def build(cfg: Config) -> None: boot_dir = ensure_dir(staging / "boot") grub_dir = ensure_dir(boot_dir / "grub") - _log.log(f"Staging ISO filesystem at {staging}") + log.info("Staging ISO filesystem at %s", staging) - # Copy kernel and initramfs shutil.copy2(vmlinuz, boot_dir / "vmlinuz") shutil.copy2(initramfs, boot_dir / "initramfs") - # Write GRUB configuration (grub_dir / "grub.cfg").write_text(_grub_cfg(cfg.arch)) - # Build the ISO iso_dir = ensure_dir(cfg.iso_output) iso_path = iso_dir / f"captainos-{cfg.kernel_version}-{cfg.arch_info.output_arch}.iso" - _log.log(f"Building ISO with grub-mkrescue ({grub_platform})...") + log.info("Building ISO with grub-mkrescue (%s)...", grub_platform) grub_mkrescue = shutil.which("grub-mkrescue") if grub_mkrescue is None: - _log.err("grub-mkrescue not found. Install grub-common or use ISO_MODE=docker.") + log.error("grub-mkrescue not found. Install grub-common or use ISO_MODE=docker.") raise SystemExit(1) run( @@ -121,4 +117,4 @@ def build(cfg: Config) -> None: ) size_mb = iso_path.stat().st_size / (1024 * 1024) - _log.log(f"ISO created: {iso_path} ({size_mb:.1f}M)") + log.info("ISO created: %s (%.1fM)", iso_path, size_mb) diff --git a/captain/kernel.py b/captain/kernel.py index 9aceba3..a528b0c 100644 --- a/captain/kernel.py +++ b/captain/kernel.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging import os import re import shutil @@ -16,80 +17,71 @@ import urllib.request from pathlib import Path +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TextColumn, + TimeRemainingColumn, + TransferSpeedColumn, +) + +from captain import console from captain.config import Config -from captain.log import for_stage from captain.util import ensure_dir, run, safe_extractall -_log = for_stage("kernel") +log = logging.getLogger(__name__) _DOWNLOAD_TIMEOUT = 60 # seconds -def _urlretrieve_with_timeout( - url: str, - filename: Path | str, - *, - reporthook: object = None, - timeout: int = _DOWNLOAD_TIMEOUT, -) -> None: - """Like urllib.request.urlretrieve but with a socket timeout.""" +def _download_with_progress(url: str, filename: Path) -> None: + """Download *url* to *filename* with a Rich progress bar.""" req = urllib.request.Request(url) - with urllib.request.urlopen(req, timeout=timeout) as resp: - headers = resp.info() - total = int(headers.get("Content-Length", -1)) - block_size = 8192 - block_num = 0 - with open(filename, "wb") as out: - while True: - buf = resp.read(block_size) - if not buf: - break - out.write(buf) - block_num += 1 - if reporthook is not None: - reporthook(block_num, block_size, total) # type: ignore[operator] - - -def _progress_hook(block_num: int, block_size: int, total_size: int) -> None: - """Simple download progress indicator.""" - downloaded = block_num * block_size - if total_size > 0: - pct = min(100, downloaded * 100 // total_size) - mb = downloaded / (1024 * 1024) - total_mb = total_size / (1024 * 1024) - print(f"\r {mb:.1f}/{total_mb:.1f} MB ({pct}%)", end="", flush=True) - else: - mb = downloaded / (1024 * 1024) - print(f"\r {mb:.1f} MB", end="", flush=True) + with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp: + total = int(resp.headers.get("Content-Length", 0)) or None + with Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task(" Downloading", total=total) + with open(filename, "wb") as out: + while True: + buf = resp.read(8192) + if not buf: + break + out.write(buf) + progress.update(task, advance=len(buf)) def download_kernel(version: str, dest_dir: Path) -> Path: """Download and extract a kernel tarball. Returns the source directory.""" src_dir = dest_dir / f"linux-{version}" if src_dir.is_dir(): - _log.log(f"Using cached kernel source at {src_dir}") + log.info("Using cached kernel source at %s", src_dir) return src_dir major = version.split(".")[0] url = f"https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{version}.tar.xz" tarball = dest_dir / f"linux-{version}.tar.xz" - _log.log(f"Downloading kernel {version}...") - _log.log(f" URL: {url}") + log.info("Downloading kernel %s...", version) + log.info(" URL: %s", url) ensure_dir(dest_dir) try: - _urlretrieve_with_timeout(url, tarball, reporthook=_progress_hook) + _download_with_progress(url, tarball) except urllib.error.HTTPError as exc: - print() # newline after progress - _log.err(f"Download failed: {exc} — {url}") + log.error("Download failed: %s — %s", exc, url) raise SystemExit(1) from None except urllib.error.URLError as exc: - print() # newline after progress - _log.err(f"Download failed: {exc.reason} — {url}") + log.error("Download failed: %s — %s", exc.reason, url) raise SystemExit(1) from None - print() # newline after progress - _log.log("Extracting kernel source...") + log.info("Extracting kernel source...") with tarfile.open(tarball, "r:xz") as tf: safe_extractall(tf, path=dest_dir) tarball.unlink() @@ -98,31 +90,23 @@ def download_kernel(version: str, dest_dir: Path) -> Path: def _kernel_branch(version: str) -> str: - """Derive the stable branch prefix from a full kernel version. - - ``"6.18.16"`` → ``"6.18.y"`` - """ + """Derive the stable branch prefix from a full kernel version.""" parts = version.split(".") if len(parts) < 2: - _log.err(f"Invalid kernel version format: {version}") + log.error("Invalid kernel version format: %s", version) raise SystemExit(1) return f"{parts[0]}.{parts[1]}.y" def _find_defconfig(cfg: Config) -> Path: - """Locate the defconfig for the current kernel version and architecture. - - When ``cfg.kernel_config`` is set, that path is used directly. - Otherwise returns ``kernel.configs/{major}.{minor}.y.{arch}``. - Exits with a helpful error if no matching config file is found. - """ + """Locate the defconfig for the current kernel version and architecture.""" if cfg.kernel_config: explicit = Path(cfg.kernel_config) if not explicit.is_absolute(): explicit = cfg.project_dir / explicit if explicit.is_file(): return explicit - _log.err(f"Kernel config not found: {explicit}") + log.error("Kernel config not found: %s", explicit) raise SystemExit(1) ai = cfg.arch_info @@ -131,7 +115,6 @@ def _find_defconfig(cfg: Config) -> Path: if defconfig.is_file(): return defconfig - # List available branches for a helpful error message. configs_dir = cfg.project_dir / "kernel.configs" available = sorted( { @@ -141,10 +124,13 @@ def _find_defconfig(cfg: Config) -> Path: } ) avail_str = ", ".join(available) if available else "(none)" - _log.err( - f"No kernel config found for {branch} on {ai.arch}\n" - f" Expected: {defconfig}\n" - f" Available branches for {ai.arch}: {avail_str}" + log.error( + "No kernel config found for %s on %s\n Expected: %s\n Available branches for %s: %s", + branch, + ai.arch, + defconfig, + ai.arch, + avail_str, ) raise SystemExit(1) @@ -158,18 +144,16 @@ def configure_kernel(cfg: Config, src_dir: Path) -> None: if ai.cross_compile: make_env["CROSS_COMPILE"] = ai.cross_compile - _log.log(f"Using defconfig: {defconfig}") + log.info("Using defconfig: %s", defconfig) shutil.copy2(defconfig, src_dir / ".config") run(["make", "olddefconfig"], env=make_env, cwd=src_dir) - # Save the resolved config for debugging branch = _kernel_branch(cfg.kernel_version) resolved = cfg.project_dir / "kernel.configs" / f".config.resolved.{branch}.{ai.arch}" shutil.copy2(src_dir / ".config", resolved) - _log.log(f"Resolved config saved to kernel.configs/.config.resolved.{branch}.{ai.arch}") + log.info("Resolved config saved to kernel.configs/.config.resolved.%s.%s", branch, ai.arch) - # Increase COMMAND_LINE_SIZE on x86_64 (Tinkerbell needs large cmdlines) if ai.kernel_arch == "x86_64": - _log.log("Increasing COMMAND_LINE_SIZE to 4096 (x86_64)...") + log.info("Increasing COMMAND_LINE_SIZE to 4096 (x86_64)...") setup_h = src_dir / "arch" / "x86" / "include" / "asm" / "setup.h" text = setup_h.read_text() new_text = re.sub( @@ -178,7 +162,9 @@ def configure_kernel(cfg: Config, src_dir: Path) -> None: text, ) if new_text == text: - _log.warn("COMMAND_LINE_SIZE patch did not match — the kernel default may have changed") + log.warning( + "COMMAND_LINE_SIZE patch did not match — the kernel default may have changed" + ) setup_h.write_text(new_text) @@ -191,14 +177,13 @@ def build_kernel(cfg: Config, src_dir: Path) -> str: if ai.cross_compile: make_env["CROSS_COMPILE"] = ai.cross_compile - _log.log(f"Building kernel with {nproc} jobs...") + log.info("Building kernel with %d jobs...", nproc) run( ["make", f"-j{nproc}", ai.image_target, "modules"], env=make_env, cwd=src_dir, ) - # Determine actual kernel version from build result = run( ["make", "-s", "kernelrelease"], env={"ARCH": ai.kernel_arch}, @@ -206,7 +191,7 @@ def build_kernel(cfg: Config, src_dir: Path) -> str: cwd=src_dir, ) built_kver = result.stdout.strip() - _log.log(f"Built kernel version: {built_kver}") + log.info("Built kernel version: %s", built_kver) return built_kver @@ -219,35 +204,26 @@ def install_kernel(cfg: Config, src_dir: Path, built_kver: str) -> None: if ai.cross_compile: make_env["CROSS_COMPILE"] = ai.cross_compile - # Install modules into the modules subtree. - # make modules_install writes to {INSTALL_MOD_PATH}/lib/modules/{kver}/. - _log.log("Installing modules...") + log.info("Installing modules...") run( ["make", f"INSTALL_MOD_PATH={modules_root}", "modules_install"], env=make_env, cwd=src_dir, ) - # Strip debug symbols from modules - _log.log("Stripping debug symbols from modules...") + log.info("Stripping debug symbols from modules...") strip_cmd = f"{ai.strip_prefix}strip" for ko in modules_root.rglob("*.ko"): run([strip_cmd, "--strip-unneeded", str(ko)], check=False) - # Compress modules with zstd (the defconfig sets CONFIG_MODULE_COMPRESS_ZSTD - # and CONFIG_MODULE_DECOMPRESS so the kernel can load .ko.zst at runtime). - # We compress explicitly here because the build container's modules_install - # may not always invoke zstd, and stripping must happen before compression. - _log.log("Compressing kernel modules with zstd...") + log.info("Compressing kernel modules with zstd...") for ko in modules_root.rglob("*.ko"): run(["zstd", "--rm", "-q", "-19", str(ko)], check=True) - # Clean up build/source symlinks mod_base = modules_root / "lib" / "modules" / built_kver (mod_base / "build").unlink(missing_ok=True) (mod_base / "source").unlink(missing_ok=True) - # Move modules from /lib/modules to /usr/lib/modules (merged-usr) usr_moddir = ensure_dir(modules_root / "usr" / "lib" / "modules" / built_kver) if mod_base.is_dir(): for item in mod_base.iterdir(): @@ -258,50 +234,41 @@ def install_kernel(cfg: Config, src_dir: Path, built_kver: str) -> None: else: dest.unlink() shutil.move(str(item), str(dest)) - # Remove /lib tree shutil.rmtree(modules_root / "lib", ignore_errors=True) - # Regenerate module dependency metadata for the compressed .ko.zst files. - _log.log("Running depmod for compressed modules...") + log.info("Running depmod for compressed modules...") run( ["depmod", "-a", "-b", str(modules_root / "usr"), built_kver], check=True, ) - # Place vmlinuz alongside modules under kernel_output. iPXE loads - # the kernel image separately — it must NOT end up in the initramfs. kernel_image = src_dir / ai.kernel_image_path vmlinuz_dir = ensure_dir(cfg.kernel_output) - # Remove stale vmlinuz images from prior builds so artifact collection - # never picks an outdated kernel. for old in vmlinuz_dir.glob("vmlinuz-*"): old.unlink(missing_ok=True) shutil.copy2(kernel_image, vmlinuz_dir / f"vmlinuz-{built_kver}") - _log.log("Kernel build complete:") + log.info("Kernel build complete:") vmlinuz = vmlinuz_dir / f"vmlinuz-{built_kver}" vmlinuz_size = vmlinuz.stat().st_size / (1024 * 1024) - _log.log(f" Image: {vmlinuz} ({vmlinuz_size:.1f}M)") - _log.log(f" Modules: {usr_moddir}/") - _log.log(f" Version: {built_kver}") - _log.log(f" Output: {cfg.kernel_output}") + log.info(" Image: %s (%.1fM)", vmlinuz, vmlinuz_size) + log.info(" Modules: %s/", usr_moddir) + log.info(" Version: %s", built_kver) + log.info(" Output: %s", cfg.kernel_output) def build(cfg: Config) -> None: """Full kernel build pipeline — download, configure, build, install.""" - # Clean previous kernel output to ensure idempotency. - # Only the kernel directory is wiped — tools are left intact. if cfg.kernel_output.exists(): shutil.rmtree(cfg.kernel_output) ensure_dir(cfg.kernel_output) build_dir = Path("/var/tmp/kernel-build") - # Obtain kernel source if cfg.kernel_src and Path(cfg.kernel_src).is_dir(): - _log.log(f"Using provided kernel source at {cfg.kernel_src}") + log.info("Using provided kernel source at %s", cfg.kernel_src) src_dir = Path(cfg.kernel_src) else: src_dir = download_kernel(cfg.kernel_version, build_dir) diff --git a/captain/log.py b/captain/log.py deleted file mode 100644 index c0439b5..0000000 --- a/captain/log.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Colored logging helpers matching the original build.sh output style. - -Use :func:`for_stage` to create a stage-scoped logger whose prefix -includes the stage name (e.g. ``[captainos-kernel]``). The module-level -:func:`log`, :func:`warn`, and :func:`err` convenience functions use a -plain ``[captainos]`` prefix for cross-cutting messages. -""" - -from __future__ import annotations - -import sys - -RED = "\033[0;31m" -GREEN = "\033[0;32m" -YELLOW = "\033[1;33m" -NC = "\033[0m" - - -class StageLogger: - """Logger that tags output with an optional stage name.""" - - __slots__ = ("_prefix",) - - def __init__(self, stage: str = "") -> None: - tag = f"captainos-{stage}" if stage else "captainos" - self._prefix = f"[{tag}]" - - def log(self, *args: object) -> None: - print(f"{GREEN}{self._prefix}{NC}", *args, flush=True) - - def warn(self, *args: object) -> None: - print(f"{YELLOW}{self._prefix}{NC}", *args, flush=True) - - def err(self, *args: object) -> None: - print(f"{RED}{self._prefix}{NC}", *args, file=sys.stderr, flush=True) - - -def for_stage(stage: str) -> StageLogger: - """Return a :class:`StageLogger` whose prefix includes *stage*.""" - return StageLogger(stage) - - -# Module-level convenience functions (un-staged [captainos] prefix). -_default = StageLogger() - -log = _default.log -warn = _default.warn -err = _default.err diff --git a/captain/oci/_build.py b/captain/oci/_build.py index 657f451..4b0a22b 100644 --- a/captain/oci/_build.py +++ b/captain/oci/_build.py @@ -3,15 +3,17 @@ from __future__ import annotations import contextlib +import logging import shutil import tarfile from datetime import datetime from pathlib import Path from captain import artifacts, buildah -from captain.log import StageLogger from captain.util import get_arch_info +log = logging.getLogger(__name__) + def _deterministic_tar(file_path: Path, output_dir: Path) -> Path: """Create a tar containing a single file with deterministic metadata. @@ -39,7 +41,6 @@ def _collect_arch_artifacts( out: Path, arch: str, kernel_version: str, - logger: StageLogger, ) -> list[Path]: """Collect and return the artifact files for a single architecture. @@ -52,9 +53,9 @@ def _collect_arch_artifacts( vmlinuz_dst = out / f"vmlinuz-{kernel_version}-{oarch}" if vmlinuz_files: shutil.copy2(vmlinuz_files[0], vmlinuz_dst) - logger.log(f"kernel: {vmlinuz_dst}") + log.info("kernel: %s", vmlinuz_dst) else: - logger.warn(f"No kernel image found for {arch}") + log.warning("No kernel image found for %s", arch) arch_files = [ out / f"vmlinuz-{kernel_version}-{oarch}", @@ -62,12 +63,12 @@ def _collect_arch_artifacts( out / f"captainos-{kernel_version}-{oarch}.iso", ] checksums_path = out / f"sha256sums-{kernel_version}-{oarch}.txt" - artifacts.collect_checksums(arch_files, checksums_path, logger=logger) + artifacts.collect_checksums(arch_files, checksums_path) push_files = [*arch_files, checksums_path] for f in push_files: if not f.is_file(): - logger.err(f"Missing artifact: {f}") + log.error("Missing artifact: %s", f) raise SystemExit(1) return push_files @@ -77,7 +78,6 @@ def _build_platform_image( platform: str, sha: str, repository: str, - logger: StageLogger, *, created: str, tag: str, @@ -118,8 +118,8 @@ def _build_platform_image( intermediates: list[str] = [] for i, tar_path in enumerate(layer_tars): is_last = i == len(layer_tars) - 1 - ctr = buildah.from_image(current, platform=platform, logger=logger) - buildah.add(ctr, [tar_path], logger=logger) + ctr = buildah.from_image(current, platform=platform) + buildah.add(ctr, [tar_path]) if is_last: buildah.config( ctr, @@ -127,15 +127,14 @@ def _build_platform_image( arch=arch, annotations=oci_metadata, labels=oci_metadata, - logger=logger, ) prev = current - current = buildah.commit(ctr, timestamp=epoch, logger=logger) + current = buildah.commit(ctr, timestamp=epoch) if prev != base: intermediates.append(prev) for img in intermediates: with contextlib.suppress(Exception): - buildah.rmi(img, logger=logger) + buildah.rmi(img) return current diff --git a/captain/oci/_common.py b/captain/oci/_common.py index 4bc4a8d..9bb5afc 100644 --- a/captain/oci/_common.py +++ b/captain/oci/_common.py @@ -5,10 +5,6 @@ import subprocess from pathlib import Path -from captain.log import for_stage - -_default_log = for_stage("release") - _ARCHES = ("amd64", "arm64") diff --git a/captain/oci/_publish.py b/captain/oci/_publish.py index 75bd9fc..0bf7941 100644 --- a/captain/oci/_publish.py +++ b/captain/oci/_publish.py @@ -3,23 +3,24 @@ from __future__ import annotations import contextlib +import logging from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 from captain import buildah, skopeo from captain.config import Config -from captain.log import StageLogger from captain.util import ensure_dir from ._build import _build_platform_image, _collect_arch_artifacts, _deterministic_tar -from ._common import _ARCHES, _default_log, _image_ref +from ._common import _ARCHES, _image_ref + +log = logging.getLogger(__name__) def _create_push_cleanup( image_ids: list[str], dest_ref: str, - logger: StageLogger, ) -> None: """Create a manifest list from *image_ids*, push it to *dest_ref*, and clean up. @@ -30,17 +31,17 @@ def _create_push_cleanup( temp_name = f"captain-local-{uuid4().hex[:12]}" manifest_id: str | None = None try: - manifest_id = buildah.manifest_create(temp_name, logger=logger) + manifest_id = buildah.manifest_create(temp_name) for image_id in image_ids: - buildah.manifest_add(manifest_id, image_id, logger=logger) - buildah.manifest_push(manifest_id, dest_ref, logger=logger) + buildah.manifest_add(manifest_id, image_id) + buildah.manifest_push(manifest_id, dest_ref) finally: if manifest_id is not None: with contextlib.suppress(Exception): - buildah.rmi(manifest_id, logger=logger) + buildah.rmi(manifest_id) for image_id in image_ids: with contextlib.suppress(Exception): - buildah.rmi(image_id, logger=logger) + buildah.rmi(image_id) def _publish_single_arch( @@ -52,7 +53,6 @@ def _publish_single_arch( repository: str, artifact_name: str, created: str, - logger: StageLogger, ) -> None: """Build a per-arch multi-arch index and push it. @@ -66,14 +66,13 @@ def _publish_single_arch( f"linux/{platform_arch}", sha, repository, - logger, created=created, tag=tag, artifact_name=artifact_name, ) image_ids.append(image_id) - _create_push_cleanup(image_ids, ref, logger) + _create_push_cleanup(image_ids, ref) def _publish_combined( @@ -86,7 +85,6 @@ def _publish_combined( sha: str, created: str, force: bool = False, - logger: StageLogger, ) -> bool: """Build and push the combined multi-arch image. @@ -104,19 +102,20 @@ def _publish_combined( combined_ref = _image_ref(registry, repository, artifact_name, tag) # Skip if the combined image already exists. - if not force and skopeo.image_exists(combined_ref, logger=logger): - logger.log(f"{combined_ref} already exists — skipping (use --force to overwrite)") + if not force and skopeo.image_exists(combined_ref): + log.info("%s already exists — skipping (use --force to overwrite)", combined_ref) return False # Ensure per-arch images exist in the registry. for arch in _ARCHES: per_arch_tag = f"{tag}-{arch}" per_arch_ref = _image_ref(registry, repository, artifact_name, per_arch_tag) - if skopeo.image_exists(per_arch_ref, logger=logger): - logger.log(f"Found {per_arch_ref} in registry — will reuse layers for combined image") + if skopeo.image_exists(per_arch_ref): + log.info("Found %s in registry — will reuse layers for combined image", per_arch_ref) else: - logger.log( - f"{per_arch_ref} not found in registry — building and pushing before combined image" + log.info( + "%s not found in registry — building and pushing before combined image", + per_arch_ref, ) _publish_single_arch( layer_tars=arch_layer_tars[arch], @@ -126,7 +125,6 @@ def _publish_combined( repository=repository, artifact_name=artifact_name, created=created, - logger=logger, ) # Build the combined image using per-arch registry images as bases. @@ -140,7 +138,6 @@ def _publish_combined( f"linux/{arch}", sha, repository, - logger, created=created, tag=tag, artifact_name=artifact_name, @@ -148,7 +145,7 @@ def _publish_combined( ) image_ids.append(image_id) - _create_push_cleanup(image_ids, combined_ref, logger) + _create_push_cleanup(image_ids, combined_ref) return True @@ -162,7 +159,6 @@ def publish( tag: str, sha: str, force: bool = False, - logger: StageLogger | None = None, ) -> None: """Collect artifacts and publish a multi-arch OCI index. @@ -177,15 +173,14 @@ def publish( (unless *force* is ``True``). For per-arch targets this prevents overwriting images that the combined image depends on. """ - _log = logger or _default_log arches = list(_ARCHES) if target == "combined" else [target] tag_suffix = "" if target == "combined" else f"-{target}" full_tag = f"{tag}{tag_suffix}" final_ref = _image_ref(registry, repository, artifact_name, full_tag) # For per-arch targets, skip if the image already exists. - if target != "combined" and not force and skopeo.image_exists(final_ref, logger=_log): - _log.log(f"{final_ref} already exists — skipping (use --force to overwrite)") + if target != "combined" and not force and skopeo.image_exists(final_ref): + log.info("%s already exists — skipping (use --force to overwrite)", final_ref) return out = ensure_dir(cfg.output_dir) @@ -199,7 +194,6 @@ def publish( out, arch, cfg.kernel_version, - _log, ) # Create deterministic layer tars (shared across manifest pushes). @@ -219,7 +213,6 @@ def publish( sha=sha, created=created, force=force, - logger=_log, ) else: _publish_single_arch( @@ -230,7 +223,6 @@ def publish( repository=repository, artifact_name=artifact_name, created=created, - logger=_log, ) finally: for tars in arch_layer_tars.values(): @@ -245,12 +237,12 @@ def publish( for arch in arches: artifact_names.extend(f.name for f in arch_files.get(arch, [])) platforms = [f"linux/{a}" for a in _ARCHES] - _log.log("") - _log.log("Publish complete") - _log.log(f" Image: {final_ref}") - _log.log(f" Target: {target}") - _log.log(f" Platforms: {', '.join(platforms)}") - _log.log(f" Layers: {len(artifact_names)}") - _log.log(" Artifacts:") + log.info("") + log.info("Publish complete") + log.info(" Image: %s", final_ref) + log.info(" Target: %s", target) + log.info(" Platforms: %s", ", ".join(platforms)) + log.info(" Layers: %d", len(artifact_names)) + log.info(" Artifacts:") for name in artifact_names: - _log.log(f" - {name}") + log.info(" - %s", name) diff --git a/captain/oci/_pull.py b/captain/oci/_pull.py index 75860e0..13165f6 100644 --- a/captain/oci/_pull.py +++ b/captain/oci/_pull.py @@ -2,12 +2,14 @@ from __future__ import annotations +import logging from pathlib import Path from captain import skopeo -from captain.log import StageLogger -from ._common import _ARCHES, _default_log, _image_ref +from ._common import _ARCHES, _image_ref + +log = logging.getLogger(__name__) def pull( @@ -18,7 +20,6 @@ def pull( tag: str, target: str, output_dir: Path, - logger: StageLogger | None = None, ) -> None: """Pull and extract OCI artifacts. @@ -26,20 +27,19 @@ def pull( suffix is ``-{target}`` for single architectures, or bare ``{tag}`` for ``"combined"``. """ - _log = logger or _default_log tag_suffix = "" if target == "combined" else f"-{target}" ref = _image_ref(registry, repository, artifact_name, f"{tag}{tag_suffix}") - skopeo.export_image(ref, output_dir, logger=_log) + skopeo.export_image(ref, output_dir) # Recap extracted = sorted(f.name for f in Path(output_dir).iterdir() if f.is_file()) - _log.log("") - _log.log("Pull complete") - _log.log(f" Image: {ref}") - _log.log(f" Target: {target}") - _log.log(" Artifacts:") + log.info("") + log.info("Pull complete") + log.info(" Image: %s", ref) + log.info(" Target: %s", target) + log.info(" Artifacts:") for name in extracted: - _log.log(f" - {name}") + log.info(" - %s", name) def tag_image( @@ -49,14 +49,12 @@ def tag_image( artifact_name: str, src_tag: str, new_tag: str, - logger: StageLogger | None = None, ) -> None: """Tag an existing OCI artifact image with a new version.""" - _log = logger or _default_log src_ref = _image_ref(registry, repository, artifact_name, src_tag) dest_ref = _image_ref(registry, repository, artifact_name, new_tag) - skopeo.copy(src_ref, dest_ref, logger=_log) - _log.log(f"Tagged {src_ref} → {new_tag}") + skopeo.copy(src_ref, dest_ref) + log.info("Tagged %s → %s", src_ref, new_tag) def tag_all( @@ -67,10 +65,8 @@ def tag_all( src_tag: str, new_tag: str, arches: list[str] | None = None, - logger: StageLogger | None = None, ) -> None: """Tag all artifact images (per-arch + combined) with a new version.""" - _log = logger or _default_log arches = arches or list(_ARCHES) for a in arches: tag_image( @@ -79,7 +75,6 @@ def tag_all( artifact_name=artifact_name, src_tag=f"{src_tag}-{a}", new_tag=f"{new_tag}-{a}", - logger=_log, ) # Tag the combined image (no arch suffix). tag_image( @@ -88,14 +83,13 @@ def tag_all( artifact_name=artifact_name, src_tag=src_tag, new_tag=new_tag, - logger=_log, ) # Recap image = f"{registry}/{repository}/{artifact_name}" - _log.log("") - _log.log("Tag complete") - _log.log(f" Image: {image}") + log.info("") + log.info("Tag complete") + log.info(" Image: %s", image) for a in arches: - _log.log(f" {src_tag}-{a} → {new_tag}-{a}") - _log.log(f" {src_tag} → {new_tag}") + log.info(" %s-%s → %s-%s", src_tag, a, new_tag, a) + log.info(" %s → %s", src_tag, new_tag) diff --git a/captain/qemu.py b/captain/qemu.py index 942cf93..d8af60f 100644 --- a/captain/qemu.py +++ b/captain/qemu.py @@ -3,13 +3,13 @@ from __future__ import annotations import argparse +import logging import sys from captain.config import Config -from captain.log import for_stage from captain.util import run -_log = for_stage("qemu") +log = logging.getLogger(__name__) # Tinkerbell kernel cmdline parameters. # Maps the argparse dest name → kernel cmdline key. @@ -40,9 +40,9 @@ def _tink_cmdline(args: argparse.Namespace) -> str: # Kernel cmdline is space-delimited; whitespace in values would # split them into multiple arguments and silently change meaning. if any(ch.isspace() for ch in value): - _log.err( - f"--{attr.replace('_', '-')} must not contain whitespace; " - "cannot safely add it to the kernel cmdline." + log.error( + "--%s must not contain whitespace; cannot safely add it to the kernel cmdline.", + attr.replace("_", "-"), ) sys.exit(1) parts.append(f"{cmdline_key}={value}") @@ -51,7 +51,7 @@ def _tink_cmdline(args: argparse.Namespace) -> str: ipam = getattr(args, "ipam", "") or "" if ipam: if any(ch.isspace() for ch in ipam): - _log.err("--ipam must not contain whitespace.") + log.error("--ipam must not contain whitespace.") sys.exit(1) parts.append(f"ipam={ipam}") @@ -74,27 +74,27 @@ def run_qemu(cfg: Config, args: argparse.Namespace | None = None) -> None: if not initrd.is_file(): missing.append(str(initrd)) if missing: - _log.err("Build artifacts not found:") + log.error("Build artifacts not found:") for m in missing: - _log.err(f" {m}") - _log.err(f"Run './build.py --kernel-version {cfg.kernel_version}' first.") + log.error(" %s", m) + log.error("Run './build.py --kernel-version %s' first.", cfg.kernel_version) sys.exit(1) tink = _tink_cmdline(args) if args is not None else "" if args is not None and not any( getattr(args, v, None) for v in ("tink_worker_image", "tink_docker_registry") ): - _log.warn( + log.warning( "Neither --tink-worker-image nor --tink-docker-registry is set. " "tink-agent services will not start." ) - _log.log("Booting CaptainOS in QEMU (Ctrl-A X to exit)...") + log.info("Booting CaptainOS in QEMU (Ctrl-A X to exit)...") qemu_cmd = cfg.arch_info.qemu_binary append = f"console=ttyS0 audit=0 {tink} {cfg.qemu_append}".strip() - _log.log(f"Kernel cmdline: {append}") + log.info("Kernel cmdline: %s", append) run( [ qemu_cmd, diff --git a/captain/skopeo.py b/captain/skopeo.py index be535de..a3b5712 100644 --- a/captain/skopeo.py +++ b/captain/skopeo.py @@ -8,23 +8,18 @@ from __future__ import annotations import json +import logging import tarfile from pathlib import Path -from captain.log import StageLogger, for_stage from captain.util import run, safe_extractall -_default_log = for_stage("skopeo") +log = logging.getLogger(__name__) -def image_exists( - image_ref: str, - *, - logger: StageLogger | None = None, -) -> bool: +def image_exists(image_ref: str) -> bool: """Return ``True`` if *image_ref* exists in the remote registry.""" - _log = logger or _default_log - _log.log(f"Checking registry for {image_ref}") + log.info("Checking registry for %s", image_ref) result = run( ["skopeo", "inspect", f"docker://{image_ref}"], capture=True, @@ -33,14 +28,9 @@ def image_exists( return result.returncode == 0 -def inspect_digest( - image_ref: str, - *, - logger: StageLogger | None = None, -) -> str: +def inspect_digest(image_ref: str) -> str: """Return the manifest digest (``sha256:…``) of *image_ref*.""" - _log = logger or _default_log - _log.log(f"skopeo inspect digest {image_ref}") + log.info("skopeo inspect digest %s", image_ref) result = run( [ "skopeo", @@ -54,12 +44,7 @@ def inspect_digest( return result.stdout.strip() -def copy( - src: str, - dest: str, - *, - logger: StageLogger | None = None, -) -> None: +def copy(src: str, dest: str) -> None: """Copy an image from *src* to *dest*. *src* and *dest* are plain image references (e.g. @@ -67,8 +52,7 @@ def copy( added automatically. Typically used for retagging: the source and destination differ only in the tag component. """ - _log = logger or _default_log - _log.log(f"skopeo copy {src} → {dest}") + log.info("skopeo copy %s → %s", src, dest) run(["skopeo", "copy", "--all", f"docker://{src}", f"docker://{dest}"]) @@ -77,7 +61,6 @@ def copy_to_dir( output_dir: Path, *, platform: str | None = None, - logger: StageLogger | None = None, ) -> Path: """Download *image_ref* to a local directory. @@ -86,7 +69,6 @@ def copy_to_dir( Returns *output_dir*. """ - _log = logger or _default_log output_dir.mkdir(parents=True, exist_ok=True) cmd: list[str] = ["skopeo", "copy"] if platform: @@ -94,7 +76,7 @@ def copy_to_dir( if len(parts) == 2: cmd += ["--override-os", parts[0], "--override-arch", parts[1]] cmd += [f"docker://{image_ref}", f"dir:{output_dir}"] - _log.log(f"skopeo copy {image_ref} → dir:{output_dir}") + log.info("skopeo copy %s → dir:%s", image_ref, output_dir) run(cmd) return output_dir @@ -104,7 +86,6 @@ def export_image( output_dir: Path, *, platform: str | None = None, - logger: StageLogger | None = None, ) -> None: """Download and extract all layers from *image_ref* into *output_dir*. @@ -114,12 +95,11 @@ def export_image( """ import tempfile - _log = logger or _default_log output_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="skopeo-export-") as tmp: tmp_dir = Path(tmp) - copy_to_dir(image_ref, tmp_dir, platform=platform, logger=_log) + copy_to_dir(image_ref, tmp_dir, platform=platform) # Parse manifest to find layer blob digests. manifest_path = tmp_dir / "manifest.json" @@ -137,6 +117,6 @@ def export_image( if not blob_file.exists(): raise FileNotFoundError(f"Layer blob not found: {digest_str}") - _log.log(f"Extracting layer {digest_str[:20]}…") + log.info("Extracting layer %s…", digest_str[:20]) with tarfile.open(blob_file, "r:*") as tf: safe_extractall(tf, output_dir) diff --git a/captain/tools.py b/captain/tools.py index 315f04f..d4f6fac 100644 --- a/captain/tools.py +++ b/captain/tools.py @@ -8,6 +8,7 @@ from __future__ import annotations import io +import logging import os import stat import tarfile @@ -16,10 +17,9 @@ from pathlib import Path from captain.config import Config -from captain.log import for_stage from captain.util import ensure_dir, safe_extractall -_log = for_stage("tools") +log = logging.getLogger(__name__) @dataclass(slots=True) @@ -91,7 +91,7 @@ def _check_binary(dest_dir: Path, tool: ToolSpec) -> str | None: def _download_tarball(url: str, dest_dir: Path, members: list[str]) -> None: """Download a gzipped tarball and extract specific members.""" - _log.log(f" Downloading {url}") + log.info(" Downloading %s", url) with urllib.request.urlopen(url, timeout=60) as resp: data = resp.read() @@ -114,7 +114,7 @@ def _download_tarball(url: str, dest_dir: Path, members: list[str]) -> None: def _download_binary(url: str, dest: Path) -> None: """Download a single binary file.""" - _log.log(f" Downloading {url}") + log.info(" Downloading %s", url) with urllib.request.urlopen(url, timeout=60) as resp: dest.parent.mkdir(parents=True, exist_ok=True) with open(dest, "wb") as f: @@ -127,11 +127,11 @@ def download_tool(tool: ToolSpec, arch: str, output_base: Path, force: bool) -> dest_dir = ensure_dir(output_base / tool.dest) if not force and _check_binary(dest_dir, tool): - _log.log(f"{tool.name} already present (set FORCE_TOOLS=1 to re-download)") + log.info("%s already present (set FORCE_TOOLS=1 to re-download)", tool.name) return url = tool.url_template.format(version=tool.version, arch=arch) - _log.log(f"Installing {tool.name} {tool.version} ({arch})...") + log.info("Installing %s %s (%s)...", tool.name, tool.version, arch) if tool.members is not None: # Tarball with selective extraction @@ -145,16 +145,16 @@ def download_tool(tool: ToolSpec, arch: str, output_base: Path, force: bool) -> p = dest_dir / name if p.exists(): p.unlink() - _log.log(f" Removed leftover: {p.name}") + log.info(" Removed leftover: %s", p.name) # Report installed files if tool.members: for m in tool.members: p = dest_dir / m if p.exists(): - _log.log(f" {tool.name}: {p}") + log.info(" %s: %s", tool.name, p) elif tool.binary_name: - _log.log(f" {tool.name}: {dest_dir / tool.binary_name}") + log.info(" %s: %s", tool.name, dest_dir / tool.binary_name) def download_all(cfg: Config) -> None: @@ -165,9 +165,5 @@ def download_all(cfg: Config) -> None: for tool in TOOLS: download_tool(tool, arch, output_base, cfg.force_tools) - _log.log("Tool download complete.") - - # NOTE: UPX compression is not used. The initramfs is a zstd-compressed - # CPIO archive, and zstd compresses raw ELF binaries better than UPX-packed - # ones (UPX output looks like random data to zstd, defeating its compression). - _log.log("All tools ready.") + log.info("Tool download complete.") + log.info("All tools ready.") diff --git a/captain/util.py b/captain/util.py index 38c3c16..9876813 100644 --- a/captain/util.py +++ b/captain/util.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import sys @@ -9,7 +10,7 @@ from dataclasses import dataclass from pathlib import Path -from captain.log import err +log = logging.getLogger(__name__) @dataclass(slots=True) @@ -58,7 +59,7 @@ def get_arch_info(arch: str) -> ArchInfo: strip_prefix="aarch64-linux-gnu-", ) case _: - err(f"Unsupported architecture: {arch}") + log.error("Unsupported architecture: %s", arch) sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 2ab7dea..17aa984 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,11 @@ [project] +version = "0.0.1" name = "captain" -requires-python = ">=3.10" -dependencies = ["configargparse>=1.7"] +requires-python = ">=3.13" +dependencies = ["configargparse>=1.7", "rich>=14.3.3"] + +[project.optional-dependencies] +dev = ["ruff>=0.9", "pyright>=1.1"] [tool.ruff] target-version = "py310" @@ -22,6 +26,7 @@ select = [ "captain/iso.py" = ["E501"] # embedded grub config has long data lines [tool.pyright] -pythonVersion = "3.10" +pythonVersion = "3.13" typeCheckingMode = "standard" exclude = ["mkosi.tools", "mkosi.output", "__pycache__"] +include = ["captain", "build.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index fb5597f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -ruff>=0.9 -pyright>=1.1 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 595ad7c..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -configargparse>=1.7