diff --git a/.bootc-dev-infra-commit.txt b/.bootc-dev-infra-commit.txt new file mode 100644 index 000000000..439a61cec --- /dev/null +++ b/.bootc-dev-infra-commit.txt @@ -0,0 +1 @@ +56e4f615d38cc4a923f6a7e2a174a0c05a962451 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/common/.claude/CLAUDE.md b/.claude/CLAUDE.md similarity index 100% rename from common/.claude/CLAUDE.md rename to .claude/CLAUDE.md diff --git a/.devcontainer b/.devcontainer deleted file mode 120000 index 4a580c986..000000000 --- a/.devcontainer +++ /dev/null @@ -1 +0,0 @@ -common/.devcontainer \ No newline at end of file diff --git a/common/.devcontainer/debian/devcontainer.json b/.devcontainer/debian/devcontainer.json similarity index 100% rename from common/.devcontainer/debian/devcontainer.json rename to .devcontainer/debian/devcontainer.json diff --git a/common/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json similarity index 100% rename from common/.devcontainer/devcontainer.json rename to .devcontainer/devcontainer.json diff --git a/common/.devcontainer/ubuntu/devcontainer.json b/.devcontainer/ubuntu/devcontainer.json similarity index 100% rename from common/.devcontainer/ubuntu/devcontainer.json rename to .devcontainer/ubuntu/devcontainer.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1f5579978 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Exclude everything by default, then include just what we need +# Especially note this means that .git is not included, and not tests/ +# to avoid spurious rebuilds. +* + +# This one signals we're in a bootc toplevel +!ADOPTERS.md +# Toplevel build bits +!Makefile +!Cargo.* +# License and doc files needed for RPM +!LICENSE-* +!README.md +# We do build manpages from markdown +!docs/ +# We use the spec file +!contrib/ +# The systemd units and baseimage bits end up in installs +!systemd/ +!baseimage/ +# Workaround for podman bug with secrets + remote +# https://github.com/containers/podman/issues/25314 +!podman-build-secret* +# And finally of course all the Rust sources +!crates/ diff --git a/.fmf/version b/.fmf/version new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/.gemini b/.gemini deleted file mode 120000 index 5668b26af..000000000 --- a/.gemini +++ /dev/null @@ -1 +0,0 @@ -common/.gemini \ No newline at end of file diff --git a/common/.gemini/config.yaml b/.gemini/config.yaml similarity index 100% rename from common/.gemini/config.yaml rename to .gemini/config.yaml diff --git a/.github/actions b/.github/actions deleted file mode 120000 index 864d822be..000000000 --- a/.github/actions +++ /dev/null @@ -1 +0,0 @@ -common/.github/actions \ No newline at end of file diff --git a/.github/actions/bootc-ubuntu-setup/action.yml b/.github/actions/bootc-ubuntu-setup/action.yml new file mode 100644 index 000000000..8353a6e48 --- /dev/null +++ b/.github/actions/bootc-ubuntu-setup/action.yml @@ -0,0 +1,116 @@ +name: 'Bootc Ubuntu Setup' +description: 'Default host setup' +inputs: + libvirt: + description: 'Install libvirt and virtualization stack' + required: false + default: 'false' +runs: + using: 'composite' + steps: + # The default runners have TONS of crud on them... + - name: Free up disk space on runner + shell: bash + run: | + set -xeuo pipefail + sudo df -h + # Use globs for package patterns (apt and dpkg both support fnmatch globs) + unwanted_pkgs=('aspnetcore-*' 'dotnet-*' 'llvm-*' 'php*' 'mongodb-*' 'mysql-*' + azure-cli google-chrome-stable firefox mono-devel) + unwanted_dirs=(/usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL) + # Start background removal operations as systemd units; if this causes + # races in the future around disk space we can look at waiting for cleanup + # before starting further jobs, but right now we spent a lot of time waiting + # on the network and scripts and such below, giving these plenty of time to run. + n=0 + runcleanup() { + sudo systemd-run -r -u action-cleanup-${n} -- "$@" + n=$(($n + 1)) + } + runcleanup docker image prune --all --force + for x in ${unwanted_dirs[@]}; do + runcleanup rm -rf "$x" + done + # Apt removals in foreground, as we can't parallelize these. + # Only attempt removal if matching packages are installed. + for x in ${unwanted_pkgs[@]}; do + if dpkg -l "$x" >/dev/null 2>&1; then + /bin/time -f '%E %C' sudo apt-get remove -y "$x" + fi + done + # We really want support for heredocs + - name: Update podman and install just + shell: bash + run: | + set -eux + # Require the runner is ubuntu-24.04 + IDV=$(. /usr/lib/os-release && echo ${ID}-${VERSION_ID}) + test "${IDV}" = "ubuntu-24.04" + # plucky is the next release + # Ubuntu uses different mirrors for different architectures: + # - amd64: azure.archive.ubuntu.com/ubuntu (on Azure runners) + # - arm64: ports.ubuntu.com/ubuntu-ports + case "$(arch)" in + x86_64) + mirror="http://azure.archive.ubuntu.com/ubuntu" + ;; + aarch64) + mirror="http://ports.ubuntu.com/ubuntu-ports" + ;; + *) + echo "Unsupported architecture: $(arch)" >&2 + exit 1 + ;; + esac + echo "deb ${mirror} plucky universe main" | sudo tee /etc/apt/sources.list.d/plucky.list + /bin/time -f '%E %C' sudo apt update + # skopeo is currently older in plucky for some reason hence --allow-downgrades + /bin/time -f '%E %C' sudo apt install -y --allow-downgrades crun/plucky podman/plucky skopeo/plucky just + # This is the default on e.g. Fedora derivatives, but not Debian + - name: Enable unprivileged /dev/kvm access + shell: bash + run: | + set -xeuo pipefail + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + # Only trigger if /dev/kvm exists (may not be available on arm64 runners) + if [ -e /dev/kvm ]; then + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + else + echo "Note: /dev/kvm not available on this runner" + fi + # Used by a few workflows, but generally useful + - name: Set architecture variable + id: set_arch + shell: bash + run: echo "ARCH=$(arch)" >> $GITHUB_ENV + # Install libvirt stack if requested + - name: Install libvirt and virtualization stack + if: ${{ inputs.libvirt == 'true' }} + shell: bash + run: | + set -xeuo pipefail + export BCVK_VERSION=0.10.0 + # see https://github.com/bootc-dev/bcvk/issues/176 + /bin/time -f '%E %C' sudo apt install -y libkrb5-dev pkg-config libvirt-dev genisoimage qemu-utils qemu-kvm virtiofsd libvirt-daemon-system python3-virt-firmware + # Something in the stack is overriding this, but we want session right now for bcvk + echo LIBVIRT_DEFAULT_URI=qemu:///session >> $GITHUB_ENV + td=$(mktemp -d) + cd $td + # Install bcvk + target=bcvk-$(arch)-unknown-linux-gnu + /bin/time -f '%E %C' curl -LO https://github.com/bootc-dev/bcvk/releases/download/v${BCVK_VERSION}/${target}.tar.gz + tar xzf ${target}.tar.gz + sudo install -T ${target} /usr/bin/bcvk + cd - + rm -rf "$td" + + # Also bump the default fd limit as a workaround for https://github.com/bootc-dev/bcvk/issues/65 + sudo sed -i -e 's,^\* hard nofile 65536,* hard nofile 524288,' /etc/security/limits.conf + - name: Cleanup status + shell: bash + run: | + set -xeuo pipefail + systemctl list-units 'action-cleanup*' + df -h diff --git a/.github/auto-review-config.yml b/.github/auto-review-config.yml new file mode 100644 index 000000000..c1c5aafa6 --- /dev/null +++ b/.github/auto-review-config.yml @@ -0,0 +1,6 @@ +# Auto-reviewer configuration +# Start date for the rotation cycle (YYYY-MM-DD format) +start_date: "2025-08-04" + +# Rotation cycle in weeks +rotation_cycle_weeks: 3 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..b4365da59 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,25 @@ +# Maybe in the future we have a label-per-crate, but +# for now this is just a rough cut. + +area/documentation: +- changed-files: + - any-glob-to-any-file: + - 'docs/**' + - README.md + - CONTRIBUTING.md + +area/install: +- changed-files: + - any-glob-to-any-file: + - 'crates/lib/src/install.rs' + - 'crates/lib/src/install/**' + +area/system-reinstall-bootc: +- changed-files: + - any-glob-to-any-file: + - 'crates/system-reinstall-bootc/**' + +area/ostree: +- changed-files: + - any-glob-to-any-file: + - 'crates/ostree-ext/**' diff --git a/.github/scripts/assign_reviewer.py b/.github/scripts/assign_reviewer.py new file mode 100755 index 000000000..9a3b7a855 --- /dev/null +++ b/.github/scripts/assign_reviewer.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Auto-reviewer assignment script for GitHub PRs. +Rotates through a list of reviewers based on a weekly cycle. +Automatically reads reviewers from MAINTAINERS.md +""" + +import os +import sys +import yaml +import subprocess +import re +from datetime import datetime, timezone, timedelta +from typing import List, Optional, TypedDict + + +class RotationInfo(TypedDict): + start_date: Optional[datetime] + rotation_cycle_weeks: int + weeks_since_start: float + before_start: bool + error: Optional[str] + + +class SprintInfo(TypedDict): + sprint_number: int + week_in_sprint: int + total_weeks: int + before_start: Optional[bool] + + +def load_config(config_path: str) -> dict: + """Load the reviewer configuration from YAML file.""" + try: + with open(config_path, 'r') as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(f"Error: Configuration file {config_path} not found", file=sys.stderr) + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error parsing YAML configuration: {e}", file=sys.stderr) + sys.exit(1) + + +def extract_reviewers_from_maintainers() -> List[str]: + """Extract GitHub usernames from MAINTAINERS.md file by parsing the table structure.""" + maintainers_path = os.environ.get("MAINTAINERS_PATH", 'MAINTAINERS.md') + reviewers = [] + + try: + with open(maintainers_path, 'r') as f: + content = f.read() + + lines = content.splitlines() + github_id_column_index = None + in_table = False + + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Look for table header to find GitHub ID column + if line.startswith('|') and 'GitHub ID' in line: + in_table = True + columns = [col.strip() for col in line.split('|')[1:-1]] # Remove empty first/last elements + try: + github_id_column_index = columns.index('GitHub ID') + except ValueError: + print("Error: Could not find 'GitHub ID' column in MAINTAINERS.md table") + sys.exit(1) + continue + + # Skip separator line (|---|---|...) + if in_table and line.startswith('|') and '---' in line: + continue + + # Process table data rows + if line.startswith('|') and github_id_column_index is not None: + columns = [col.strip() for col in line.split('|')[1:-1]] + if len(columns) > github_id_column_index: + github_id_cell = columns[github_id_column_index] + match = re.search(r'\[([a-zA-Z0-9-]+)\]\(https://github\.com/[^)]+\)', github_id_cell) + if match: + username = match.group(1) + if username and username not in reviewers: + reviewers.append(username) + + # Stop parsing when we hit the end of the table + if in_table and not line.startswith('|'): + break + + if reviewers: + print(f"Found {len(reviewers)} reviewers from MAINTAINERS.md: {', '.join(reviewers)}") + else: + print("Warning: No GitHub usernames found in MAINTAINERS.md") + + except FileNotFoundError: + print(f"Error: MAINTAINERS.md file not found") + sys.exit(1) + except IOError as e: + print(f"Error reading MAINTAINERS.md: {e}") + sys.exit(1) + + return reviewers + + +def calculate_rotation_info(config: dict) -> RotationInfo: + """Calculate rotation information from config (helper function).""" + start_date_str = config.get('start_date') + rotation_cycle_weeks = config.get('rotation_cycle_weeks', 3) + + if not start_date_str: + return { + "start_date": None, + "rotation_cycle_weeks": rotation_cycle_weeks, + "weeks_since_start": 0, + "before_start": False, + "error": "No start_date configured" + } + + try: + start_date = datetime.fromisoformat(start_date_str).replace(tzinfo=timezone.utc) + weeks_since_start = (datetime.now(timezone.utc) - start_date) / timedelta(weeks=1) + + return { + "start_date": start_date, + "rotation_cycle_weeks": rotation_cycle_weeks, + "weeks_since_start": weeks_since_start, + "before_start": weeks_since_start < 0, + "error": None + } + except ValueError: + return { + "start_date": None, + "rotation_cycle_weeks": rotation_cycle_weeks, + "weeks_since_start": 0, + "before_start": False, + "error": f"Invalid start_date format: {start_date_str}" + } + + +def get_current_sprint_info(rotation_info: RotationInfo) -> SprintInfo: + """Calculate current sprint information.""" + + if rotation_info["before_start"]: + return { + "sprint_number": 1, + "week_in_sprint": 1, + "total_weeks": 0 + } + + weeks_since_start = rotation_info["weeks_since_start"] + rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"] + + sprint_number = int(weeks_since_start // rotation_cycle_weeks) + 1 + week_in_sprint = int(weeks_since_start % rotation_cycle_weeks) + 1 + + return { + "sprint_number": sprint_number, + "week_in_sprint": week_in_sprint, + "total_weeks": int(weeks_since_start) + } + + +def get_pr_author(pr_number: str) -> str: + """Get the author of the PR.""" + repo = os.environ.get('GITHUB_REPOSITORY', 'bootc-dev/bootc') + result = run_gh_command( + ['api', f'repos/{repo}/pulls/{pr_number}', '--jq', '.user.login'], + f"Could not fetch PR author for PR {pr_number}" + ) + return result.stdout.strip() + + +def calculate_current_reviewer(reviewers: List[str], rotation_info: RotationInfo, exclude_user: Optional[str] = None) -> Optional[str]: + """Calculate the current reviewer based on the rotation schedule, excluding specified user.""" + if not reviewers: + print("Error: No reviewers found") + return None + + if rotation_info["before_start"]: + print(f"Warning: Current date is before start date. Using first reviewer.") + # Find first reviewer that's not the excluded user + for reviewer in reviewers: + if reviewer != exclude_user: + return reviewer + return reviewers[0] if reviewers else None + + # Calculate total weeks since start and map to reviewer + # Each reviewer gets rotation_cycle_weeks weeks, then we cycle to the next + total_weeks = int(rotation_info["weeks_since_start"]) + rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"] + reviewer_index = (total_weeks // rotation_cycle_weeks) % len(reviewers) + + # If the calculated reviewer is the excluded user, find the next one + if exclude_user and reviewers[reviewer_index] == exclude_user: + # Try next reviewer in the list + next_reviewer_index = (reviewer_index + 1) % len(reviewers) + attempts = 0 + while reviewers[next_reviewer_index] == exclude_user and attempts < len(reviewers): + next_reviewer_index = (next_reviewer_index + 1) % len(reviewers) + attempts += 1 + + # If all reviewers are excluded, return None + if reviewers[next_reviewer_index] == exclude_user: + print(f"Warning: All reviewers are excluded ({exclude_user}), skipping assignment") + return None + + return reviewers[next_reviewer_index] + + return reviewers[reviewer_index] + + +def run_gh_command(args: List[str], error_message: str) -> subprocess.CompletedProcess: + """Run a GitHub CLI command with consistent error handling.""" + try: + return subprocess.run( + ['gh'] + args, + capture_output=True, text=True, check=True + ) + except FileNotFoundError: + print("Error: 'gh' command not found. Is the GitHub CLI installed and in the PATH?") + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"{error_message}: {e.stderr}", file=sys.stderr) + sys.exit(1) + + +def get_existing_reviewers(pr_number: str) -> List[str]: + """Get list of reviewers already assigned to the PR.""" + repo = os.environ.get('GITHUB_REPOSITORY', 'bootc-dev/bootc') + result = run_gh_command( + ['api', f'repos/{repo}/pulls/{pr_number}', '--jq', '.requested_reviewers[].login'], + f"Could not fetch existing reviewers for PR {pr_number}" + ) + return result.stdout.strip().split('\n') if result.stdout.strip() else [] + + +def assign_reviewer(pr_number: str, reviewer: str) -> None: + """Assign a reviewer to the PR using GitHub CLI.""" + print(f"Attempting to assign reviewer {reviewer} to PR {pr_number}") + + run_gh_command( + ['pr', 'edit', pr_number, '--add-reviewer', reviewer], + f"Error assigning reviewer {reviewer} to PR {pr_number}" + ) + print(f"Successfully assigned reviewer {reviewer} to PR {pr_number}") + + +def main(): + """Main function to handle reviewer assignment.""" + # Get PR number from environment variable + pr_number = os.environ.get('PR_NUMBER') + if not pr_number: + print("Error: PR_NUMBER environment variable not set") + sys.exit(1) + + # Load configuration (for start_date and rotation_cycle_weeks) + config_path = os.environ.get('AUTO_REVIEW_CONFIG_PATH', '.github/auto-review-config.yml') + config = load_config(config_path) + + # Extract reviewers from MAINTAINERS.md + reviewers = extract_reviewers_from_maintainers() + if not reviewers: + print("Error: No reviewers found in MAINTAINERS.md") + sys.exit(1) + + # Calculate rotation information once + rotation_info = calculate_rotation_info(config) + if rotation_info['error']: + print(f"Error in configuration: {rotation_info['error']}", file=sys.stderr) + sys.exit(1) + + # Get sprint information + sprint_info = get_current_sprint_info(rotation_info) + print(f"Current sprint: {sprint_info['sprint_number']}, week: {sprint_info['week_in_sprint']}") + + # Get PR author to exclude them from being assigned + pr_author = get_pr_author(pr_number) + print(f"PR author: {pr_author}") + + # Calculate current reviewer, excluding the PR author + current_reviewer = calculate_current_reviewer(reviewers, rotation_info, exclude_user=pr_author) + if not current_reviewer: + print("Error: Could not calculate current reviewer") + sys.exit(1) + + print(f"Assigned reviewer for this week: {current_reviewer}") + + # Get existing reviewers + existing_reviewers = get_existing_reviewers(pr_number) + + # Check if current reviewer is already assigned + if current_reviewer in existing_reviewers: + print(f"Reviewer {current_reviewer} is already assigned to PR {pr_number}") + return + + # Assign the current reviewer + assign_reviewer(pr_number, current_reviewer) + +if __name__ == "__main__": + main() diff --git a/.github/workflows/auto-review.yml b/.github/workflows/auto-review.yml new file mode 100644 index 000000000..a26afac05 --- /dev/null +++ b/.github/workflows/auto-review.yml @@ -0,0 +1,41 @@ +name: Auto Assign Reviewer + +on: + pull_request_target: + types: [opened, ready_for_review] + +jobs: + assign-reviewer: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + issues: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyyaml + + - name: Generate Bootc Actions Token + id: bootc_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Assign reviewer + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ steps.bootc_token.outputs.token }} + run: | + python .github/scripts/assign_reviewer.py diff --git a/.github/workflows/autovendor.yml b/.github/workflows/autovendor.yml new file mode 100644 index 000000000..7df33fcfd --- /dev/null +++ b/.github/workflows/autovendor.yml @@ -0,0 +1,24 @@ +# Automatically generate a vendor.tar.zstd on pushes to git main. +name: Auto-vendor artifact + +permissions: + actions: read + +on: + push: + branches: [main] + +jobs: + vendor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install vendor tool + run: cargo install cargo-vendor-filterer + - name: Run + run: mkdir -p target && cd crates/cli && cargo vendor-filterer --format=tar.zstd --prefix=vendor/ ../../target/vendor.tar.zst + - uses: actions/upload-artifact@v5 + with: + name: vendor.tar.zst + path: target/vendor.tar.zst diff --git a/.github/workflows/build-devcontainer.yml b/.github/workflows/build-devcontainer.yml deleted file mode 100644 index 4c884539a..000000000 --- a/.github/workflows/build-devcontainer.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Build DevContainer - -on: - push: - branches: [main] - tags: ['v*'] - pull_request: - paths: - - 'devenv/**' - - 'common/.devcontainer/**' - - '.github/workflows/build-devcontainer.yml' - -env: - REGISTRY: ghcr.io - -jobs: - validate-devcontainer: - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install just - uses: extractions/setup-just@v3 - - - name: Validate devcontainer.json syntax - run: just devcontainer-validate - - build: - needs: validate-devcontainer - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - os: [debian, ubuntu, c10s] - arch: [amd64, arm64] - include: - - arch: amd64 - runner: ubuntu-24.04 - - arch: arm64 - runner: ubuntu-24.04-arm - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to Container Registry - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/devenv-${{ matrix.os }} - - - name: Build and push by digest - id: build - uses: docker/build-push-action@v7 - with: - context: devenv - file: devenv/Containerfile.${{ matrix.os }} - platforms: linux/${{ matrix.arch }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/devenv-${{ matrix.os }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} - - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v7 - with: - name: digests-${{ matrix.os }}-${{ matrix.arch }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - runs-on: ubuntu-24.04 - needs: build - if: github.event_name != 'pull_request' - strategy: - matrix: - os: [debian, ubuntu, c10s] - steps: - - name: Download digests - uses: actions/download-artifact@v8 - with: - path: /tmp/digests - pattern: digests-${{ matrix.os }}-* - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/devenv-${{ matrix.os }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix={{branch}}-,format=short - type=sha,prefix={{branch}}-,format=long - type=ref,event=pr - type=ref,event=tag - - - name: Log in to Container Registry - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.REGISTRY }}/${{ github.repository_owner }}/devenv-${{ matrix.os }}@sha256:%s ' *) - - - name: Inspect image - run: | - docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/devenv-${{ matrix.os }}:${{ steps.meta.outputs.version }} - diff --git a/.github/workflows/build-staged-images.yml b/.github/workflows/build-staged-images.yml deleted file mode 100644 index d32b60c9b..000000000 --- a/.github/workflows/build-staged-images.yml +++ /dev/null @@ -1,163 +0,0 @@ -name: Build chunkah-staged bootc base images - -on: - push: - branches: [main] - paths: - - 'staged-images/**' - - '.github/workflows/build-staged-images.yml' - pull_request: - paths: - - 'staged-images/**' - - '.github/workflows/build-staged-images.yml' - schedule: - # Rebuild weekly to pick up upstream base image updates - - cron: '0 6 * * 1' - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -env: - REGISTRY: ghcr.io - -jobs: - generate-matrix: - name: Generate build matrix - runs-on: ubuntu-24.04 - outputs: - build: ${{ steps.matrix.outputs.build }} - manifest: ${{ steps.matrix.outputs.manifest }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Generate matrix from base-images.json - id: matrix - run: | - # Build matrix: each image/tag combo × {amd64, arm64} - build=$(jq -c '[to_entries[] | .value as $v | - ({arch: "amd64", runner: "ubuntu-24.04"}, {arch: "arm64", runner: "ubuntu-24.04-arm"}) | - {image: $v.image, tag: $v.tag, source: $v.source, arch: .arch, runner: .runner} - ]' staged-images/base-images.json) - echo "build=${build}" >> "$GITHUB_OUTPUT" - - # Manifest matrix: unique image/tag combos (no arch) - manifest=$(jq -c '[to_entries[] | .value | {image, tag}]' staged-images/base-images.json) - echo "manifest=${manifest}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.image }}:${{ matrix.tag }} (${{ matrix.arch }}) - needs: generate-matrix - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.generate-matrix.outputs.build) }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up podman - uses: bootc-dev/actions/bootc-ubuntu-setup@main - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login -u ${{ github.actor }} --password-stdin ${{ env.REGISTRY }} - - - name: Pull source image - run: podman pull --arch ${{ matrix.arch }} ${{ matrix.source }} - - - name: Write source image config to build context - working-directory: staged-images - run: podman inspect ${{ matrix.source }} > source-config.json - - - name: Build staged image - working-directory: staged-images - run: | - buildah build --skip-unused-stages=false \ - --build-arg SOURCE_IMAGE=${{ matrix.source }} \ - --build-arg MAX_LAYERS=128 \ - -f Containerfile.staged \ - -t localhost/${{ matrix.image }}:${{ matrix.tag }} \ - . - - - name: Verify image - run: | - echo "=== Layer count ===" - podman inspect localhost/${{ matrix.image }}:${{ matrix.tag }} | jq '.[0].RootFS.Layers | length' - - - name: Push by digest - if: github.event_name != 'pull_request' - run: | - full_image=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} - podman tag localhost/${{ matrix.image }}:${{ matrix.tag }} ${full_image}:${{ matrix.tag }} - podman push --digestfile ${{ runner.temp }}/digest ${full_image}:${{ matrix.tag }} - echo "Pushed digest: $(cat ${{ runner.temp }}/digest)" - - mkdir -p ${{ runner.temp }}/build-digests - cp ${{ runner.temp }}/digest "${{ runner.temp }}/build-digests/${{ matrix.arch }}" - - - name: Upload digest - if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: digests-${{ matrix.image }}-${{ matrix.tag }}-${{ matrix.arch }} - path: ${{ runner.temp }}/build-digests/* - if-no-files-found: error - retention-days: 1 - - manifest: - name: Manifest ${{ matrix.image }}:${{ matrix.tag }} - runs-on: ubuntu-24.04 - needs: [generate-matrix, build] - if: always() && !cancelled() && github.event_name != 'pull_request' - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.generate-matrix.outputs.manifest) }} - - steps: - - name: Set up podman - uses: bootc-dev/actions/bootc-ubuntu-setup@main - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login -u ${{ github.actor }} --password-stdin ${{ env.REGISTRY }} - - - name: Download digests - uses: actions/download-artifact@v8 - with: - path: ${{ runner.temp }}/digests - pattern: digests-${{ matrix.image }}-${{ matrix.tag }}-* - merge-multiple: true - - - name: Create and push manifest - run: | - set -euxo pipefail - full_image=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} - - podman manifest create ${full_image}:${{ matrix.tag }} - - for arch_file in ${{ runner.temp }}/digests/*; do - arch=$(basename "$arch_file") - digest=$(cat "$arch_file") - echo "Adding ${arch}: ${digest}" - podman manifest add --arch "${arch}" \ - ${full_image}:${{ matrix.tag }} \ - "${full_image}@${digest}" - done - - podman manifest push --all ${full_image}:${{ matrix.tag }} \ - docker://${full_image}:${{ matrix.tag }} - - - name: Inspect manifest - run: | - podman manifest inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:${{ matrix.tag }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..ea0a6d67e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,260 @@ +# CI Workflow for bootc +# +# Core principles: +# - Everything done here should be easy to replicate locally. Most tasks +# should invoke `just `. Read the Justfile for more explanation +# of this. +# - Most additions to this should be extending existing tasks; e.g. +# there's places for unit and integration tests already. +name: CI + +permissions: + actions: read + packages: write + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: {} + +env: + CARGO_TERM_COLOR: always + # Something seems to be setting this in the default GHA runners, which breaks bcvk + # as the default runner user doesn't have access + LIBVIRT_DEFAULT_URI: "qemu:///session" + DEV_IMAGE: ghcr.io/bootc-dev/dev-bootc + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Run basic validation checks (linting, formatting, etc) + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + - name: Validate (default) + run: just validate + # Check for security vulnerabilities and license compliance + cargo-deny: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + log-level: warn + command: check -A duplicate bans sources licenses + # Test bootc installation scenarios and fsverity support + # TODO convert to be an integration test + install-tests: + name: "Test install" + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + - name: Enable fsverity for / + run: sudo tune2fs -O verity $(findmnt -vno SOURCE /) + - name: Install utils + run: sudo apt -y install fsverity just + - name: Integration tests + run: | + set -xeu + # Build images to test; TODO investigate doing single container builds + # via GHA and pushing to a temporary registry to share among workflows? + sudo just build + sudo just build-install-test-image + sudo podman build -t localhost/bootc-fsverity -f ci/Containerfile.install-fsverity + + # TODO move into a container, and then have this tool run other containers + cargo build --release -p tests-integration + + df -h / + sudo install -m 0755 target/release/tests-integration /usr/bin/bootc-integration-tests + rm target -rf + df -h / + # The ostree-container tests + sudo podman run --privileged --pid=host -v /:/run/host -v $(pwd):/src:ro -v /var/tmp:/var/tmp \ + --tmpfs /var/lib/containers \ + -v /run/dbus:/run/dbus -v /run/systemd:/run/systemd localhost/bootc /src/crates/ostree-ext/ci/priv-integration.sh + # Nondestructive but privileged tests + sudo bootc-integration-tests host-privileged localhost/bootc-integration-install + # Install tests + sudo bootc-integration-tests install-alongside localhost/bootc-integration-install + + # system-reinstall-bootc tests + cargo build --release -p system-reinstall-bootc + + # not sure why this is missing in the ubuntu image but just creating this directory allows the tests to pass + sudo mkdir -p /run/sshd + + sudo install -m 0755 target/release/system-reinstall-bootc /usr/bin/system-reinstall-bootc + # These tests may mutate the system live so we can't run in parallel + sudo bootc-integration-tests system-reinstall localhost/bootc-integration --test-threads=1 + + # And the fsverity case + sudo podman run --privileged --pid=host localhost/bootc-fsverity bootc install to-existing-root --stateroot=other \ + --acknowledge-destructive --skip-fetch-check + # Crude cross check + sudo find /ostree/repo/objects -name '*.file' -type f | while read f; do + sudo fsverity measure $f >/dev/null + done + # Test that we can build documentation + docs: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + - name: Build mdbook + run: just build-mdbook + # Build bootc from source into a container image FROM each specified base `test_os` + # running unit and integration tests (using TMT, leveraging the support for nested virtualization + # in the GHA runners) + test-integration: + strategy: + fail-fast: false + matrix: + test_os: [fedora-42, fedora-43, centos-9, centos-10] + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + with: + libvirt: true + - name: Build bcvk from PR 159 + run: | + git clone https://github.com/cgwalters/bcvk-fork /tmp/bcvk + cd /tmp/bcvk + git checkout bind-storage-ro-selinux + cargo build --release + sudo install -m 0755 target/release/bcvk /usr/local/bin/bcvk + - name: Install tmt + run: pip install --user "tmt[provision-virtual]" + + - name: Setup env + run: | + BASE=$(just pullspec-for-os ${{ matrix.test_os }}) + echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + + - name: Build container + run: | + just build-integration-test-image + # Extra cross-check (duplicating the integration test) that we're using the right base + used_vid=$(podman run --rm localhost/bootc-integration bash -c '. /usr/lib/os-release && echo ${ID}-${VERSION_ID}') + test ${{ matrix.test_os }} = "${used_vid}" + + - name: Unit and container integration tests + run: just test-container + + - name: Run all TMT tests + run: just test-tmt + + - name: Archive TMT logs + if: always() + uses: actions/upload-artifact@v5 + with: + name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-ostree-${{ env.ARCH }} + path: /var/tmp/tmt + + - name: Login to ghcr.io + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: redhat-actions/podman-login@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push container image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + podman tag localhost/bootc ${{ env.DEV_IMAGE }}:${{ matrix.test_os }} + podman push ${{ env.DEV_IMAGE }}:${{ matrix.test_os }} + + # This variant does composefs testing + test-integration-cfs: + strategy: + fail-fast: false + matrix: + # TODO expand this matrix, we need to make it better to override the target + # OS via Justfile variables too + test_os: [centos-10] + variant: [composefs-sealeduki-sdboot] + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + with: + libvirt: true + - name: Build bcvk from PR 159 + run: | + git clone https://github.com/cgwalters/bcvk-fork /tmp/bcvk + cd /tmp/bcvk + git checkout bind-storage-ro-selinux + cargo build --release + sudo install -m 0755 target/release/bcvk /usr/local/bin/bcvk + - name: Install tmt + run: pip install --user "tmt[provision-virtual]" + + - name: Setup env + run: | + BASE=$(just pullspec-for-os ${{ matrix.test_os }}) + echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + echo "BOOTC_variant="${{ matrix.variant }} >> $GITHUB_ENV + + - name: Build container + run: | + just build-integration-test-image + + - name: Unit and container integration tests + run: just test-container + + - name: Run TMT tests + # Note that this one only runs a subset of tests right now + run: just test-composefs + + - name: Archive TMT logs + if: always() + uses: actions/upload-artifact@v5 + with: + name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-cfs-${{ env.ARCH }} + path: /var/tmp/tmt + + - name: Login to ghcr.io + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: redhat-actions/podman-login@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push container image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + podman tag localhost/bootc ${{ env.DEV_IMAGE }}:stream10-uki + podman push ${{ env.DEV_IMAGE }}:stream10-uki + + # Sentinel job for required checks - configure this job name in repository settings + required-checks: + if: always() + needs: [cargo-deny, validate, test-integration, test-integration-cfs] + runs-on: ubuntu-latest + steps: + - run: exit 1 + if: >- + needs.cargo-deny.result != 'success' || + needs.validate.result != 'success' || + needs.test-integration.result != 'success' || + needs.test-integration-cfs.result != 'success' diff --git a/.github/workflows/container-gc.yml b/.github/workflows/container-gc.yml deleted file mode 100644 index f2c148af0..000000000 --- a/.github/workflows/container-gc.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Container Image Garbage Collection - -on: - workflow_dispatch: - inputs: - retention-days: - description: "Delete container images older than this many days" - required: false - default: "14" - type: string - dry-run: - description: "Dry run mode - don't actually delete anything" - required: false - default: false - type: boolean - schedule: - # Run weekly on Sundays at 2 AM UTC - - cron: '0 2 * * 0' - -jobs: - cleanup: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Delete old container images - uses: snok/container-retention-policy@v3.0.1 - with: - account: ${{ github.repository_owner }} - token: ${{ secrets.GITHUB_TOKEN }} - cut-off: ${{ github.event.inputs.retention-days || '14' }}d - dry-run: ${{ github.event.inputs.dry-run || false }} - diff --git a/.github/workflows/debug-arm64-upterm.yml b/.github/workflows/debug-arm64-upterm.yml new file mode 100644 index 000000000..a1fd4c9f0 --- /dev/null +++ b/.github/workflows/debug-arm64-upterm.yml @@ -0,0 +1,36 @@ +name: Debug arm64 with upterm + +on: + workflow_dispatch: + +permissions: {} + +jobs: + debug-arm64: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - name: Show system info + run: | + set -x + uname -a + arch + cat /etc/os-release + df -h + + - name: Test local bootc-ubuntu-setup action + uses: ./.github/actions/bootc-ubuntu-setup + + - name: Verify setup worked + run: | + set -x + podman --version + just --version + echo "Setup completed successfully!" + + - name: Setup upterm session + if: failure() + uses: owenthereal/action-upterm@v1 + with: + limit-access-to-actor: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..d44b06aba --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,43 @@ +name: Deploy docs to pages + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + - name: Build mdbook + run: mkdir target && just build-mdbook-to target/docs + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./target/docs + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..2ed8309df --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,13 @@ +name: "Pull Request Labeler" +on: +- pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/labeler@v6 diff --git a/.github/workflows/openssf-scorecard.yml b/.github/workflows/openssf-scorecard.yml deleted file mode 120000 index 29c12de90..000000000 --- a/.github/workflows/openssf-scorecard.yml +++ /dev/null @@ -1 +0,0 @@ -../../common/.github/workflows/openssf-scorecard.yml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..9e5089e4d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + release: + name: Create Release + if: | + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Extract version + id: extract_version + run: | + # Extract version from crates/lib/Cargo.toml + VERSION=$(cargo read-manifest --manifest-path crates/lib/Cargo.toml | jq -r '.version') + + # Validate version format + if ! echo "$VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' >/dev/null; then + echo "Error: Invalid version format in Cargo.toml: $VERSION" + exit 1 + fi + + echo "Extracted version: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "TAG_NAME=v$VERSION" >> $GITHUB_OUTPUT + + - name: Install deps + run: ./ci/installdeps.sh + + - name: Mark git checkout as safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Import GPG key + if: github.event_name != 'push' + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + git_tag_gpgsign: true + + - name: Create and push tag + if: github.event_name != 'push' + run: | + VERSION="${{ steps.extract_version.outputs.version }}" + TAG_NAME="v$VERSION" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists" + exit 0 + fi + + git tag -s -m "Release $VERSION" "$TAG_NAME" + git push origin "$TAG_NAME" + + echo "Successfully created and pushed tag $TAG_NAME" + + git checkout "$TAG_NAME" + + - name: Install vendor tool + run: cargo install cargo-vendor-filterer + + - name: Cache Dependencies + uses: Swatinem/rust-cache@v2 + with: + key: "release" + + - name: Run cargo xtask package + run: cargo xtask package + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + with: + tag_name: ${{ steps.extract_version.outputs.TAG_NAME }} + release_name: Release ${{ steps.extract_version.outputs.TAG_NAME }} + draft: true + prerelease: false + body: | + ## bootc ${{ steps.extract_version.outputs.version }} + + ### Changes + + Auto-generated release notes will be populated here. + + ### Assets + + - `bootc-${{ steps.extract_version.outputs.version }}-vendor.tar.zstd` - Vendored dependencies archive + - `bootc-${{ steps.extract_version.outputs.version }}.tar.zstd` - Source archive + + - name: Upload vendor archive + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./target/bootc-${{ steps.extract_version.outputs.version }}-vendor.tar.zstd + asset_name: bootc-${{ steps.extract_version.outputs.version }}-vendor.tar.zstd + asset_content_type: application/zstd + + - name: Upload source archive + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./target/bootc-${{ steps.extract_version.outputs.version }}.tar.zstd + asset_name: bootc-${{ steps.extract_version.outputs.version }}.tar.zstd + asset_content_type: application/zstd diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml deleted file mode 100644 index dc4fc490d..000000000 --- a/.github/workflows/renovate.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Renovate -on: - pull_request: - workflow_dispatch: - inputs: - log-level: - description: "Set the Renovate log level (default: info)" - required: false - default: info - type: choice - options: - - info - - debug - schedule: - - cron: '3 * * * *' -jobs: - validate: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Validate Renovate config with schema validator - run: | - docker run --rm -v $(pwd):/usr/src/app ghcr.io/renovatebot/renovate:42 \ - renovate-config-validator --strict \ - /usr/src/app/renovate.json \ - /usr/src/app/renovate-shared-config.json \ - /usr/src/app/renovate-config.js - - renovate: - runs-on: ubuntu-latest - needs: validate - if: github.event_name != 'pull_request' - strategy: - fail-fast: false - matrix: - include: - - owner: bootc-dev - - owner: composefs - steps: - - name: Generate Actions Token - id: token - if: github.repository_owner == 'bootc-dev' - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ matrix.owner }} - - - name: Checkout - uses: actions/checkout@v6 - - - name: Self-hosted Renovate - uses: renovatebot/github-action@v46.1.7 - env: - RENOVATE_DRY_RUN: ${{ github.repository_owner != 'bootc-dev' && 'full' }} - LOG_LEVEL: ${{ github.event.inputs.log-level || 'info' }} - with: - configurationFile: renovate-config.js - token: '${{ steps.token.outputs.token || secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/scheduled-release.yml b/.github/workflows/scheduled-release.yml new file mode 100644 index 000000000..badd0fc21 --- /dev/null +++ b/.github/workflows/scheduled-release.yml @@ -0,0 +1,124 @@ +name: Create Release PR + +on: + schedule: + # Run every 3 weeks on Monday at 8:00 AM UTC + # Note: GitHub Actions doesn't support "every 3 weeks" directly, + # so we use a workaround by running weekly and checking if it's been 3 weeks + - cron: '0 8 * * 1' + workflow_dispatch: + inputs: + version: + description: 'Version type to release, either "minor" (default) or "patch" for just a bugfix release' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + create-release-pr: + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Mark git checkout as safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Check if it's time for a release + id: check_schedule + run: | + # For manual workflow dispatch, always proceed + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "should_release=true" >> $GITHUB_OUTPUT + exit 0 + fi + + START_DATE="2025-08-04" # start of a 3 week sprint + START_TIMESTAMP=$(date -d "$START_DATE" +%s) + CURRENT_TIMESTAMP=$(date +%s) + # Add 12 hour buffer (43200 seconds) to account for scheduling delays + ADJUSTED_TIMESTAMP=$((CURRENT_TIMESTAMP + 43200)) + DAYS_SINCE_START=$(( (ADJUSTED_TIMESTAMP - START_TIMESTAMP) / 86400 )) + WEEKS_SINCE_START=$(( DAYS_SINCE_START / 7 )) + + echo "Days since start date ($START_DATE): $DAYS_SINCE_START" + echo "Weeks since start date: $WEEKS_SINCE_START" + + # Release every 3 weeks + if [ $WEEKS_SINCE_START -gt 0 ] && [ $((WEEKS_SINCE_START % 3)) -eq 0 ]; then + echo "should_release=true" >> $GITHUB_OUTPUT + else + echo "should_release=false" >> $GITHUB_OUTPUT + fi + + - name: Install deps + if: steps.check_schedule.outputs.should_release == 'true' + run: ./ci/installdeps.sh + + - name: Import GPG key + if: steps.check_schedule.outputs.should_release == 'true' + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + git_tag_gpgsign: true + + - name: Generate release changes + id: create_commit + if: steps.check_schedule.outputs.should_release == 'true' + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + dnf -y install go-md2man + cargo install cargo-edit + + # Default to bumping a minor + cargo set-version --manifest-path crates/lib/Cargo.toml --package bootc-lib --bump ${INPUT_VERSION:-minor} + VERSION=$(cargo read-manifest --manifest-path crates/lib/Cargo.toml | jq -r '.version') + + cargo update --workspace + cargo xtask update-generated + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + env: + VERSION: ${{ steps.create_commit.outputs.VERSION }} + with: + token: ${{ steps.app-token.outputs.token }} + signoff: true + sign-commits: true + title: "Release ${{ env.VERSION }}" + commit-message: "Release ${{ env.VERSION }}" + branch: "release-${{ env.VERSION }}" + delete-branch: true + labels: release + body: | + ## Release ${{ env.VERSION }} + + This is an automated release PR created by the scheduled release workflow. + + ### Release Process + + 1. Review the changes in this PR + 2. Ensure all tests pass + 3. Merge the PR + 4. The release tag will be automatically created and signed when this PR is merged + + The release workflow will automatically trigger when the tag is pushed. diff --git a/.github/workflows/sync-common.yml b/.github/workflows/sync-common.yml deleted file mode 100644 index aa9836166..000000000 --- a/.github/workflows/sync-common.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Sync common files -on: - workflow_dispatch: - inputs: - test_mode: - description: 'Test mode - only sync to ci-sandbox' - type: boolean - default: false - push: - branches: - - main - paths: - - 'common/**' - - '.github/workflows/sync-common.yml' - - 'scripts/sync-common/**' - -# Prevent multiple workflow runs from racing -concurrency: ${{ github.workflow }} - -permissions: - contents: read - -jobs: - build: - name: Build sync-common tool - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Rust - uses: bootc-dev/actions/setup-rust@main - - - name: Build sync-common - run: cargo build --release --manifest-path scripts/sync-common/Cargo.toml - - - name: Upload sync-common binary - uses: actions/upload-artifact@v7 - with: - name: sync-common - path: scripts/sync-common/target/release/sync-common - retention-days: 1 - - init: - name: Discover repositories - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - steps: - - name: Generate Actions Token (bootc-dev) - id: token-bootc - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Generate Actions Token (composefs) - id: token-composefs - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: composefs - - - name: Discover repositories - id: discover - uses: bootc-dev/actions/discover-repos@main - with: - orgs: '["${{ github.repository_owner }}", "composefs"]' - tokens: '{"${{ github.repository_owner }}": "${{ steps.token-bootc.outputs.token }}", "composefs": "${{ steps.token-composefs.outputs.token }}"}' - test-mode: ${{ github.event.inputs.test_mode || 'false' }} - exclude-self: 'true' - - sync: - name: Sync to ${{ matrix.full_name }} - needs: [build, init] - if: needs.init.outputs.matrix != '[]' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.init.outputs.matrix) }} - steps: - - name: Generate Actions Token - id: token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ matrix.owner }} - repositories: ${{ matrix.repo }} - - - name: Checkout infra repository - uses: actions/checkout@v6 - with: - path: infra - fetch-depth: 0 - - - name: Checkout target repository - uses: actions/checkout@v6 - with: - repository: ${{ matrix.full_name }} - token: ${{ steps.token.outputs.token }} - path: repo - - - name: Download sync-common binary - uses: actions/download-artifact@v8 - with: - name: sync-common - path: . - - - name: Sync common files to repository - run: | - chmod +x sync-common - ./sync-common infra repo "${{ github.sha }}" - - - name: Open pull request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.token.outputs.token }} - path: repo - branch: sync-common-files - commit-message: | - Sync common files from infra repository - - Synchronized from ${{ github.repository }}@${{ github.sha }}. - title: Sync common files from infra repository - body: | - Created by [GitHub workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/sync-common.yml) ([source](${{ github.server_url }}/${{ github.repository }}/blob/main/.github/workflows/sync-common.yml)). - - This PR synchronizes common files from the [infra repository](${{ github.server_url }}/${{ github.repository }}/tree/main/common). - - Synchronized from ${{ github.repository }}@${{ github.sha }}. - committer: "bootc-dev Bot " - author: "bootc-dev Bot " - signoff: true diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml deleted file mode 100644 index db4fffb07..000000000 --- a/.github/workflows/sync-labels.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: Sync labels -on: - workflow_dispatch: - inputs: - test_mode: - description: 'Test mode - only sync to ci-sandbox' - type: boolean - default: false - push: - branches: - - main - paths: - - 'labels.toml' - - '.github/workflows/sync-labels.yml' - -# Prevent multiple workflow runs from racing -concurrency: ${{ github.workflow }} - -permissions: - contents: read - -jobs: - init: - name: Discover repositories - runs-on: ubuntu-24.04 - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - labels: ${{ steps.parse-labels.outputs.labels }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Parse labels from TOML - id: parse-labels - run: | - python3 -c ' - import tomllib - import json - import sys - import os - - try: - with open("labels.toml", "rb") as f: - data = tomllib.load(f) - labels = data["labels"] - - with open(os.environ["GITHUB_OUTPUT"], "a") as output: - output.write(f"labels={json.dumps(labels)}\n") - except Exception as e: - print(f"Error parsing labels.toml: {e}", file=sys.stderr) - sys.exit(1) - ' - - - name: Generate Actions Token (bootc-dev) - id: token-bootc - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Generate Actions Token (composefs) - id: token-composefs - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: composefs - - - name: Discover repositories - id: discover - uses: bootc-dev/actions/discover-repos@main - with: - orgs: '["${{ github.repository_owner }}", "composefs"]' - tokens: '{"${{ github.repository_owner }}": "${{ steps.token-bootc.outputs.token }}", "composefs": "${{ steps.token-composefs.outputs.token }}"}' - test-mode: ${{ github.event.inputs.test_mode || 'false' }} - - sync: - name: Sync to ${{ matrix.full_name }} - needs: init - if: needs.init.outputs.matrix != '[]' - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.init.outputs.matrix) }} - steps: - - name: Generate Actions Token - id: token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ matrix.owner }} - repositories: ${{ matrix.repo }} - - - name: Sync labels - uses: actions/github-script@v8 - env: - LABELS_JSON: ${{ needs.init.outputs.labels }} - with: - github-token: ${{ steps.token.outputs.token }} - script: | - const labels = JSON.parse(process.env.LABELS_JSON); - const owner = '${{ matrix.owner }}'; - const repo = '${{ matrix.repo }}'; - - console.log(`Syncing labels to ${owner}/${repo}`); - - for (const label of labels) { - try { - // Try to get the label to see if it exists - const existingLabel = await github.rest.issues.getLabel({ - owner, - repo, - name: label.name - }); - - // Label exists - update it if color or description changed - if (existingLabel.data.color !== label.color || - existingLabel.data.description !== label.description) { - console.log(`Updating label "${label.name}"`); - await github.rest.issues.updateLabel({ - owner, - repo, - name: label.name, - color: label.color, - description: label.description - }); - } else { - console.log(`Label "${label.name}" already up to date`); - } - } catch (error) { - if (error.status === 404) { - // Label doesn't exist, create it - console.log(`Creating label "${label.name}"`); - await github.rest.issues.createLabel({ - owner, - repo, - name: label.name, - color: label.color, - description: label.description - }); - } else { - throw error; - } - } - } - - console.log('Label sync complete'); diff --git a/.github/workflows/test-devcontainer.yml b/.github/workflows/test-devcontainer.yml deleted file mode 100644 index de6811ca3..000000000 --- a/.github/workflows/test-devcontainer.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Test DevContainer - -on: - push: - branches: [main] - pull_request: - paths: - - 'devenv/**' - - '.devcontainer/**' - - 'common/.devcontainer/**' - - '.github/workflows/test-devcontainer.yml' - - 'Justfile' - -env: - REGISTRY: ghcr.io - -jobs: - test: - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - os: [debian, ubuntu] - # TODO: c10s has PAM/sudo issues with devcontainer CLI's --userns=keep-id - # include: - # - os: c10s - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up runner - uses: bootc-dev/actions/bootc-ubuntu-setup@main - - - name: Build devcontainer image - run: just devenv-build-${{ matrix.os }} - - - name: Test devcontainer - run: just devcontainer-test ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index c18dd8d83..81d5d39b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -__pycache__/ +example +.cosa +_kola_temp +bootc.tar.zst + +# Added by cargo +/target diff --git a/.packit.yaml b/.packit.yaml new file mode 100644 index 000000000..fc39a05e6 --- /dev/null +++ b/.packit.yaml @@ -0,0 +1,100 @@ +--- +upstream_package_name: bootc +downstream_package_name: bootc + +upstream_tag_template: v{version} + +specfile_path: contrib/packaging/bootc.spec + +srpm_build_deps: + - cargo + - git + - zstd + - libzstd-devel + - ostree-devel + - openssl-devel + +actions: + # The last setp here is required by Packit to return the archive name + # https://packit.dev/docs/configuration/actions#create-archive + create-archive: + - bash -c "cargo install cargo-vendor-filterer" + - bash -c "cargo xtask spec" + - bash -c "cat target/bootc.spec" + - bash -c "cp target/bootc* contrib/packaging/" + - bash -c "ls -1 target/bootc*.tar.zstd | grep -v 'vendor'" + # Do nothing with spec file. Two steps here are for debugging + fix-spec-file: + - bash -c "cat contrib/packaging/bootc.spec" + - bash -c "ls -al contrib/packaging/" + +jobs: + - job: copr_build + trigger: pull_request + targets: + # Primary targets are c9s, c10s and supported fedora right now, + # which build for all architectures + - centos-stream-9-x86_64 + - centos-stream-9-aarch64 + - centos-stream-9-s390x + - centos-stream-10-x86_64 + - centos-stream-10-aarch64 + - centos-stream-10-s390x + - fedora-42-x86_64 + - fedora-42-aarch64 + - fedora-42-s390x + - fedora-43-x86_64 + - fedora-43-aarch64 + - fedora-43-s390x + # Sanity check on secondary targets, fewer architectures just + # because the chance that we break e.g. ppc64le *just* on + # rawhide is basically nil. + - fedora-rawhide-x86_64 + - fedora-rawhide-aarch64 + # Temporarily disabled due to too old Rust...reenable post 9.6 + # - rhel-9-x86_64 + # - rhel-9-aarch64 + + # Build on new commit to main branch + - job: copr_build + trigger: commit + branch: main + owner: rhcontainerbot + project: bootc + enable_net: true + + - job: tests + trigger: pull_request + targets: + - centos-stream-9-x86_64 + - centos-stream-9-aarch64 + - centos-stream-10-x86_64 + - centos-stream-10-aarch64 + - fedora-42-x86_64 + - fedora-42-aarch64 + - fedora-43-x86_64 + - fedora-43-aarch64 + - fedora-rawhide-x86_64 + - fedora-rawhide-aarch64 + tmt_plan: /tmt/plans/integration + tf_extra_params: + environments: + - tmt: + context: + running_env: "packit" + + - job: propose_downstream + trigger: release + dist_git_branches: + - fedora-all + + - job: koji_build + trigger: commit + dist_git_branches: + - fedora-all + + - job: bodhi_update + trigger: commit + dist_git_branches: + # Fedora rawhide updates are created automatically + - fedora-branched diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 000000000..1a5b28b5b --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,39 @@ + +> **Note** +> Do you want to add yourself to this list? Simply fork the repository and open a PR with the required change. +> We have a short description of the adopter types at the bottom of this page. Each type is in alphabetical order. + +# bootc Adopters (direct) + +| Type | Name | Since | Website | Use-Case | +|:-|:-|:-|:-|:-| +Vendor | Red Hat | 2024 | https://redhat.com | Image Based Linux +Vendor | HeliumOS | 2024 | https://www.heliumos.org/ | An atomic desktop operating system for your devices + +# bootc Adopters (indirect, via ostree) + +Bootc is a relatively new project, but much of the underlying technology and goals is *not* new. +The underlying ostree project is over 13 years old (as of 2024). This project also relates +to [rpm-ostree](https://github.com/coreos/rpm-ostree/) which has existed a really long time. + +Not every one of these projects uses bootc directly today, but a toplevel goal of bootc +is to be the successor to ostree, and it is our aim to seamlessly carry forward these users. + +| Type | Name | Since | Website | Use-Case | +|:-|:-|:-|:-|:-| +| Vendor | Endless | 2014 | [link](https://www.endlessos.org/os) | A Completely Free, User-Friendly Operating System Packed with Educational Tools, Games, and More +| Vendor | Red Hat | 2015 | [link](https://redhat.com) | Image Based Linux +| Vendor | Apertis | 2020 | [link](https://apertis.org) | Collaborative OS platform for products +| Vendor | Fedora Project | 2021 | [link](https://fedoraproject.org/atomic-desktops/) | An atomic desktop operating system aimed at good support for container-focused workflows +| Vendor | Playtron GameOS | 2022 | [link](https://www.playtron.one/) | A video game console OS that has integration with the top PC game stores | +| Vendor | Universal Blue | 2022 | [link](https://universal-blue.org/) | The reliability of a Chromebook, but with the flexibility and power of a traditional Linux desktop +| Vendor | Fyra Labs | 2024 | [link](https://fyralabs.com) | Bootc powers an experimental variant of Ultramarine Linux + +### Adopter Types + +**End-user**: The organization runs bootc in production in some way. + +**Integration**: The organization has a product that integrates with bootc, but does not contain bootc. + +**Vendor**: The organization packages bootc in their product and sells it as part of their product. + diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index ef8d0fd4f..000000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -common/AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..98ff24780 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ + + +# Instructions for AI agents + +## CRITICAL instructions for generating commits + +### Signed-off-by + +Human review is required for all code that is generated +or assisted by a large language model. If you +are a LLM, you MUST NOT include a `Signed-off-by` +on any automatically generated git commits. Only explicit +human action or request should include a Signed-off-by. +If for example you automatically create a pull request +and the DCO check fails, tell the human to review +the code and give them instructions on how to add +a signoff. + +### Attribution + +When generating substantial amounts of code, you SHOULD +include an `Assisted-by: TOOLNAME (MODELNAME)`. For example, +`Assisted-by: Goose (Sonnet 4.5)`. + +## Code guidelines + +The [REVIEW.md](REVIEW.md) file describes expectations around +testing, code quality, commit organization, etc. If you're +creating a change, it is strongly encouraged after each +commit and especially when you think a task is complete +to spawn a subagent to perform a review using guidelines (alongside +looking for any other issues). + +If you are performing a review of other's code, the same +principles apply. + +## Follow other guidelines + +Look at the project README.md and look for guidelines +related to contribution, such as a CONTRIBUTING.md +and follow those. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..151cd0efb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,227 @@ +# Contributing to bootc + +Thanks for your interest in contributing! At the current time, +bootc is implemented in Rust, and calls out to important components +which are written in Go (e.g. https://github.com/containers/image) +as well as C (e.g. https://github.com/ostreedev/ostree/). Depending +on what area you want to work on, you'll need to be familiar with +the relevant language. + +## Note: Before writing a big patch + +If you plan to contribute a large change, please get in touch *before* +submitting a pull request by e.g. filing an issue describing your proposed +change. This will help ensure alignment. + +## Development environment + +There isn't a single approach to working on bootc; however +the primary developers tend to use Linux host systems, +and test in Linux VMs. One specifically recommended +approach is to use [toolbox](https://github.com/containers/toolbox/) +to create a containerized development environment +(it's possible, though not necessary to create the toolbox + dev environment using a bootc image as well). + +At the current time most upstream developers use a Fedora derivative +as a base, and the [hack/Containerfile](hack/Containerfile) defaults +to Fedora. However, bootc itself is not intended to strongly tie to a particular +OS or distribution, and patches to handle others are gratefully +accepted! + +## Key recommended ingredients: + +- A development environment (toolbox or a host) with a Rust and C compiler, etc. + While this isn't specific to bootc, you will find the experience of working on Rust + is greatly aided with use of e.g. [rust-analyzer](https://github.com/rust-lang/rust-analyzer/). +- Install [bcvk](https://github.com/bootc-dev/bcvk). + +## Ensure you're familiar with a bootc system + +Worth stating: before you start diving into the code you should understand using +the system as a user and how it works. See the user documentation for that. + +## Understanding the Justfile + +Edit the source code; a simple thing to do is add e.g. +`eprintln!("hello world");` into `run_from_opt` in [crates/lib/src/cli.rs](cli.rs). +You can run `make` or `cargo build` to build that locally. However, a key +next step is to get that binary into a bootc container image. + +Running `just` defaults to `just build` which will build a container +from the current source code; the result will be named `localhost/bootc`. + +### Running an interactive shell in an environment from the container + +You can of course `podman run --rm -ti localhost/bootc bash` to get a shell, +and try running `bootc`. + +### Running container-oriented integration tests + +`just test-container` + +### Running (TMT) integration tests + +A common cycle here is you'll edit e.g. `deploy.rs` and want to run the +tests that perform an upgrade: + +`just test-tmt-one test-20-local-upgrade` + +### Faster iteration cycles + +The test cycle currently builds a disk image and creates a new ephemeral +VM for each test run. + +You can shortcut some iteration cycles by having a more persistent +environment where you run bootc. + +#### Upgrading from the container image + +One good approach is to create a persistent target virtual machine via e.g. +`bcvk libvirt run` (or a cloud VM), and then after doing a `just build` and getting +a container image, you can directly upgrade to that image. + +For the local case, check out [cstor-dist](https://github.com/cgwalters/cstor-dist). +Another alternative is mounting via virtiofs (see e.g. [this PR to bcvk](https://github.com/bootc-dev/bcvk/pull/16)). +If you're using libvirt, see [this document](https://libvirt.org/kbase/virtiofs.html). + +#### Running bootc against a live environment + +If your development environment host is also a bootc system (e.g. a +workstation or a virtual server) one way to shortcut some cycles is just +to directly run the output of the built binary against your host. + +Say for example your host is a Fedora 42 workstation (based on bootc), +then you can `cargo b --release` directly in a Fedora 42 container +or even on your host system, and then directly run e.g. `./target/release/bootc upgrade` +etc. + + +### Debugging via lldb + +The `hack/lldb` directory contains an example of how to use lldb to debug bootc code. +`hack/lldb/deploy.sh` can be used to build and deploy a bootc VM in libvirt with an lldb-server +running as a systemd service. Depending on your editor, you can then connect to the lldb server +to use an interactive debugger, and set up the editor to build and push the new binary to the VM. +`hack/lldb/dap-example-vim.lua` is an example for neovim. + +The VM can be connected to via `ssh test@bootc-lldb` if you have [nss](https://libvirt.org/nss.html) +enabled. + +For some bootc install commands, it's simpler to run the lldb-server in a container, e.g. + +```bash +sudo podman run --pid=host --network=host --privileged --security-opt label=type:unconfined_t -v /var/lib/containers:/var/lib/containers -v /dev:/dev -v .:/output localhost/bootc-lldb lldb-server platform --listen "*:1234" --server +``` + +## Code linting + +The `make validate` target runs checks locally that we gate on +in CI, currently around `cargo fmt` and `cargo clippy`. + +## Running the tests + +First, you can run many unit tests with `cargo test`. + +### container tests + +There's a small set of tests which are designed to run inside a bootc container +and are built into the default container image: + +``` +$ podman run --rm -ti localhost/bootc bootc-integration-tests container +``` + +## Submitting a patch + +The podman project has some [generic useful guidance](https://github.com/containers/podman/blob/main/CONTRIBUTING.md#submitting-pull-requests); +like that project, a "Developer Certificate of Origin" is required. + +### Sign your PRs + +The sign-off is a line at the end of the explanation for the patch. Your +signature certifies that you wrote the patch or otherwise have the right to pass +it on as an open-source patch. The rules are simple: if you can certify +the below (from [developercertificate.org](https://developercertificate.org/)): + +``` +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +660 York Street, Suite 102, +San Francisco, CA 94110 USA + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +Then you just add a line to every git commit message: + + Signed-off-by: Joe Smith + +Use your real name (sorry, no pseudonyms or anonymous contributions.) + +If you set your `user.name` and `user.email` git configs, you can sign your +commit automatically with `git commit -s`. + +### Git commit style + +Please look at `git log` and match the commit log style, which is very +similar to the +[Linux kernel](https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git). + +You may use `Signed-off-by`, but we're not requiring it. + +**General Commit Message Guidelines**: + +1. Title + - Specify the context or category of the changes e.g. `lib` for library changes, `docs` for document changes, `bin/` for command changes, etc. + - Begin the title with the first letter of the first word capitalized. + - Aim for less than 50 characters, otherwise 72 characters max. + - Do not end the title with a period. + - Use an [imperative tone](https://en.wikipedia.org/wiki/Imperative_mood). +2. Body + - Separate the body with a blank line after the title. + - Begin a paragraph with the first letter of the first word capitalized. + - Each paragraph should be formatted within 72 characters. + - Content should be about what was changed and why this change was made. + - If your commit fixes an issue, the commit message should end with `Closes: #`. + +Commit Message example: + +```bash +: Less than 50 characters for subject title + +A paragraph of the body should be within 72 characters. + +This paragraph is also less than 72 characters. +``` + +For more information see [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..0789cb98a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-compression" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bootc" +version = "0.0.0" +dependencies = [ + "anstream", + "anyhow", + "bootc-internal-utils", + "bootc-lib", + "log", + "tokio", + "tracing", +] + +[[package]] +name = "bootc-initramfs-setup" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "composefs", + "composefs-boot", + "fn-error-context", + "libc", + "rustix", + "serde", + "toml", +] + +[[package]] +name = "bootc-internal-blockdev" +version = "0.0.0" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "fn-error-context", + "indoc", + "libc", + "regex", + "rustix", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "bootc-internal-utils" +version = "0.0.0" +dependencies = [ + "anstream", + "anyhow", + "chrono", + "owo-colors", + "rustix", + "serde", + "serde_json", + "shlex", + "similar-asserts", + "static_assertions", + "tempfile", + "tokio", + "tracing", + "tracing-journald", + "tracing-subscriber", +] + +[[package]] +name = "bootc-kernel-cmdline" +version = "0.0.0" +dependencies = [ + "anyhow", + "serde", + "similar-asserts", + "static_assertions", +] + +[[package]] +name = "bootc-lib" +version = "1.10.0" +dependencies = [ + "anstream", + "anstyle", + "anyhow", + "bootc-initramfs-setup", + "bootc-internal-blockdev", + "bootc-internal-utils", + "bootc-kernel-cmdline", + "bootc-mount", + "bootc-sysusers", + "bootc-tmpfiles", + "camino", + "canon-json", + "cap-std-ext", + "cfg-if", + "chrono", + "clap", + "clap_mangen", + "comfy-table", + "composefs", + "composefs-boot", + "composefs-oci", + "etc-merge", + "fn-error-context", + "hex", + "indicatif 0.18.2", + "indoc", + "libc", + "liboverdrop", + "libsystemd", + "linkme", + "nom", + "openssl", + "ostree-ext", + "regex", + "rustix", + "schemars", + "serde", + "serde_ignored", + "serde_json", + "serde_yaml", + "similar-asserts", + "static_assertions", + "tempfile", + "thiserror 2.0.17", + "tini", + "tokio", + "tokio-util", + "toml", + "tracing", + "uapi-version", + "uuid", + "xshell", +] + +[[package]] +name = "bootc-mount" +version = "0.0.0" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "cap-std-ext", + "fn-error-context", + "indoc", + "libc", + "rustix", + "serde", + "tempfile", + "tracing", +] + +[[package]] +name = "bootc-sysusers" +version = "0.1.0" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "cap-std-ext", + "fn-error-context", + "hex", + "indoc", + "rustix", + "similar-asserts", + "tempfile", + "thiserror 2.0.17", + "uzers", +] + +[[package]] +name = "bootc-tmpfiles" +version = "0.1.0" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "cap-std-ext", + "fn-error-context", + "indoc", + "rustix", + "similar-asserts", + "tempfile", + "thiserror 2.0.17", + "uzers", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "canon-json" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ae9f90437d2e2efba2a6c75b8279aa6b8f2f4017e0a4aeb64a76cd9d3a2bab" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 2.0.4", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "camino", + "cap-primitives", + "io-extras", + "io-lifetimes 2.0.4", + "rustix", +] + +[[package]] +name = "cap-std-ext" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7770022cf9ca0e804cdc7725fa6be84a3721e5733ba889b3300689dcdb407fa1" +dependencies = [ + "cap-primitives", + "cap-tempfile", + "libc", + "rustix", +] + +[[package]] +name = "cap-tempfile" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d8ad5cfac469e58e632590f033d45c66415ef7a8aa801409884818036706f5" +dependencies = [ + "camino", + "cap-std", + "rand 0.8.5", + "rustix", + "rustix-linux-procfs", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9acd0bdbbf4b2612d09f52ba61da432140cb10930354079d0d53fafc12968726" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clap_mangen" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "comfy-table" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "comma" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" + +[[package]] +name = "composefs" +version = "0.3.0" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" +dependencies = [ + "anyhow", + "hex", + "log", + "once_cell", + "rand 0.9.2", + "rustix", + "sha2", + "tempfile", + "thiserror 2.0.17", + "tokio", + "xxhash-rust", + "zerocopy", + "zstd", +] + +[[package]] +name = "composefs-boot" +version = "0.3.0" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" +dependencies = [ + "anyhow", + "composefs", + "hex", + "regex-automata", + "thiserror 2.0.17", + "zerocopy", +] + +[[package]] +name = "composefs-oci" +version = "0.3.0" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" +dependencies = [ + "anyhow", + "async-compression", + "composefs", + "containers-image-proxy", + "hex", + "indicatif 0.17.11", + "oci-spec", + "rustix", + "sha2", + "tar", + "tokio", +] + +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "containers-image-proxy" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ca6531917f9b250bf6a1af43603b2e083c192565774451411f9bf4f8bf8f2b" +dependencies = [ + "cap-std-ext", + "futures-util", + "itertools", + "oci-spec", + "rustix", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.109", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console 0.16.1", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "etc-merge" +version = "0.1.0" +dependencies = [ + "anstream", + "anyhow", + "cap-std-ext", + "composefs", + "fn-error-context", + "hex", + "openssl", + "owo-colors", + "rustix", + "tracing", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", +] + +[[package]] +name = "fn-error-context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd66269887534af4b0c3e3337404591daa8dc8b9b2b3db71f9523beb4bafb41" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gvariant" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748b888e9db06c42fef01ec5958d0955fd8813b3d6b5d3bb8b21713806abca04" +dependencies = [ + "gvariant-macro", + "memchr", + "ref-cast", +] + +[[package]] +name = "gvariant-macro" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bee3fdb16eb087e08c38ea50f796c75085659c8a70b6928a8c9f3c7449beb5" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "tokio", + "web-time", +] + +[[package]] +name = "indicatif" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +dependencies = [ + "console 0.16.1", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes 2.0.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "liboverdrop" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e5373d7512834e2fbbe4100111483a99c28ca3818639f67ab2337672301f8e" +dependencies = [ + "log", +] + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsystemd" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c97a761fc86953c5b885422b22c891dbf5bcb9dcc99d0110d6ce4c052759f0" +dependencies = [ + "hmac", + "libc", + "log", + "nix 0.29.0", + "nom", + "once_cell", + "serde", + "sha2", + "thiserror 2.0.17", + "uuid", +] + +[[package]] +name = "libtest-mimic" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5297962ef19edda4ce33aaa484386e0a5b3d7f2f4e037cbeee00503ef6b29d33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linkme" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "mandown" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edef1e8731732e8977534921abcef085c2308096092e7294b1bb2629589908f" +dependencies = [ + "deunicode", + "pulldown-cmark", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "oci-spec" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb4684653aeaba48dea019caa17b2773e1212e281d50b6fa759f36fe032239d" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.17", +] + +[[package]] +name = "ocidir" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e746e3e6a7bb57a72ea4f0b8085f84aedaab1d537a5dc5488fd3cd5af0cd67" +dependencies = [ + "camino", + "canon-json", + "cap-std-ext", + "chrono", + "flate2", + "hex", + "oci-spec", + "openssl", + "serde", + "serde_json", + "tar", + "thiserror 2.0.17", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssh-keys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "351339c4d45e6bdf2defef3ef1ce0b153810bd59b171b92b6a42e7bb0f32a4ad" +dependencies = [ + "base64 0.21.7", + "byteorder", + "md-5", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ostree" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7b42858b9c42999daefaf06f2a60a0dfbb6995a7b87deb0a873f2fb447c269" +dependencies = [ + "base64 0.20.0", + "bitflags 1.3.2", + "gio", + "glib", + "hex", + "libc", + "once_cell", + "ostree-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "ostree-ext" +version = "0.15.3" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "canon-json", + "cap-std-ext", + "chrono", + "clap", + "clap_mangen", + "comfy-table", + "composefs", + "composefs-boot", + "composefs-oci", + "containers-image-proxy", + "flate2", + "fn-error-context", + "futures-util", + "gvariant", + "hex", + "indexmap", + "indicatif 0.18.2", + "indoc", + "io-lifetimes 3.0.1", + "libc", + "libsystemd", + "ocidir", + "openssl", + "ostree", + "ostree-ext", + "pin-project", + "quickcheck", + "regex", + "rustix", + "serde", + "serde_json", + "similar-asserts", + "tar", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "xshell", + "zstd", +] + +[[package]] +name = "ostree-sys" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aaaff741a79d31706e713a3971cfc670dfd969321e758b758b3fe79e3cdad49" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "env_logger", + "log", + "rand 0.8.5", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rexpect" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1bcd4ac488e9d2d726d147031cceff5cff6425011ff1914049739770fa4726" +dependencies = [ + "comma", + "nix 0.30.1", + "regex", + "tempfile", + "thiserror 2.0.17", +] + +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.109", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + +[[package]] +name = "similar-asserts" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b441962c817e33508847a22bd82f03a30cff43642dc2fae8b050566121eb9a" +dependencies = [ + "console 0.15.11", + "similar", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "system-reinstall-bootc" +version = "0.1.9" +dependencies = [ + "anstream", + "anyhow", + "bootc-internal-utils", + "bootc-mount", + "clap", + "crossterm", + "dialoguer", + "fn-error-context", + "indoc", + "log", + "openssh-keys", + "rustix", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tracing", + "uzers", + "which", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tests-integration" +version = "0.1.0" +dependencies = [ + "anyhow", + "bootc-kernel-cmdline", + "camino", + "cap-std-ext", + "clap", + "fn-error-context", + "indoc", + "libtest-mimic", + "oci-spec", + "rexpect", + "rustix", + "serde", + "serde_json", + "tempfile", + "xshell", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tini" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004df4c5f0805eb5f55883204a514cfa43a6d924741be29e871753a53d5565a" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-journald" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0b4143302cf1022dac868d521e36e8b27691f72c84b3311750d5188ebba657" +dependencies = [ + "libc", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uapi-version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "849f6b1fe8a0fb07170737d7f3acf72cac5462fb3f4e86614474a49f7fac3b65" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "uzers" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df81ff504e7d82ad53e95ed1ad5b72103c11253f39238bcc0235b90768a97dd" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.109", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.10.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anstream", + "anyhow", + "camino", + "chrono", + "clap", + "fn-error-context", + "mandown", + "owo-colors", + "rand 0.9.2", + "serde", + "serde_json", + "serde_yaml", + "tar", + "tempfile", + "toml", + "xshell", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..ff0288f7a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,114 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[profile.dev] +opt-level = 1 # No optimizations are too slow for us. + +[profile.release] +lto = "thin" +# We use FFI so this is safest +panic = "abort" +# We assume we're being delivered via e.g. RPM which supports split debuginfo +debug = true + +[profile.thin] +# drop bootc size when split debuginfo is not available and go a step +# further in size optimization (when tested from 140mb, to 12mb without +# symbols/debuginfo, to 5.8mb with extra optimizations) +# https://github.com/johnthagen/min-sized-rust +# cargo build --profile=thin +inherits = "release" +debug = false # Re-strip debug symbols +strip = true # Strip symbols from binary +lto = true # Use full lto to remove dead code +opt-level = 's' # Optimize for size with vector vectorization +codegen-units = 1 # Reduce number of codegen units to increase optimizations + +[profile.releaselto] +codegen-units = 1 +inherits = "release" +lto = "yes" + +[workspace.dependencies] +anstream = "0.6" +anyhow = "1.0.82" +camino = "1.1.6" +canon-json = "0.2.1" +cap-std-ext = "4.0.3" +cfg-if = "1.0" +chrono = { version = "0.4.38", default-features = false } +clap = "4.5.4" +clap_mangen = { version = "0.2.20" } +# Reviewers (including AI tools): This hash is duplicated as there's not a much better way to handle it right now +composefs = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs", features = ["rhel9"] } +composefs-boot = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs-boot" } +composefs-oci = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs-oci" } +fn-error-context = "0.2.1" +hex = "0.4.3" +indicatif = "0.18.0" +indoc = "2.0.5" +libc = "0.2.154" +log = "0.4.21" +openssl = "0.10.72" +owo-colors = { version = "4" } +regex = "1.10.4" +# For the same rationale as https://github.com/coreos/rpm-ostree/commit/27f3f4b77a15f6026f7e1da260408d42ccb657b3 +rustix = { "version" = "1", features = ["use-libc", "thread", "net", "fs", "system", "process", "mount"] } +serde = "1.0.199" +serde_json = "1.0.116" +shlex = "1.3" +similar-asserts = "1.5.0" +static_assertions = "1.1.0" +tempfile = "3.10.1" +thiserror = "2.0.11" +tokio = ">= 1.37.0" +tokio-util = { features = ["io-util"], version = "0.7.10" } +toml = "0.9.5" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tracing-journald = "0.3.1" +uzers = "0.12" +xshell = "0.2.6" + +# See https://github.com/coreos/cargo-vendor-filterer +[workspace.metadata.vendor-filter] +# For now we only care about tier 1+2 Linux. (In practice, it's unlikely there is a tier3-only Linux dependency) +platforms = ["*-unknown-linux-gnu"] +tier = "2" +all-features = true +exclude-crate-paths = [ { name = "libz-sys", exclude = "src/zlib" }, + { name = "libz-sys", exclude = "src/zlib-ng" }, + # rustix includes pre-generated assembly for linux_raw, which we don't use + { name = "rustix", exclude = "src/imp/linux_raw" }, + # Test files that include binaries + { name = "system-deps", exclude = "src/tests" }, + # This stuff is giant, trim unused versions + { name = "k8s-openapi", exclude = "src/v1_25" }, + { name = "k8s-openapi", exclude = "src/v1_27" }, + ] + +# This is an made up key for external binary dependencies. +# setpriv is a proxy for util-linux, and systemctl is a proxy for systemd. +[workspace.metadata.binary-dependencies] +bins = ["skopeo", "podman", "ostree", "zstd", "setpriv", "systemctl", "chcon"] + +[workspace.lints.rust] +# Require an extra opt-in for unsafe +unsafe_code = "deny" +# Absolutely must handle errors +unused_must_use = "forbid" +missing_docs = "deny" +missing_debug_implementations = "deny" +# Feel free to comment this one out locally during development of a patch. +dead_code = "deny" + +[workspace.lints.clippy] +disallowed_methods = "deny" +# These should only be in local code +dbg_macro = "deny" +todo = "deny" +# These two are in my experience the lints which are most likely +# to trigger, and among the least valuable to fix. +needless_borrow = "allow" +needless_borrows_for_generic_args = "allow" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d849c3ed4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# Build this project from source and write the updated content +# (i.e. /usr/bin/bootc and systemd units) to a new derived container +# image. See the `Justfile` for an example + +# Note this is usually overridden via Justfile +ARG base=quay.io/centos-bootc/centos-bootc:stream10 + +# This first image captures a snapshot of the source code, +# note all the exclusions in .dockerignore. +FROM scratch as src +COPY . /src + +# And this image only captures contrib/packaging separately +# to ensure we have more precise cache hits. +FROM scratch as packaging +COPY contrib/packaging / + +FROM $base as base +# Mark this as a test image (moved from --label build flag to fix layer caching) +LABEL bootc.testimage="1" + +# This image installs build deps, pulls in our source code, and installs updated +# bootc binaries in /out. The intention is that the target rootfs is extracted from /out +# back into a final stage (without the build deps etc) below. +FROM base as buildroot +# Flip this off to disable initramfs code +ARG initramfs=1 +# Version for RPM build (optional, computed from git in Justfile) +ARG pkgversion= +# This installs our buildroot, and we want to cache it independently of the rest. +# Basically we don't want changing a .rs file to blow out the cache of packages. +RUN --mount=type=bind,from=packaging,target=/run/packaging /run/packaging/install-buildroot +# Now copy the rest of the source +COPY --from=src /src /src +WORKDIR /src +# See https://www.reddit.com/r/rust/comments/126xeyx/exploring_the_problem_of_faster_cargo_docker/ +# We aren't using the full recommendations there, just the simple bits. +# First we download all of our Rust dependencies +RUN --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome cargo fetch + +FROM buildroot as build +# Build RPM directly from source, using cached target directory +RUN --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome --network=none RPM_VERSION=${pkgversion} /src/contrib/packaging/build-rpm + +# This "build" includes our unit tests +FROM build as units +# A place that we're more likely to be able to set xattrs +VOLUME /var/tmp +ENV TMPDIR=/var/tmp +RUN --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome --network=none make install-unit-tests + +# This just does syntax checking +FROM build as validate +RUN --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome --network=none make validate + +# The final image that derives from the original base and adds the release binaries +FROM base +# See the Justfile for possible variants +ARG variant +RUN --mount=type=bind,from=packaging,target=/run/packaging /run/packaging/configure-variant "${variant}" +# Support overriding the rootfs at build time conveniently +ARG rootfs= +RUN --mount=type=bind,from=packaging,target=/run/packaging /run/packaging/configure-rootfs "${variant}" "${rootfs}" +# Inject additional content +COPY --from=packaging /usr-extras/ /usr/ +# Install the RPM built in the build stage +# This replaces the manual file deletion hack and COPY, ensuring proper package management +# Use rpm -Uvh with --oldpackage to allow replacing with dev version +COPY --from=build /out/*.rpm /tmp/ +RUN --mount=type=bind,from=packaging,target=/run/packaging --network=none /run/packaging/install-rpm-and-setup /tmp +# Finally, testour own linting +RUN bootc container lint --fatal-warnings diff --git a/Dockerfile.cfsuki b/Dockerfile.cfsuki new file mode 100644 index 000000000..2fd9bb047 --- /dev/null +++ b/Dockerfile.cfsuki @@ -0,0 +1,84 @@ +# Override via --build-arg=base= to use a different base +ARG base=localhost/bootc +# This is where we get the tools to build the UKI +ARG buildroot=quay.io/centos/centos:stream10 +FROM $base AS base + +FROM $buildroot as buildroot-base +RUN < "common/.devcontainer/${os}/devcontainer.json" - done - -# Validate devcontainer.json syntax and that per-OS configs are in sync -devcontainer-validate: - #!/bin/bash - set -euo pipefail - template=common/.devcontainer/devcontainer.json - for os in debian ubuntu; do - if ! diff -u "common/.devcontainer/${os}/devcontainer.json" <(sed "s/devenv-debian/devenv-${os}/g" "$template"); then - echo "ERROR: common/.devcontainer/${os}/devcontainer.json is out of sync with template" - echo "Run 'just devcontainer-generate' to fix" - exit 1 - fi - done - echo "All devcontainer configs are in sync" - npx --yes @devcontainers/cli read-configuration --workspace-folder . - -# Build devenv Debian image with local tag -devenv-build-debian: - cd devenv && podman build --jobs=4 -f Containerfile.debian -t localhost/bootc-devenv-debian . - -# Build devenv Ubuntu 24.04 image with local tag -devenv-build-ubuntu: - cd devenv && podman build --jobs=4 -f Containerfile.ubuntu -t localhost/bootc-devenv-ubuntu . - -# Build devenv CentOS Stream 10 image with local tag -devenv-build-c10s: - cd devenv && podman build --jobs=4 -f Containerfile.c10s -t localhost/bootc-devenv-c10s . - -# Build devenv image with local tag (defaults to Debian) -devenv-build: devenv-build-debian - -# Test devcontainer with a locally built image -# Usage: just devcontainer-test -# Example: just devcontainer-test debian -devcontainer-test os: - #!/bin/bash - set -euo pipefail - config=common/.devcontainer/{{os}}/devcontainer.json - # Tag local image to match what devcontainer.json expects - podman tag localhost/bootc-devenv-{{os}}:latest ghcr.io/bootc-dev/devenv-{{os}}:latest - npx --yes @devcontainers/cli up \ - --workspace-folder . \ - --docker-path podman \ - --config "$config" \ - --remove-existing-container - npx @devcontainers/cli exec \ - --workspace-folder . \ - --docker-path podman \ - --config "$config" \ - /usr/libexec/devenv-selftest.sh +# The default entrypoint to working on this project. +# Commands here typically wrap e.g. `podman build` or +# other tools like `bcvk` which might launch local virtual machines. +# +# See also `Makefile` and `xtask.rs`. Commands which end in `-local` +# skip containerization or virtualization (and typically just proxy `make`). +# +# Rules written here are *often* used by the Github Action flows, +# and should support being configurable where that makes sense (e.g. +# the `build` rule supports being provided a base image). + +# -------------------------------------------------------------------- + +# This image is just the base image plus our updated bootc binary +base_img := "localhost/bootc" +# Derives from the above and adds nushell, cloudinit etc. +integration_img := base_img + "-integration" +# Has a synthetic upgrade +integration_upgrade_img := integration_img + "-upgrade" + +# ostree: The default +# composefs-sealeduki-sdboot: A system with a sealed composefs using systemd-boot +variant := env("BOOTC_variant", "ostree") +base := env("BOOTC_base", "quay.io/centos-bootc/centos-bootc:stream10") + +testimage_label := "bootc.testimage=1" +# We used to have --jobs=4 here but sometimes that'd hit this +# ``` +# [2/3] STEP 2/2: RUN --mount=type=bind,from=context,target=/run/context < Using cache b068d42ac7491067cf5fafcaaf2f09d348e32bb752a22c85bbb87f266409554d +# --> b068d42ac749 +# + cd /run/context/ +# /bin/sh: line 3: cd: /run/context/: Permission denied +# ``` +# TODO: Gather more info and file a buildah bug +base_buildargs := "" +buildargs := "--build-arg=base=" + base + " --build-arg=variant=" + variant + +# Build the container image from current sources. +# Note commonly you might want to override the base image via e.g. +# `just build --build-arg=base=quay.io/fedora/fedora-bootc:42` +build: + podman build {{base_buildargs}} -t {{base_img}}-bin {{buildargs}} . + ./tests/build-sealed {{variant}} {{base_img}}-bin {{base_img}} + +# Build a sealed image from current sources. +build-sealed: + @just --justfile {{justfile()}} variant=composefs-sealeduki-sdboot build + +# Build packages (e.g. RPM) using a container buildroot +_packagecontainer: + #!/bin/bash + set -xeuo pipefail + # Compute version from git (matching xtask.rs gitrev logic) + if VERSION=$(git describe --tags --exact-match 2>/dev/null); then + VERSION="${VERSION#v}" + VERSION="${VERSION//-/.}" + else + COMMIT=$(git rev-parse HEAD | cut -c1-10) + COMMIT_TS=$(git show -s --format=%ct) + TIMESTAMP=$(date -u -d @${COMMIT_TS} +%Y%m%d%H%M) + VERSION="${TIMESTAMP}.g${COMMIT}" + fi + echo "Building RPM with version: ${VERSION}" + podman build {{base_buildargs}} {{buildargs}} --build-arg=pkgversion=${VERSION} -t localhost/bootc-pkg --target=build . + +# Build a packages (e.g. RPM) into target/ +# Any old packages will be removed. +package: _packagecontainer + mkdir -p target + rm -vf target/*.rpm + podman run --rm localhost/bootc-pkg tar -C /out/ -cf - . | tar -C target/ -xvf - + +# This container image has additional testing content and utilities +build-integration-test-image: build + cd hack && podman build {{base_buildargs}} -t {{integration_img}}-bin -f Containerfile . + ./tests/build-sealed {{variant}} {{integration_img}}-bin {{integration_img}} + # Keep these in sync with what's used in hack/lbi + podman pull -q --retry 5 --retry-delay 5s quay.io/curl/curl:latest quay.io/curl/curl-base:latest registry.access.redhat.com/ubi9/podman:latest + +# Build+test using the `composefs-sealeduki-sdboot` variant. +test-composefs: + # These first two are currently a distinct test suite from tmt that directly + # runs an integration test binary in the base image via bcvk + just variant=composefs-sealeduki-sdboot build + cargo run --release -p tests-integration -- composefs-bcvk {{base_img}} + # We're trying to move more testing to tmt + just variant=composefs-sealeduki-sdboot test-tmt readonly local-upgrade-reboot + +# Only used by ci.yml right now +build-install-test-image: build-integration-test-image + cd hack && podman build {{base_buildargs}} -t {{integration_img}}-install -f Containerfile.drop-lbis + +# These tests accept the container image as input, and may spawn it. +run-container-external-tests: + ./tests/container/run {{base_img}} + +# We build the unit tests into a container image +build-units: + podman build {{base_buildargs}} --target units -t localhost/bootc-units . + +# Perform validation (build, linting) in a container build environment +validate: + podman build {{base_buildargs}} --target validate . + +# Run tmt-based test suites using local virtual machines with +# bcvk. +# +# To run an individual test, pass it as an argument like: +# `just test-tmt readonly` +test-tmt *ARGS: build-integration-test-image _build-upgrade-image + @just test-tmt-nobuild {{ARGS}} + +# Generate a local synthetic upgrade +_build-upgrade-image: + cat tmt/tests/Dockerfile.upgrade | podman build -t {{integration_upgrade_img}}-bin --from={{integration_img}}-bin - + ./tests/build-sealed {{variant}} {{integration_upgrade_img}}-bin {{integration_upgrade_img}} + +# Assume the localhost/bootc-integration image is up to date, and just run tests. +# Useful for iterating on tests quickly. +test-tmt-nobuild *ARGS: + cargo xtask run-tmt --env=BOOTC_variant={{variant}} --env=BOOTC_upgrade_image={{integration_upgrade_img}} {{integration_img}} {{ARGS}} + +# Cleanup all test VMs created by tmt tests +tmt-vm-cleanup: + bcvk libvirt rm --stop --force --label bootc.test=1 + +# Run tests (unit and integration) that are containerized +test-container: build-units build-integration-test-image + podman run --rm --read-only localhost/bootc-units /usr/bin/bootc-units + # Pass these through for cross-checking + podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} {{integration_img}} bootc-integration-tests container + +# Remove all container images built (locally) via this Justfile, by matching a label +clean-local-images: + podman images --filter "label={{testimage_label}}" + podman images --filter "label={{testimage_label}}" --format "{{{{.ID}}" | xargs -r podman rmi -f + +# Print the container image reference for a given short $ID-VERSION_ID +pullspec-for-os NAME: + @jq -r --arg v "{{NAME}}" '.[$v]' < hack/os-image-map.json + +build-mdbook: + cd docs && podman build {{base_buildargs}} -t localhost/bootc-mdbook -f Dockerfile.mdbook + +# Generate the rendered HTML to the target DIR directory +build-mdbook-to DIR: build-mdbook + #!/bin/bash + set -xeuo pipefail + # Create a temporary container to extract the built docs + container_id=$(podman create localhost/bootc-mdbook) + podman cp ${container_id}:/src/book {{DIR}} + podman rm -f ${container_id} + +mdbook-serve: build-mdbook + #!/bin/bash + set -xeuo pipefail + podman run --init --replace -d --name bootc-mdbook --rm --publish 127.0.0.1::8000 localhost/bootc-mdbook + echo http://$(podman port bootc-mdbook 8000/tcp) + +# Update all generated files (man pages and JSON schemas) +# +# This is the unified command that: +# - Auto-discovers new CLI commands and creates man page templates +# - Syncs CLI options from Rust code to existing man page templates +# - Updates JSON schema files +# +# Use this after adding, removing, or modifying CLI options or schemas. +update-generated: + cargo run -p xtask update-generated diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..0488a132a --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,21 @@ +# Maintainers + +The current Maintainers Group for the bootc Project consists of: + +| Name | GitHub ID | Employer | Responsibilities | +| ---- | ---- | ---- | ---- | +| Chris Kyrouac | [ckyrouac](https://github.com/orgs/bootc-dev/people/ckyrouac) | Red Hat | Approver | +| Colin Walters | [cgwalters](https://github.com/orgs/bootc-dev/people/cgwalters) | Red Hat | Approver | +| John Eckersberg | [jeckersb](https://github.com/orgs/bootc-dev/people/jeckersb) | Red Hat | Approver | +| Xiaofeng Wang | [henrywang](https://github.com/orgs/bootc-dev/people/henrywang) | Red Hat | Approver | +| Gursewak Mangat | [gursewak1997](https://github.com/orgs/bootc-dev/people/gursewak1997)| Red Hat | Approver | +| Joseph Marrero | [jmarrero](https://github.com/orgs/bootc-dev/people/jmarrero) | Red Hat | Approver | + +# Community Managers + +This group can represent the project for administrative and program manager duties. Examples: CNCF Service Desk tickets, coordinating with CNCF Project and Events teams, and LFX Administration. No code or code review rights. + +| Name | GitHub ID | Employer | Responsibilities | +| ---- | ---- | ---- | ---- | +| Laura Santamaria | [nimbinatus](https://github.com/nimbinatus) | Red Hat | Representative | +| Mohan Shash | [mohan-shash](https://github.com/mohan-shash) | Red Hat | Representative | diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..bc523276b --- /dev/null +++ b/Makefile @@ -0,0 +1,128 @@ +# Understanding Makefile vs Justfile: +# +# This file should primarily *only* involve +# invoking tools which *do not* have side effects outside +# of the current working directory. In particular, this file MUST NOT: +# - Spawn podman or virtualization tools +# - Invoke `sudo` +# +# Stated positively, the code invoked from here is only expected to +# operate as part of "a build" that results in a bootc binary +# plus data files. The two key operations are `make` +# and `make install`. As this is Rust, the generated binaries are in +# the current directory under `target/` by default. Some rules may place +# other generated files there. +# +# We expect code run from here is (or can be) inside a container with low +# privileges - running as a nonzero UID even. +# +# Understanding Makefile vs xtask.rs: Basically use xtask.rs if what +# you're doing would turn into a mess of bash code, whether inline here +# or externally in e.g. ./ci/somebashmess.sh etc. +# +# In particular, the Justfile contains rules for things like integration +# tests which might spawn VMs, etc. + +prefix ?= /usr + +# Enable rhsm if we detect the build environment is RHEL-like. +# We may in the future also want to include Fedora+derivatives as +# the code is really tiny. +# (Note we should also make installation of the units conditional on the rhsm feature) +CARGO_FEATURES ?= $(shell . /usr/lib/os-release; if echo "$$ID_LIKE" |grep -qF rhel; then echo rhsm; fi) +CARGO_PROFILE ?= release + +all: bin manpages + +bin: + cargo build --profile $(CARGO_PROFILE) --features "$(CARGO_FEATURES)" + +.PHONY: manpages +manpages: + cargo run --profile $(CARGO_PROFILE) --package xtask -- manpages + +STORAGE_RELATIVE_PATH ?= $(shell realpath -m -s --relative-to="$(prefix)/lib/bootc/storage" /sysroot/ostree/bootc/storage) +install: + install -D -m 0755 -t $(DESTDIR)$(prefix)/bin target/release/bootc + install -D -m 0755 -t $(DESTDIR)$(prefix)/bin target/release/system-reinstall-bootc + install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/bound-images.d + install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/kargs.d + ln -s "$(STORAGE_RELATIVE_PATH)" "$(DESTDIR)$(prefix)/lib/bootc/storage" + install -D -m 0755 crates/cli/bootc-generator-stub $(DESTDIR)$(prefix)/lib/systemd/system-generators/bootc-systemd-generator + install -d $(DESTDIR)$(prefix)/lib/bootc/install + install -D -m 0644 -t $(DESTDIR)$(prefix)/share/man/man5 target/man/*.5; \ + install -D -m 0644 -t $(DESTDIR)$(prefix)/share/man/man8 target/man/*.8; \ + install -D -m 0644 -t $(DESTDIR)/$(prefix)/lib/systemd/system systemd/*.service systemd/*.timer systemd/*.path systemd/*.target + install -D -m 0644 -t $(DESTDIR)/$(prefix)/share/doc/bootc/baseimage/base/usr/lib/ostree/ baseimage/base/usr/lib/ostree/prepare-root.conf + install -d -m 755 $(DESTDIR)/$(prefix)/share/doc/bootc/baseimage/base/sysroot + cp -PfT baseimage/base/ostree $(DESTDIR)/$(prefix)/share/doc/bootc/baseimage/base/ostree + # Ensure we've cleaned out any possibly older files + rm -vrf $(DESTDIR)$(prefix)/share/doc/bootc/baseimage/dracut + rm -vrf $(DESTDIR)$(prefix)/share/doc/bootc/baseimage/systemd + # Copy dracut and systemd config files + cp -Prf baseimage/dracut $(DESTDIR)$(prefix)/share/doc/bootc/baseimage/dracut + cp -Prf baseimage/systemd $(DESTDIR)$(prefix)/share/doc/bootc/baseimage/systemd + # Install fedora-bootc-destructive-cleanup in fedora derivatives + ID=$$(. /usr/lib/os-release && echo $$ID); \ + ID_LIKE=$$(. /usr/lib/os-release && echo $$ID_LIKE); \ + if [ "$$ID" = "fedora" ] || [[ "$$ID_LIKE" == *"fedora"* ]]; then \ + install -D -m 0755 -t $(DESTDIR)/$(prefix)/lib/bootc contrib/scripts/fedora-bootc-destructive-cleanup; \ + fi + install -D -m 0644 -t $(DESTDIR)/usr/lib/systemd/system crates/initramfs/*.service + install -D -m 0755 target/release/bootc-initramfs-setup $(DESTDIR)/usr/lib/bootc/initramfs-setup + install -D -m 0755 -t $(DESTDIR)/usr/lib/dracut/modules.d/51bootc crates/initramfs/dracut/module-setup.sh + +# Run this to also take over the functionality of `ostree container` for example. +# Only needed for OS/distros that have callers invoking `ostree container` and not bootc. +install-ostree-hooks: + install -d $(DESTDIR)$(prefix)/libexec/libostree/ext + for x in ostree-container ostree-ima-sign ostree-provisional-repair; do \ + ln -sf ../../../bin/bootc $(DESTDIR)$(prefix)/libexec/libostree/ext/$$x; \ + done + +# Install the main binary, the ostree hooks, and the integration test suite. +install-all: install install-ostree-hooks + install -D -m 0755 target/release/tests-integration $(DESTDIR)$(prefix)/bin/bootc-integration-tests + +build-unit-tests: + cargo t --no-run + +# We separate the build of the unit tests from actually running them in some cases +install-unit-tests: build-unit-tests + cargo t --no-run --frozen + install -D -m 0755 -t $(DESTDIR)/usr/lib/bootc/units/ $$(cargo t --no-run --message-format=json | jq -r 'select(.profile.test == true and .executable != null) | .executable') + install -d -m 0755 /usr/bin/ + echo -e '#!/bin/bash\nset -xeuo pipefail\nfor f in /usr/lib/bootc/units/*; do echo $$f && $$f; done' > $(DESTDIR)/usr/bin/bootc-units && chmod a+x $(DESTDIR)/usr/bin/bootc-units + +test-bin-archive: all + $(MAKE) install-all DESTDIR=tmp-install && $(TAR_REPRODUCIBLE) --zstd -C tmp-install -cf target/bootc.tar.zst . && rm tmp-install -rf + +# This gates CI by default. Note that for clippy, we gate on +# only the clippy correctness and suspicious lints, plus a select +# set of default rustc warnings. +# We intentionally don't gate on this for local builds in cargo.toml +# because it impedes iteration speed. +CLIPPY_CONFIG = -A clippy::all -D clippy::correctness -D clippy::suspicious -D clippy::disallowed-methods -Dunused_imports -Ddead_code +validate: + cargo fmt -- --check -l + cargo test --no-run + (cd crates/ostree-ext && cargo check --no-default-features) + (cd crates/lib && cargo check --no-default-features) + cargo clippy -- $(CLIPPY_CONFIG) + env RUSTDOCFLAGS='-D warnings' cargo doc --lib +.PHONY: validate +fix-rust: + cargo clippy --fix --allow-dirty -- $(CLIPPY_CONFIG) +.PHONY: fix-rust + +update-generated: + cargo xtask update-generated +.PHONY: update-generated + +vendor: + cargo xtask $@ +.PHONY: vendor + +package-rpm: + cargo xtask $@ +.PHONY: package-rpm diff --git a/README.md b/README.md index 16e7efc84..4339cb451 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,74 @@ -# CI Infrastructure +![bootc logo](https://raw.githubusercontent.com/containers/common/main/logos/bootc-logo-full-vert.png) +# bootc -This repository provides centralized configuration and automation for the -[bootc-dev](https://github.com/bootc-dev) organization. +Transactional, in-place operating system updates using OCI/Docker container images. -## What's Here +## Motivation -- **[Development Environment](devenv/README.md)** - Containerized dev environment with - necessary tools -- **Scheduled Global Actions** - Automation that runs across the organization: - - [Renovate](#renovate) - Centralized dependency update management - - Container Garbage Collection - Automated cleanup of old images from GHCR +The original Docker container model of using "layers" to model +applications has been extremely successful. This project +aims to apply the same technique for bootable host systems - using +standard OCI/Docker containers as a transport and delivery format +for base operating system updates. + +The container image includes a Linux kernel (in e.g. `/usr/lib/modules`), +which is used to boot. At runtime on a target system, the base userspace is +*not* itself running in a "container" by default. For example, assuming +systemd is in use, systemd acts as pid1 as usual - there's no "outer" process. +More about this in the docs; see below. + +## Status + +The CLI and API are considered stable. We will ensure that every existing system +can be upgraded in place seamlessly across any future changes. ## Documentation -- [Repository Structure](docs/repository-structure.md) - Organization-wide repository standards +See the [project documentation](https://bootc-dev.github.io/bootc/). + +## Versioning + +Although bootc is not released to crates.io as a library, version +numbers are expected to follow [semantic +versioning](https://semver.org/) standards. This practice began with +the release of version 1.2.0; versions prior may not adhere strictly +to semver standards. + +## Adopters (base and end-user images) + +The bootc CLI is just a client system; it is not tied to any particular +operating system or Linux distribution. You very likely want to actually +start by looking at [ADOPTERS.md](ADOPTERS.md). -## Renovate +## Community discussion -Renovate runs centrally from this repository using autodiscovery. All org repositories -automatically inherit the shared configuration from `renovate-shared-config.json`. +- [Github discussion forum](https://github.com/containers/bootc/discussions) for async discussion +- [#bootc-dev on CNCF Slack](https://cloud-native.slack.com/archives/C08SKSQKG1L) for live chat +- Recurring live meeting hosted on [CNCF Zoom](https://zoom-lfx.platform.linuxfoundation.org/meeting/96540875093?password=7889708d-c520-4565-90d3-ce9e253a1f65) each Friday at 15:30 UTC. -Key features of the shared config: -- Signed-off commits for all dependency updates -- Grouped updates by ecosystem (GitHub Actions, Rust, Docker, npm) -- Custom regex managers for Containerfiles and version files -- Disabled digest pinning for container images +This project is also tightly related to the previously mentioned Fedora/CentOS bootc project, +and many developers monitor the relevant discussion forums there. In particular there's a +Matrix channel and a weekly video call meeting for example: . -### For Repository Maintainers +## Developing bootc -Add a `renovate.json` to your repository to inherit the shared config: +Are you interested in working on bootc? Great! See our [CONTRIBUTING.md](CONTRIBUTING.md) guide. +There is also a list of [MAINTAINERS.md](MAINTAINERS.md). -```json -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>bootc-dev/infra:renovate-shared-config.json"] -} -``` +## Governance +See [GOVERNANCE.md](GOVERNANCE.md) for project governance details. -Override or extend settings as needed for your repository. +## Badges -### Manual Runs +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/10113/badge)](https://www.bestpractices.dev/projects/10113) +[![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=bootc)](https://insights.linuxfoundation.org/project/bootc) +[![LFX Contributors](https://insights.linuxfoundation.org/api/badge/contributors?project=bootc)](https://insights.linuxfoundation.org/project/bootc) +[![LFX Active Contributors](https://insights.linuxfoundation.org/api/badge/active-contributors?project=bootc)](https://insights.linuxfoundation.org/project/bootc) -Trigger Renovate manually from the [Actions tab](../../actions/workflows/renovate.yml) -with optional debug logging. +### Code of Conduct -## License +The bootc project is a [Cloud Native Computing Foundation (CNCF) Sandbox project](https://www.cncf.io/sandbox-projects/) +and adheres to the [CNCF Community Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). -MIT OR Apache-2.0 +--- +The Linux Foundation® (TLF) has registered trademarks and uses trademarks. For a list of TLF trademarks, see [Trademark Usage](https://www.linuxfoundation.org/trademark-usage/). diff --git a/common/REVIEW.md b/REVIEW.md similarity index 100% rename from common/REVIEW.md rename to REVIEW.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..63d3e0425 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Reporting a Vulnerability + +If you find a potential security vulnerability in bootc, please report it by following these steps: + +### 1. **Use the GitHub Security Tab** +This repository is set up to allow vulnerability reports through GitHub's Security Advisories feature. To report a vulnerability: + +1. Navigate to the repository's main page. +2. Select the [**Security**](https://github.com/bootc-dev/bootc/security) tab. +3. Select **Advisories** from the left-hand sidebar. +4. Click on **Report a vulnerability**. +5. Fill in the required details and submit the report. + +Following this process will create a private advisory for our maintainers to review. + +### 2. **Do Not Open Public Pull Requests, Issues, or Discussions** +Please **do not** discuss the issue, create PRs, or start discussions about the vulnerability. This ensures the vulnerability is not widely exploited before a fix is provided. diff --git a/baseimage/README.md b/baseimage/README.md new file mode 100644 index 000000000..51dc2de6d --- /dev/null +++ b/baseimage/README.md @@ -0,0 +1,14 @@ +# Recommended image content + +The subdirectories here are recommended to be installed alongside +bootc in `/usr/share/doc/bootc/baseimage` - they act as reference +sources of content. + +- [base](base): At the current time the content here is effectively + a hard requirement. It's not much, just an ostree configuration + enabling composefs, plus the default `sysroot` directory (which + may go away in the future) and the `ostree` symlink into `sysroot`. +- [dracut](dracut): Default/basic dracut configuration; at the current + time this basically just enables ostree in the initramfs. +- [systemd](systemd): Optional configuration for systemd, currently + this has configuration for kernel-install enabling rpm-ostree integration. diff --git a/baseimage/base/ostree b/baseimage/base/ostree new file mode 120000 index 000000000..99bd5a25e --- /dev/null +++ b/baseimage/base/ostree @@ -0,0 +1 @@ +sysroot/ostree \ No newline at end of file diff --git a/baseimage/base/sysroot/.gitignore b/baseimage/base/sysroot/.gitignore new file mode 100644 index 000000000..ffead3ebb --- /dev/null +++ b/baseimage/base/sysroot/.gitignore @@ -0,0 +1,3 @@ +# A trick to keep an empty directory in git +* +!.gitignore diff --git a/baseimage/base/usr/lib/ostree/prepare-root.conf b/baseimage/base/usr/lib/ostree/prepare-root.conf new file mode 100644 index 000000000..2faae22bc --- /dev/null +++ b/baseimage/base/usr/lib/ostree/prepare-root.conf @@ -0,0 +1,2 @@ +[composefs] +enabled = true diff --git a/baseimage/dracut/usr/lib/dracut.conf.d/10-bootc-base.conf b/baseimage/dracut/usr/lib/dracut.conf.d/10-bootc-base.conf new file mode 100644 index 000000000..5352cb209 --- /dev/null +++ b/baseimage/dracut/usr/lib/dracut.conf.d/10-bootc-base.conf @@ -0,0 +1,7 @@ +# Typically we want want a generic image and +# hostonly makes no sense as part of a server side build. +# (really hostonly=no should be the default if dracut detects that +# it's in a container or so) +hostonly=no +# We require ostree and our own module in the initramfs +add_dracutmodules+=" ostree bootc " diff --git a/baseimage/systemd/usr/lib/kernel/install.conf b/baseimage/systemd/usr/lib/kernel/install.conf new file mode 100644 index 000000000..4acaefb07 --- /dev/null +++ b/baseimage/systemd/usr/lib/kernel/install.conf @@ -0,0 +1,5 @@ +# kernel-install will not try to run dracut and allow rpm-ostree to +# take over. Rpm-ostree will use this to know that it is responsible +# to run dracut and ensure that there is only one kernel in the image +layout=ostree + diff --git a/ci/Containerfile.install-fsverity b/ci/Containerfile.install-fsverity new file mode 100644 index 000000000..a47c2964f --- /dev/null +++ b/ci/Containerfile.install-fsverity @@ -0,0 +1,14 @@ +# Enable fsverity at install time +FROM localhost/bootc +RUN < /usr/lib/ostree/prepare-root.conf < /usr/lib/bootc/install/90-ext4.toml </etc/yum.repos.d/coreos-continuous.repo << EOF +[copr:copr.fedorainfracloud.org:group_CoreOS:continuous] +name=Copr repo for continuous owned by @CoreOS +baseurl=$baseurl +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/@CoreOS/continuous/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +EOF + +# TODO: Recursively extract this from the existing cargo system-deps metadata +case $OS_ID in + fedora) dnf -y builddep bootc ;; + *) dnf -y install libzstd-devel openssl-devel ostree-devel cargo ;; +esac + +bindeps=$(cargo metadata --format-version 1 --no-deps | jq -r '.metadata.["binary-dependencies"].bins | map("/usr/bin/" + .) | join(" ")') +dnf -y install $bindeps diff --git a/ci/run-kola.sh b/ci/run-kola.sh new file mode 100755 index 000000000..66df2bf5d --- /dev/null +++ b/ci/run-kola.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -xeuo pipefail + +# We require the an image containing bootc-under-test to have been injected +# by an external system, e.g. Prow +# https://docs.ci.openshift.org/docs/architecture/ci-operator/#referring-to-images-in-tests +if test -z "${TARGET_IMAGE:-}"; then + echo "fatal: Must set TARGET_IMAGE" 1>&2; exit 1 +fi +echo "Test base image: ${TARGET_IMAGE}" + + +tmpdir="$(mktemp -d -p /var/tmp)" +cd "${tmpdir}" + +# Detect Prow; if we find it, assume the image requires a pull secret +kola_args=() +if test -n "${JOB_NAME_HASH:-}"; then + oc registry login --to auth.json + cat > pull-secret.bu << 'EOF' +variant: fcos +version: 1.1.0 +storage: + files: + - path: /etc/ostree/auth.json + contents: + local: auth.json +systemd: + units: + - name: zincati.service + dropins: + - name: disabled.conf + contents: | + [Unit] + ConditionPathExists=/enoent + +EOF + butane -d . < pull-secret.bu > pull-secret.ign + kola_args+=("--append-ignition" "pull-secret.ign") +fi + +if test -z "${BASE_QEMU_IMAGE:-}"; then + coreos-installer download -p qemu -f qcow2.xz --decompress + BASE_QEMU_IMAGE="$(echo *.qcow2)" +fi +cosa kola run --oscontainer ostree-unverified-registry:${TARGET_IMAGE} --qemu-image "./${BASE_QEMU_IMAGE}" "${kola_args[@]}" ext.bootc.'*' + +echo "ok kola bootc" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..c4b887869 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +disallowed-methods = [ + # https://github.com/rust-lang/rust-clippy/issues/13434#issuecomment-2484292914 + { path = "str::len", reason = "use .as_bytes().len() instead" } +] diff --git a/common/AGENTS.md b/common/AGENTS.md deleted file mode 100644 index 98ff24780..000000000 --- a/common/AGENTS.md +++ /dev/null @@ -1,41 +0,0 @@ - - -# Instructions for AI agents - -## CRITICAL instructions for generating commits - -### Signed-off-by - -Human review is required for all code that is generated -or assisted by a large language model. If you -are a LLM, you MUST NOT include a `Signed-off-by` -on any automatically generated git commits. Only explicit -human action or request should include a Signed-off-by. -If for example you automatically create a pull request -and the DCO check fails, tell the human to review -the code and give them instructions on how to add -a signoff. - -### Attribution - -When generating substantial amounts of code, you SHOULD -include an `Assisted-by: TOOLNAME (MODELNAME)`. For example, -`Assisted-by: Goose (Sonnet 4.5)`. - -## Code guidelines - -The [REVIEW.md](REVIEW.md) file describes expectations around -testing, code quality, commit organization, etc. If you're -creating a change, it is strongly encouraged after each -commit and especially when you think a task is complete -to spawn a subagent to perform a review using guidelines (alongside -looking for any other issues). - -If you are performing a review of other's code, the same -principles apply. - -## Follow other guidelines - -Look at the project README.md and look for guidelines -related to contribution, such as a CONTRIBUTING.md -and follow those. diff --git a/contrib/packaging/README-usr-extras.md b/contrib/packaging/README-usr-extras.md new file mode 100644 index 000000000..81805fffa --- /dev/null +++ b/contrib/packaging/README-usr-extras.md @@ -0,0 +1,8 @@ +# Understanding usr-extras + +The usr-extras directory contains +content we inject into all container images +built from this project. + +It is likely though that some of this will +end up in downstream operating systems instead. diff --git a/contrib/packaging/bootc.spec b/contrib/packaging/bootc.spec new file mode 100644 index 000000000..83043d344 --- /dev/null +++ b/contrib/packaging/bootc.spec @@ -0,0 +1,220 @@ +%bcond_without check +%bcond_with tests +%if 0%{?rhel} >= 9 || 0%{?fedora} > 41 + %bcond_without ostree_ext +%else + %bcond_with ostree_ext +%endif + +%if 0%{?rhel} + %bcond_without rhsm +%else + %bcond_with rhsm +%endif + +%global rust_minor %(rustc --version | cut -f2 -d" " | cut -f2 -d".") + +# https://github.com/bootc-dev/bootc/issues/1640 +%if 0%{?fedora} || 0%{?rhel} >= 10 || 0%{?rust_minor} >= 89 + %global new_cargo_macros 1 +%else + %global new_cargo_macros 0 +%endif + +Name: bootc +# Ensure this local build overrides anything else. +Version: 99999.0.0 +Release: 1%{?dist} +Summary: Bootable container system + +# Apache-2.0 +# Apache-2.0 OR BSL-1.0 +# Apache-2.0 OR MIT +# Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +# BSD-3-Clause +# MIT +# MIT OR Apache-2.0 +# Unlicense OR MIT +License: Apache-2.0 AND BSD-3-Clause AND MIT AND (Apache-2.0 OR BSL-1.0) AND (Apache-2.0 OR MIT) AND (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) AND (Unlicense OR MIT) +URL: https://github.com/bootc-dev/bootc +Source0: %{url}/releases/download/v%{version}/bootc-%{version}.tar.zstd +Source1: %{url}/releases/download/v%{version}/bootc-%{version}-vendor.tar.zstd + +# https://fedoraproject.org/wiki/Changes/EncourageI686LeafRemoval +ExcludeArch: %{ix86} + +BuildRequires: libzstd-devel +BuildRequires: make +BuildRequires: ostree-devel +BuildRequires: openssl-devel +BuildRequires: go-md2man +%if 0%{?rhel} +BuildRequires: rust-toolset +%else +BuildRequires: cargo-rpm-macros >= 25 +%endif +BuildRequires: systemd +# For tests +BuildRequires: skopeo ostree + +# Backing storage tooling https://github.com/containers/composefs/issues/125 +Requires: composefs +# Keep this list in sync with workspace.metadata.binary-dependencies until we sync +# it automatically +Requires: ostree +Requires: skopeo +Requires: podman +Requires: util-linux-core +Requires: /usr/bin/chcon +# For bootloader updates +Recommends: bootupd + +# A made up provides so that rpm-ostree can depend on it +%if %{with ostree_ext} +Provides: ostree-cli(ostree-container) +%endif + +%description +%{summary} + +# (-n because we don't want the subpackage name to start with bootc-) +%package -n system-reinstall-bootc +Summary: Utility to reinstall the current system using bootc +Recommends: podman +# The reinstall subpackage intentionally does not require bootc, as it pulls in many unnecessary dependencies + +%description -n system-reinstall-bootc +This package provides a utility to simplify reinstalling the current system to a given bootc image. + +%if %{with tests} +%package tests +Summary: Integration tests for bootc +Requires: %{name} = %{version}-%{release} + +%description tests +This package contains the integration test suite for bootc. +%endif + +%global system_reinstall_bootc_install_podman_path %{_prefix}/lib/system-reinstall-bootc/install-podman + +%if 0%{?container_build} +# Source is already at /src, no subdirectory +%global _buildsubdir . +%endif + +%prep +%if ! 0%{?container_build} +%autosetup -p1 -a1 +# Default -v vendor config doesn't support non-crates.io deps (i.e. git) +cp .cargo/vendor-config.toml . +%cargo_prep -N +cat vendor-config.toml >> .cargo/config.toml +rm vendor-config.toml +%else +# Container build: source already at _builddir (/src), nothing to extract +# RPM's %mkbuilddir creates a subdirectory; symlink it back to the source +cd .. +rm -rf %{name}-%{version}-build +ln -s . %{name}-%{version}-build +cd %{name}-%{version}-build +%endif + +%build +export SYSTEM_REINSTALL_BOOTC_INSTALL_PODMAN_PATH=%{system_reinstall_bootc_install_podman_path} +%if 0%{?container_build} +# Container build: use cargo directly with cached dependencies +export CARGO_HOME=/var/roothome/.cargo +cargo build -j%{_smp_build_ncpus} --release %{?with_rhsm:--features rhsm} \ + --bin=bootc --bin=system-reinstall-bootc --bin=bootc-initramfs-setup \ + %{?with_tests:--bin tests-integration} +make manpages +%else +# Build the main bootc binary +%if %new_cargo_macros + %cargo_build %{?with_rhsm:-f rhsm} +%else + %cargo_build %{?with_rhsm:--features rhsm} +%endif + +# Build the system reinstallation CLI binary +%global cargo_args -p system-reinstall-bootc +%if %new_cargo_macros + # In cargo-rpm-macros, the cargo_build macro does flag processing, + # so we need to pass '--' to signify that cargo_args is not part + # of the macro args + %cargo_build -- %cargo_args +%else + # Older macros from rust-toolset do *not* do flag processing, so + # '--' would be passed through to cargo directly, which is not + # what we want. + %cargo_build %cargo_args +%endif + +make manpages +%endif + +%if ! 0%{?container_build} +%cargo_vendor_manifest +# https://pagure.io/fedora-rust/rust-packaging/issue/33 +sed -i -e '/https:\/\//d' cargo-vendor.txt +%cargo_license_summary +%{cargo_license} > LICENSE.dependencies +%endif + +%install +%make_install INSTALL="install -p -c" +%if %{with ostree_ext} +make install-ostree-hooks DESTDIR=%{?buildroot} +%endif +%if %{with tests} +install -D -m 0755 target/release/tests-integration %{buildroot}%{_bindir}/bootc-integration-tests +%endif +mkdir -p %{buildroot}/%{dirname:%{system_reinstall_bootc_install_podman_path}} +cat >%{?buildroot}/%{system_reinstall_bootc_install_podman_path} < bootcdoclist.txt + +%if %{with check} +%check +if grep -qEe 'Seccomp:.*0$' /proc/self/status; then + %cargo_test +else + echo "skipping unit tests due to https://github.com/rpm-software-management/mock/pull/1613#issuecomment-3421908652" +fi +%endif + +%files -f bootcdoclist.txt +%license LICENSE-MIT +%license LICENSE-APACHE +%if ! 0%{?container_build} +%license LICENSE.dependencies +%license cargo-vendor.txt +%endif +%doc README.md +%{_bindir}/bootc +%{_prefix}/lib/bootc/ +%{_prefix}/lib/systemd/system-generators/* +%{_prefix}/lib/dracut/modules.d/51bootc/ +%if %{with ostree_ext} +%{_prefix}/libexec/libostree/ext/* +%endif +%{_unitdir}/* +%{_mandir}/man*/*bootc* + +%files -n system-reinstall-bootc +%{_bindir}/system-reinstall-bootc +%{system_reinstall_bootc_install_podman_path} + +%if %{with tests} +%files tests +%{_bindir}/bootc-integration-tests +%endif + +%changelog +%autochangelog diff --git a/contrib/packaging/build-rpm b/contrib/packaging/build-rpm new file mode 100755 index 000000000..a63909758 --- /dev/null +++ b/contrib/packaging/build-rpm @@ -0,0 +1,44 @@ +#!/bin/bash +# Build bootc RPM package from source +set -xeuo pipefail + +# Version can be passed via RPM_VERSION env var (set by Dockerfile ARG) +# or defaults to the hardcoded value in the spec file +VERSION="${RPM_VERSION:-}" + +# Determine output directory (defaults to /out) +OUTPUT_DIR="${1:-/out}" +SRC_DIR="${2:-/src}" + +if [ -n "${VERSION}" ]; then + echo "Building RPM with version: ${VERSION}" +else + echo "Building RPM with version from spec file" +fi + +# Create temporary rpmbuild directories +mkdir -p /tmp/rpmbuild/{RPMS,BUILDROOT,SPECS} + +# If version is provided, create modified spec file; otherwise use original +if [ -n "${VERSION}" ]; then + sed "s/^Version:.*/Version: ${VERSION}/" \ + "${SRC_DIR}/contrib/packaging/bootc.spec" > /tmp/rpmbuild/SPECS/bootc.spec + SPEC_FILE=/tmp/rpmbuild/SPECS/bootc.spec +else + SPEC_FILE="${SRC_DIR}/contrib/packaging/bootc.spec" +fi + +# Build RPM +rpmbuild -bb \ + --define "_topdir /tmp/rpmbuild" \ + --define "_builddir ${SRC_DIR}" \ + --define "container_build 1" \ + --with tests \ + --nocheck \ + "${SPEC_FILE}" + +# Copy built RPMs to output directory +ARCH=$(uname -m) +mkdir -p "${OUTPUT_DIR}" +cp /tmp/rpmbuild/RPMS/${ARCH}/*.rpm "${OUTPUT_DIR}/" +rm -rf /tmp/rpmbuild diff --git a/contrib/packaging/configure-rootfs b/contrib/packaging/configure-rootfs new file mode 100755 index 000000000..1e6d8ebd8 --- /dev/null +++ b/contrib/packaging/configure-rootfs @@ -0,0 +1,46 @@ +#!/bin/bash +# Configure rootfs type for bootc installation +set -xeuo pipefail + +VARIANT="${1:-}" +ROOTFS="${2:-}" + +# Support overriding the rootfs at build time +CONFIG_DIR="/usr/lib/bootc/install" +mkdir -p "${CONFIG_DIR}" + +# Do we have an explicit build-time override? Then write it. +if [ -n "$ROOTFS" ]; then + cat > "${CONFIG_DIR}/80-rootfs-override.toml" < "${CONFIG_DIR}/80-ext4-composefs.toml" < +pub const ESP_ID_MBR: &[u8] = &[0x06, 0xEF]; + +/// EFI System Partition (ESP) for UEFI boot on GPT +pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; + +#[derive(Debug, Deserialize)] +struct DevicesOutput { + blockdevices: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct Device { + pub name: String, + pub serial: Option, + pub model: Option, + pub partlabel: Option, + pub parttype: Option, + pub partuuid: Option, + pub children: Option>, + pub size: u64, + #[serde(rename = "maj:min")] + pub maj_min: Option, + // NOTE this one is not available on older util-linux, and + // will also not exist for whole blockdevs (as opposed to partitions). + pub start: Option, + + // Filesystem-related properties + pub label: Option, + pub fstype: Option, + pub uuid: Option, + pub path: Option, +} + +impl Device { + #[allow(dead_code)] + // RHEL8's lsblk doesn't have PATH, so we do it + pub fn path(&self) -> String { + self.path.clone().unwrap_or(format!("/dev/{}", &self.name)) + } + + #[allow(dead_code)] + pub fn has_children(&self) -> bool { + self.children.as_ref().is_some_and(|v| !v.is_empty()) + } + + // The "start" parameter was only added in a version of util-linux that's only + // in Fedora 40 as of this writing. + fn backfill_start(&mut self) -> Result<()> { + let Some(majmin) = self.maj_min.as_deref() else { + // This shouldn't happen + return Ok(()); + }; + let sysfs_start_path = format!("/sys/dev/block/{majmin}/start"); + if Utf8Path::new(&sysfs_start_path).try_exists()? { + let start = std::fs::read_to_string(&sysfs_start_path) + .with_context(|| format!("Reading {sysfs_start_path}"))?; + tracing::debug!("backfilled start to {start}"); + self.start = Some( + start + .trim() + .parse() + .context("Parsing sysfs start property")?, + ); + } + Ok(()) + } + + /// Older versions of util-linux may be missing some properties. Backfill them if they're missing. + pub fn backfill_missing(&mut self) -> Result<()> { + // Add new properties to backfill here + self.backfill_start()?; + // And recurse to child devices + for child in self.children.iter_mut().flatten() { + child.backfill_missing()?; + } + Ok(()) + } +} + +#[context("Listing device {dev}")] +pub fn list_dev(dev: &Utf8Path) -> Result { + let mut devs: DevicesOutput = Command::new("lsblk") + .args(["-J", "-b", "-O"]) + .arg(dev) + .log_debug() + .run_and_parse_json()?; + for dev in devs.blockdevices.iter_mut() { + dev.backfill_missing()?; + } + devs.blockdevices + .into_iter() + .next() + .ok_or_else(|| anyhow!("no device output from lsblk for {dev}")) +} + +#[derive(Debug, Deserialize)] +struct SfDiskOutput { + partitiontable: PartitionTable, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct Partition { + pub node: String, + pub start: u64, + pub size: u64, + #[serde(rename = "type")] + pub parttype: String, + pub uuid: Option, + pub name: Option, + pub bootable: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PartitionType { + Dos, + Gpt, + Unknown(String), +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct PartitionTable { + pub label: PartitionType, + pub id: String, + pub device: String, + // We're not using these fields + // pub unit: String, + // pub firstlba: u64, + // pub lastlba: u64, + // pub sectorsize: u64, + pub partitions: Vec, +} + +impl PartitionTable { + /// Find the partition with the given device name + #[allow(dead_code)] + pub fn find<'a>(&'a self, devname: &str) -> Option<&'a Partition> { + self.partitions.iter().find(|p| p.node.as_str() == devname) + } + + pub fn path(&self) -> &Utf8Path { + self.device.as_str().into() + } + + // Find the partition with the given offset (starting at 1) + #[allow(dead_code)] + pub fn find_partno(&self, partno: u32) -> Result<&Partition> { + let r = self + .partitions + .get(partno.checked_sub(1).expect("1 based partition offset") as usize) + .ok_or_else(|| anyhow::anyhow!("Missing partition for index {partno}"))?; + Ok(r) + } + + /// Find the partition with the given type UUID (case-insensitive). + /// + /// Partition type UUIDs are compared case-insensitively per the GPT specification, + /// as different tools may report them in different cases. + pub fn find_partition_of_type(&self, uuid: &str) -> Option<&Partition> { + self.partitions.iter().find(|p| p.parttype_matches(uuid)) + } + + /// Find the partition with bootable is 'true'. + pub fn find_partition_of_bootable(&self) -> Option<&Partition> { + self.partitions.iter().find(|p| p.is_bootable()) + } + + /// Find the esp partition. + pub fn find_partition_of_esp(&self) -> Result> { + match &self.label { + PartitionType::Dos => Ok(self.partitions.iter().find(|b| { + u8::from_str_radix(&b.parttype, 16) + .map(|pt| ESP_ID_MBR.contains(&pt)) + .unwrap_or(false) + })), + PartitionType::Gpt => Ok(self.find_partition_of_type(ESP)), + _ => Err(anyhow::anyhow!("Unsupported partition table type")), + } + } +} + +impl Partition { + #[allow(dead_code)] + pub fn path(&self) -> &Utf8Path { + self.node.as_str().into() + } + + /// Check if this partition's type matches the given UUID (case-insensitive). + /// + /// Partition type UUIDs are compared case-insensitively per the GPT specification, + /// as different tools may report them in different cases. + pub fn parttype_matches(&self, uuid: &str) -> bool { + self.parttype.eq_ignore_ascii_case(uuid) + } + + /// Check this partition's bootable property. + pub fn is_bootable(&self) -> bool { + self.bootable.unwrap_or(false) + } +} + +#[context("Listing partitions of {dev}")] +pub fn partitions_of(dev: &Utf8Path) -> Result { + let o: SfDiskOutput = Command::new("sfdisk") + .args(["-J", dev.as_str()]) + .run_and_parse_json()?; + Ok(o.partitiontable) +} + +pub struct LoopbackDevice { + pub dev: Option, + // Handle to the cleanup helper process + cleanup_handle: Option, +} + +/// Handle to manage the cleanup helper process for loopback devices +struct LoopbackCleanupHandle { + /// Child process handle + child: std::process::Child, +} + +impl LoopbackDevice { + // Create a new loopback block device targeting the provided file path. + pub fn new(path: &Path) -> Result { + let direct_io = match env::var("BOOTC_DIRECT_IO") { + Ok(val) => { + if val == "on" { + "on" + } else { + "off" + } + } + Err(_e) => "off", + }; + + let dev = Command::new("losetup") + .args([ + "--show", + format!("--direct-io={direct_io}").as_str(), + "-P", + "--find", + ]) + .arg(path) + .run_get_string()?; + let dev = Utf8PathBuf::from(dev.trim()); + tracing::debug!("Allocated loopback {dev}"); + + // Try to spawn cleanup helper, but don't fail if it doesn't work + let cleanup_handle = match Self::spawn_cleanup_helper(dev.as_str()) { + Ok(handle) => Some(handle), + Err(e) => { + tracing::warn!( + "Failed to spawn loopback cleanup helper for {}: {}. \ + Loopback device may not be cleaned up if process is interrupted.", + dev, + e + ); + None + } + }; + + Ok(Self { + dev: Some(dev), + cleanup_handle, + }) + } + + // Access the path to the loopback block device. + pub fn path(&self) -> &Utf8Path { + // SAFETY: The option cannot be destructured until we are dropped + self.dev.as_deref().unwrap() + } + + /// Spawn a cleanup helper process that will clean up the loopback device + /// if the parent process dies unexpectedly + fn spawn_cleanup_helper(device_path: &str) -> Result { + // Try multiple strategies to find the bootc binary + let bootc_path = bootc_utils::reexec::executable_path() + .context("Failed to locate bootc binary for cleanup helper")?; + + // Create the helper process + let mut cmd = Command::new(bootc_path); + cmd.args([ + "internals", + "loopback-cleanup-helper", + "--device", + device_path, + ]); + + // Set environment variable to indicate this is a cleanup helper + cmd.env("BOOTC_LOOPBACK_CLEANUP_HELPER", "1"); + + // Set up stdio to redirect to /dev/null + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + // Don't redirect stderr so we can see error messages + + // Spawn the process + let child = cmd + .spawn() + .context("Failed to spawn loopback cleanup helper")?; + + Ok(LoopbackCleanupHandle { child }) + } + + // Shared backend for our `close` and `drop` implementations. + fn impl_close(&mut self) -> Result<()> { + // SAFETY: This is the only place we take the option + let Some(dev) = self.dev.take() else { + tracing::trace!("loopback device already deallocated"); + return Ok(()); + }; + + // Kill the cleanup helper since we're cleaning up normally + if let Some(mut cleanup_handle) = self.cleanup_handle.take() { + // Send SIGTERM to the child process and let it do the cleanup + let _ = cleanup_handle.child.kill(); + } + + Command::new("losetup") + .args(["-d", dev.as_str()]) + .run_capture_stderr() + } + + /// Consume this device, unmounting it. + pub fn close(mut self) -> Result<()> { + self.impl_close() + } +} + +impl Drop for LoopbackDevice { + fn drop(&mut self) { + // Best effort to unmount if we're dropped without invoking `close` + let _ = self.impl_close(); + } +} + +/// Main function for the loopback cleanup helper process +/// This function does not return - it either exits normally or via signal +pub async fn run_loopback_cleanup_helper(device_path: &str) -> Result<()> { + // Check if we're running as a cleanup helper + if std::env::var("BOOTC_LOOPBACK_CLEANUP_HELPER").is_err() { + anyhow::bail!("This function should only be called as a cleanup helper"); + } + + // Set up death signal notification - we want to be notified when parent dies + rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::TERM)) + .context("Failed to set parent death signal")?; + + // Wait for SIGTERM (either from parent death or normal cleanup) + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to create signal stream") + .recv() + .await; + + // Clean up the loopback device + let output = std::process::Command::new("losetup") + .args(["-d", device_path]) + .output(); + + match output { + Ok(output) if output.status.success() => { + // Log to systemd journal instead of stderr + tracing::info!("Cleaned up leaked loopback device {}", device_path); + std::process::exit(0); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::error!( + "Failed to clean up loopback device {}: {}. Stderr: {}", + device_path, + output.status, + stderr.trim() + ); + std::process::exit(1); + } + Err(e) => { + tracing::error!( + "Error executing losetup to clean up loopback device {}: {}", + device_path, + e + ); + std::process::exit(1); + } + } +} + +/// Parse key-value pairs from lsblk --pairs. +/// Newer versions of lsblk support JSON but the one in CentOS 7 doesn't. +fn split_lsblk_line(line: &str) -> HashMap { + static REGEX: OnceLock = OnceLock::new(); + let regex = REGEX.get_or_init(|| Regex::new(r#"([A-Z-_]+)="([^"]+)""#).unwrap()); + let mut fields: HashMap = HashMap::new(); + for cap in regex.captures_iter(line) { + fields.insert(cap[1].to_string(), cap[2].to_string()); + } + fields +} + +/// This is a bit fuzzy, but... this function will return every block device in the parent +/// hierarchy of `device` capable of containing other partitions. So e.g. parent devices of type +/// "part" doesn't match, but "disk" and "mpath" does. +pub fn find_parent_devices(device: &str) -> Result> { + let output = Command::new("lsblk") + // Older lsblk, e.g. in CentOS 7.6, doesn't support PATH, but --paths option + .arg("--pairs") + .arg("--paths") + .arg("--inverse") + .arg("--output") + .arg("NAME,TYPE") + .arg(device) + .run_get_string()?; + let mut parents = Vec::new(); + // skip first line, which is the device itself + for line in output.lines().skip(1) { + let dev = split_lsblk_line(line); + let name = dev + .get("NAME") + .with_context(|| format!("device in hierarchy of {device} missing NAME"))?; + let kind = dev + .get("TYPE") + .with_context(|| format!("device in hierarchy of {device} missing TYPE"))?; + if kind == "disk" || kind == "loop" { + parents.push(name.clone()); + } else if kind == "mpath" { + parents.push(name.clone()); + // we don't need to know what disks back the multipath + break; + } + } + Ok(parents) +} + +/// Parse a string into mibibytes +pub fn parse_size_mib(mut s: &str) -> Result { + let suffixes = [ + ("MiB", 1u64), + ("M", 1u64), + ("GiB", 1024), + ("G", 1024), + ("TiB", 1024 * 1024), + ("T", 1024 * 1024), + ]; + let mut mul = 1u64; + for (suffix, imul) in suffixes { + if let Some((sv, rest)) = s.rsplit_once(suffix) { + if !rest.is_empty() { + anyhow::bail!("Trailing text after size: {rest}"); + } + s = sv; + mul = imul; + } + } + let v = s.parse::()?; + Ok(v * mul) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_parse_size_mib() { + let ident_cases = [0, 10, 9, 1024].into_iter().map(|k| (k.to_string(), k)); + let cases = [ + ("0M", 0), + ("10M", 10), + ("10MiB", 10), + ("1G", 1024), + ("9G", 9216), + ("11T", 11 * 1024 * 1024), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)); + for (s, v) in ident_cases.chain(cases) { + assert_eq!(parse_size_mib(&s).unwrap(), v as u64, "Parsing {s}"); + } + } + + #[test] + fn test_parse_lsblk() { + let fixture = include_str!("../tests/fixtures/lsblk.json"); + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + let dev = devs.blockdevices.into_iter().next().unwrap(); + let children = dev.children.as_deref().unwrap(); + assert_eq!(children.len(), 3); + let first_child = &children[0]; + assert_eq!( + first_child.parttype.as_deref().unwrap(), + "21686148-6449-6e6f-744e-656564454649" + ); + assert_eq!( + first_child.partuuid.as_deref().unwrap(), + "3979e399-262f-4666-aabc-7ab5d3add2f0" + ); + } + + #[test] + fn test_parse_sfdisk() -> Result<()> { + let fixture = indoc::indoc! { r#" + { + "partitiontable": { + "label": "gpt", + "id": "A67AA901-2C72-4818-B098-7F1CAC127279", + "device": "/dev/loop0", + "unit": "sectors", + "firstlba": 34, + "lastlba": 20971486, + "sectorsize": 512, + "partitions": [ + { + "node": "/dev/loop0p1", + "start": 2048, + "size": 8192, + "type": "9E1A2D38-C612-4316-AA26-8B49521E5A8B", + "uuid": "58A4C5F0-BD12-424C-B563-195AC65A25DD", + "name": "PowerPC-PReP-boot" + },{ + "node": "/dev/loop0p2", + "start": 10240, + "size": 20961247, + "type": "0FC63DAF-8483-4772-8E79-3D69D8477DE4", + "uuid": "F51ABB0D-DA16-4A21-83CB-37F4C805AAA0", + "name": "root" + } + ] + } + } + "# }; + let table: SfDiskOutput = serde_json::from_str(fixture).unwrap(); + assert_eq!( + table.partitiontable.find("/dev/loop0p2").unwrap().size, + 20961247 + ); + Ok(()) + } + + #[test] + fn test_parttype_matches() { + let partition = Partition { + node: "/dev/loop0p1".to_string(), + start: 2048, + size: 8192, + parttype: "c12a7328-f81f-11d2-ba4b-00a0c93ec93b".to_string(), // lowercase ESP UUID + uuid: Some("58A4C5F0-BD12-424C-B563-195AC65A25DD".to_string()), + name: Some("EFI System".to_string()), + bootable: None, + }; + + // Test exact match (lowercase) + assert!(partition.parttype_matches("c12a7328-f81f-11d2-ba4b-00a0c93ec93b")); + + // Test case-insensitive match (uppercase) + assert!(partition.parttype_matches("C12A7328-F81F-11D2-BA4B-00A0C93EC93B")); + + // Test case-insensitive match (mixed case) + assert!(partition.parttype_matches("C12a7328-F81f-11d2-Ba4b-00a0C93ec93b")); + + // Test non-match + assert!(!partition.parttype_matches("0FC63DAF-8483-4772-8E79-3D69D8477DE4")); + } + + #[test] + fn test_find_partition_of_type() -> Result<()> { + let fixture = indoc::indoc! { r#" + { + "partitiontable": { + "label": "gpt", + "id": "A67AA901-2C72-4818-B098-7F1CAC127279", + "device": "/dev/loop0", + "unit": "sectors", + "firstlba": 34, + "lastlba": 20971486, + "sectorsize": 512, + "partitions": [ + { + "node": "/dev/loop0p1", + "start": 2048, + "size": 8192, + "type": "C12A7328-F81F-11D2-BA4B-00A0C93EC93B", + "uuid": "58A4C5F0-BD12-424C-B563-195AC65A25DD", + "name": "EFI System" + },{ + "node": "/dev/loop0p2", + "start": 10240, + "size": 20961247, + "type": "0FC63DAF-8483-4772-8E79-3D69D8477DE4", + "uuid": "F51ABB0D-DA16-4A21-83CB-37F4C805AAA0", + "name": "root" + } + ] + } + } + "# }; + let table: SfDiskOutput = serde_json::from_str(fixture).unwrap(); + + // Find ESP partition using lowercase UUID (should match uppercase in fixture) + let esp = table + .partitiontable + .find_partition_of_type("c12a7328-f81f-11d2-ba4b-00a0c93ec93b"); + assert!(esp.is_some()); + assert_eq!(esp.unwrap().node, "/dev/loop0p1"); + + // Find root partition using uppercase UUID (should match case-insensitively) + let root = table + .partitiontable + .find_partition_of_type("0fc63daf-8483-4772-8e79-3d69d8477de4"); + assert!(root.is_some()); + assert_eq!(root.unwrap().node, "/dev/loop0p2"); + + // Try to find non-existent partition type + let nonexistent = table + .partitiontable + .find_partition_of_type("00000000-0000-0000-0000-000000000000"); + assert!(nonexistent.is_none()); + + // Find esp partition on GPT + let esp = table.partitiontable.find_partition_of_esp()?.unwrap(); + assert_eq!(esp.node, "/dev/loop0p1"); + + Ok(()) + } + #[test] + fn test_find_partition_of_type_mbr() -> Result<()> { + let fixture = indoc::indoc! { r#" + { + "partitiontable": { + "label": "dos", + "id": "0xc1748067", + "device": "/dev/mmcblk0", + "unit": "sectors", + "sectorsize": 512, + "partitions": [ + { + "node": "/dev/mmcblk0p1", + "start": 2048, + "size": 1026048, + "type": "6", + "bootable": true + },{ + "node": "/dev/mmcblk0p2", + "start": 1028096, + "size": 2097152, + "type": "83" + },{ + "node": "/dev/mmcblk0p3", + "start": 3125248, + "size": 121610240, + "type": "ef" + } + ] + } + } + "# }; + let table: SfDiskOutput = serde_json::from_str(fixture).unwrap(); + + // Find ESP partition using bootalbe is true + assert_eq!(table.partitiontable.label, PartitionType::Dos); + let esp = table + .partitiontable + .find_partition_of_bootable() + .expect("bootable partition not found"); + assert_eq!(esp.node, "/dev/mmcblk0p1"); + + // Find esp partition on MBR + let esp1 = table.partitiontable.find_partition_of_esp()?.unwrap(); + assert_eq!(esp1.node, "/dev/mmcblk0p1"); + Ok(()) + } +} diff --git a/crates/blockdev/tests/fixtures/lsblk.json b/crates/blockdev/tests/fixtures/lsblk.json new file mode 100644 index 000000000..e8be201cc --- /dev/null +++ b/crates/blockdev/tests/fixtures/lsblk.json @@ -0,0 +1,313 @@ +{ + "blockdevices": [ + { + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda", + "label": null, + "log-sec": 512, + "maj:min": "252:0", + "maj": "252", + "min": "0", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/vda", + "phy-sec": 512, + "pkname": null, + "pttype": "gpt", + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 10737418240, + "start": null, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "disk", + "uuid": null, + "vendor": "0x1af4", + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0, + "children": [ + { + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda1", + "label": null, + "log-sec": 512, + "maj:min": "252:1", + "maj": "252", + "min": "1", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda1", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": "BIOS-BOOT", + "partn": 1, + "parttype": "21686148-6449-6e6f-744e-656564454649", + "parttypename": "BIOS boot", + "partuuid": "3979e399-262f-4666-aabc-7ab5d3add2f0", + "path": "/dev/vda1", + "phy-sec": 512, + "pkname": "vda", + "pttype": "gpt", + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 1048576, + "start": 2048, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "part", + "uuid": null, + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + },{ + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": "vfat", + "fsused": null, + "fsuse%": null, + "fsver": "FAT32", + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda2", + "label": "EFI-SYSTEM", + "log-sec": 512, + "maj:min": "252:2", + "maj": "252", + "min": "2", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda2", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": "EFI-SYSTEM", + "partn": 2, + "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "parttypename": "EFI System", + "partuuid": "392375a5-8cb5-4f15-885f-0d6b410dcd6c", + "path": "/dev/vda2", + "phy-sec": 512, + "pkname": "vda", + "pttype": "gpt", + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 536870912, + "start": 4096, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "part", + "uuid": "FB0A-EA04", + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + },{ + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": 5746880512, + "fsroots": [ + "/ostree/deploy/default/var", "/ostree/deploy/default/var", "/ostree/deploy/default/deploy/41b7689b3d723570fcea1942007139dbbd7a4dfa7225b1747b47ddb67b37955a.0/etc", "/boot", "/" + ], + "fssize": 9932427264, + "fstype": "ext4", + "fsused": 3658899456, + "fsuse%": "37%", + "fsver": "1.0", + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda3", + "label": "root", + "log-sec": 512, + "maj:min": "252:3", + "maj": "252", + "min": "3", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda3", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": "root", + "partn": 3, + "parttype": "0fc63daf-8483-4772-8e79-3d69d8477de4", + "parttypename": "Linux filesystem", + "partuuid": "61709da2-1972-4f44-88fd-9d87bf9efe9c", + "path": "/dev/vda3", + "phy-sec": 512, + "pkname": "vda", + "pttype": "gpt", + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 10197401600, + "start": 1052672, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": "/sysroot", + "mountpoints": [ + "/var", "/sysroot/ostree/deploy/default/var", "/etc", "/boot", "/sysroot" + ], + "tran": "virtio", + "type": "part", + "uuid": "9b243e03-e7c5-4f76-83f0-9b4b3f18b0eb", + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + } + ] + } + ] +} + diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 000000000..938091cee --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bootc" +# This is a stub, the real version is from the lib crate +version = "0.0.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/bootc-dev/bootc" +publish = false +default-run = "bootc" + +# See https://github.com/coreos/cargo-vendor-filterer +[package.metadata.vendor-filter] +# For now we only care about tier 1+2 Linux. (In practice, it's unlikely there is a tier3-only Linux dependency) +platforms = ["*-unknown-linux-gnu"] + +[dependencies] +# Internal crates +bootc-lib = { version = "1.10", path = "../lib" } +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anstream = { workspace = true } +anyhow = { workspace = true } +log = { workspace = true } +tokio = { workspace = true, features = ["macros"] } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/cli/bootc-generator-stub b/crates/cli/bootc-generator-stub new file mode 100755 index 000000000..713283415 --- /dev/null +++ b/crates/cli/bootc-generator-stub @@ -0,0 +1,5 @@ +#!/bin/bash +# We can't actually hardlink because in Fedora (+derivatives) +# these have different SELinux labels. xref +# https://issues.redhat.com/browse/RHEL-76188 +exec bootc internals systemd-generator "$@" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 000000000..a8a2edd4d --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,37 @@ +//! The main entrypoint for bootc, which just performs global initialization, and then +//! calls out into the library. +//! +use anyhow::Result; + +/// The code called after we've done process global init and created +/// an async runtime. +async fn async_main() -> Result<()> { + bootc_utils::initialize_tracing(); + + tracing::trace!("starting bootc"); + + // As you can see, the role of this file is mostly to just be a shim + // to call into the code that lives in the internal shared library. + bootc_lib::cli::run_from_iter(std::env::args()).await +} + +/// Perform process global initialization, then create an async runtime +/// and do the rest of the work there. +fn run() -> Result<()> { + // Initialize global state before we've possibly created other threads, etc. + bootc_lib::cli::global_init()?; + // We only use the "current thread" runtime because we don't perform + // a lot of CPU heavy work in async tasks. Where we do work on the CPU, + // or we do want explicit concurrency, we typically use + // tokio::task::spawn_blocking to create a new OS thread explicitly. + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to build tokio runtime"); + // And invoke the async_main + runtime.block_on(async move { async_main().await }) +} + +fn main() { + bootc_utils::run_main(run) +} diff --git a/crates/etc-merge/Cargo.toml b/crates/etc-merge/Cargo.toml new file mode 100644 index 000000000..5401b95a2 --- /dev/null +++ b/crates/etc-merge/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "etc-merge" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +anyhow = { workspace = true } +cap-std-ext = { workspace = true } +rustix = { workspace = true } +openssl = { workspace = true } +hex = { workspace = true } +tracing = { workspace = true } +composefs = { workspace = true } +fn-error-context = { workspace = true } +owo-colors = { workspace = true } +anstream = { workspace = true } + +[lints] +workspace = true diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs new file mode 100644 index 000000000..6f32d4af5 --- /dev/null +++ b/crates/etc-merge/src/lib.rs @@ -0,0 +1,1096 @@ +//! Lib for /etc merge + +#![allow(dead_code)] + +use fn_error_context::context; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::io::BufReader; +use std::io::Write; +use std::os::fd::{AsFd, AsRawFd}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use anyhow::Context; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::{Dir as CapStdDir, MetadataExt, Permissions, PermissionsExt}; +use cap_std_ext::dirext::CapStdExtDirExt; +use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}; +use composefs::generic_tree::{Directory, Inode, Leaf, LeafContent, Stat}; +use composefs::tree::ImageError; +use rustix::fs::{ + AtFlags, Gid, Uid, XattrFlags, lgetxattr, llistxattr, lsetxattr, readlinkat, symlinkat, +}; + +/// Metadata associated with a file, directory, or symlink entry. +#[derive(Debug)] +pub struct CustomMetadata { + /// A SHA256 sum representing the file contents. + content_hash: String, + /// Optional verity for the file + verity: Option, +} + +impl CustomMetadata { + fn new(content_hash: String, verity: Option) -> Self { + Self { + content_hash, + verity, + } + } +} + +type Xattrs = RefCell, Box<[u8]>>>; + +struct MyStat(Stat); + +impl From<(&cap_std::fs::Metadata, Xattrs)> for MyStat { + fn from(value: (&cap_std::fs::Metadata, Xattrs)) -> Self { + Self(Stat { + st_mode: value.0.mode(), + st_uid: value.0.uid(), + st_gid: value.0.gid(), + st_mtim_sec: value.0.mtime(), + xattrs: value.1, + }) + } +} + +fn stat_eq_ignore_mtime(this: &Stat, other: &Stat) -> bool { + if this.st_uid != other.st_uid { + return false; + } + + if this.st_gid != other.st_gid { + return false; + } + + if this.st_mode != other.st_mode { + return false; + } + + if this.xattrs != other.xattrs { + return false; + } + + return true; +} + +/// Represents the differences between two directory trees. +#[derive(Debug)] +pub struct Diff { + /// Paths that exist in the current /etc but not in the pristine + added: Vec, + /// Paths that exist in both pristine and current /etc but differ in metadata + /// (e.g., file contents, permissions, symlink targets) + modified: Vec, + /// Paths that exist in the pristine /etc but not in the current one + removed: Vec, +} + +fn collect_all_files( + root: &Directory, + current_path: PathBuf, + files: &mut Vec, +) { + fn collect( + root: &Directory, + mut current_path: PathBuf, + files: &mut Vec, + ) { + for (path, inode) in root.sorted_entries() { + current_path.push(path); + + files.push(current_path.clone()); + + if let Inode::Directory(dir) = inode { + collect(dir, current_path.clone(), files); + } + + current_path.pop(); + } + } + + collect(root, current_path, files); +} + +#[context("Getting deletions")] +fn get_deletions( + pristine: &Directory, + current: &Directory, + mut current_path: PathBuf, + diff: &mut Diff, +) -> anyhow::Result<()> { + for (file_name, inode) in pristine.sorted_entries() { + current_path.push(file_name); + + match inode { + Inode::Directory(pristine_dir) => { + match current.get_directory(file_name) { + Ok(curr_dir) => { + get_deletions(pristine_dir, curr_dir, current_path.clone(), diff)? + } + + Err(ImageError::NotFound(..)) => { + // Directory was deleted + diff.removed.push(current_path.clone()); + } + + Err(ImageError::NotADirectory(..)) => { + // Already tracked in modifications + } + + Err(e) => Err(e)?, + } + } + + Inode::Leaf(..) => match current.ref_leaf(file_name) { + Ok(..) => { + // Empty as all additions/modifications are tracked earlier in `get_modifications` + } + + Err(ImageError::NotFound(..)) => { + // File was deleted + diff.removed.push(current_path.clone()); + } + + Err(ImageError::IsADirectory(..)) => { + // Already tracked in modifications + } + + Err(e) => Err(e).context(format!("{file_name:?}"))?, + }, + } + + current_path.pop(); + } + + Ok(()) +} + +// 1. Files in the currently booted deployment’s /etc which were modified from the default /usr/etc (of the same deployment) are retained. +// +// 2. Files in the currently booted deployment’s /etc which were not modified from the default /usr/etc (of the same deployment) +// are upgraded to the new defaults from the new deployment’s /usr/etc. + +// Modifications +// 1. File deleted from new /etc +// 2. File added in new /etc +// +// 3. File modified in new /etc +// a. Content added/deleted +// b. Permissions/ownership changed +// c. Was a file but changed to directory/symlink etc or vice versa +// d. xattrs changed - we don't include this right now +#[context("Getting modifications")] +fn get_modifications( + pristine: &Directory, + current: &Directory, + mut current_path: PathBuf, + diff: &mut Diff, +) -> anyhow::Result<()> { + use composefs::generic_tree::LeafContent::*; + + for (path, inode) in current.sorted_entries() { + current_path.push(path); + + match inode { + Inode::Directory(curr_dir) => { + match pristine.get_directory(path) { + Ok(old_dir) => { + if !stat_eq_ignore_mtime(&curr_dir.stat, &old_dir.stat) { + // Directory permissions/owner modified + diff.modified.push(current_path.clone()); + } + + get_modifications(old_dir, &curr_dir, current_path.clone(), diff)? + } + + Err(ImageError::NotFound(..)) => { + // Dir not found in original /etc, dir was added + diff.added.push(current_path.clone()); + + // Also add every file inside that dir + collect_all_files(&curr_dir, current_path.clone(), &mut diff.added); + } + + Err(ImageError::NotADirectory(..)) => { + // Some directory was changed to a file/symlink + // This should be counted in the diff, but we don't really merge this + diff.modified.push(current_path.clone()); + } + + Err(e) => Err(e)?, + } + } + + Inode::Leaf(leaf) => match pristine.ref_leaf(path) { + Ok(old_leaf) => { + if !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat) { + diff.modified.push(current_path.clone()); + current_path.pop(); + continue; + } + + match (&old_leaf.content, &leaf.content) { + (Regular(old_meta), Regular(current_meta)) => { + if old_meta.content_hash != current_meta.content_hash { + // File modified in some way + diff.modified.push(current_path.clone()); + } + } + + (Symlink(old_link), Symlink(current_link)) => { + if old_link != current_link { + // Symlink modified in some way + diff.modified.push(current_path.clone()); + } + } + + (Symlink(..), Regular(..)) | (Regular(..), Symlink(..)) => { + // File changed to symlink or vice-versa + diff.modified.push(current_path.clone()); + } + + (a, b) => { + unreachable!("{a:?} modified to {b:?}") + } + } + } + + Err(ImageError::IsADirectory(..)) => { + // A directory was changed to a file + diff.modified.push(current_path.clone()); + } + + Err(ImageError::NotFound(..)) => { + // File not found in original /etc, file was added + diff.added.push(current_path.clone()); + } + + Err(e) => Err(e).context(format!("{path:?}"))?, + }, + } + + current_path.pop(); + } + + Ok(()) +} + +/// Traverses and collects directory trees for three etc states. +/// +/// Recursively walks through the given *pristine*, *current*, and *new* etc directories, +/// building filesystem trees that capture files, directories, and symlinks. +/// Device files, sockets, pipes etc are ignored +/// +/// It is primarily used to prepare inputs for later diff computations and +/// comparisons between different etc states. +/// +/// # Arguments +/// +/// * `pristine_etc` - The reference directory representing the unmodified version or current /etc. +/// Usually this will be obtained by remounting the EROFS image to a temporary location +/// +/// * `current_etc` - The current `/etc` directory +/// +/// * `new_etc` - The directory representing the `/etc` directory for a new deployment. This will +/// again be usually obtained by mounting the new EROFS image to a temporary location. If merging +/// it will be necessary to make the `/etc` for the deployment writeable +/// +/// # Returns +/// +/// [`anyhow::Result`] containing a tuple of directory trees in the order: +/// +/// 1. `pristine_etc_files` – Dirtree of the pristine etc state +/// 2. `current_etc_files` – Dirtree of the current etc state +/// 3. `new_etc_files` – Dirtree of the new etc state (if new_etc directory is passed) +pub fn traverse_etc( + pristine_etc: &CapStdDir, + current_etc: &CapStdDir, + new_etc: Option<&CapStdDir>, +) -> anyhow::Result<( + Directory, + Directory, + Option>, +)> { + let mut pristine_etc_files = Directory::default(); + recurse_dir(pristine_etc, &mut pristine_etc_files) + .context(format!("Recursing {pristine_etc:?}"))?; + + let mut current_etc_files = Directory::default(); + recurse_dir(current_etc, &mut current_etc_files) + .context(format!("Recursing {current_etc:?}"))?; + + let new_etc_files = match new_etc { + Some(new_etc) => { + let mut new_etc_files = Directory::default(); + recurse_dir(new_etc, &mut new_etc_files).context(format!("Recursing {new_etc:?}"))?; + + Some(new_etc_files) + } + + None => None, + }; + + return Ok((pristine_etc_files, current_etc_files, new_etc_files)); +} + +/// Computes the differences between two directory snapshots. +#[context("Computing diff")] +pub fn compute_diff( + pristine_etc_files: &Directory, + current_etc_files: &Directory, +) -> anyhow::Result { + let mut diff = Diff { + added: vec![], + modified: vec![], + removed: vec![], + }; + + get_modifications( + &pristine_etc_files, + ¤t_etc_files, + PathBuf::new(), + &mut diff, + )?; + + get_deletions( + &pristine_etc_files, + ¤t_etc_files, + PathBuf::new(), + &mut diff, + )?; + + Ok(diff) +} + +/// Prints a colorized summary of differences to standard output. +pub fn print_diff(diff: &Diff, writer: &mut impl Write) { + use owo_colors::OwoColorize; + + for added in &diff.added { + let _ = writeln!(writer, "{} {added:?}", ModificationType::Added.green()); + } + + for modified in &diff.modified { + let _ = writeln!(writer, "{} {modified:?}", ModificationType::Modified.cyan()); + } + + for removed in &diff.removed { + let _ = writeln!(writer, "{} {removed:?}", ModificationType::Removed.red()); + } +} + +#[context("Collecting xattrs")] +fn collect_xattrs(etc_fd: &CapStdDir, rel_path: impl AsRef) -> anyhow::Result { + let link = format!("/proc/self/fd/{}", etc_fd.as_fd().as_raw_fd()); + let path = Path::new(&link).join(rel_path); + + const DEFAULT_SIZE: usize = 128; + + // Start with a guess for size + let mut xattrs_name_buf: Vec = vec![0; DEFAULT_SIZE]; + let mut size = llistxattr(&path, &mut xattrs_name_buf).context("llistxattr")?; + + if size > xattrs_name_buf.capacity() { + xattrs_name_buf.resize(size, 0); + size = llistxattr(&path, &mut xattrs_name_buf).context("llistxattr")?; + } + + let xattrs: Xattrs = RefCell::new(BTreeMap::new()); + + for name_buf in xattrs_name_buf[..size] + .split(|&b| b == 0) + .filter(|x| !x.is_empty()) + { + let name = OsStr::from_bytes(name_buf); + + let mut xattrs_value_buf = vec![0; DEFAULT_SIZE]; + let mut size = lgetxattr(&path, name_buf, &mut xattrs_value_buf).context("lgetxattr")?; + + if size > xattrs_value_buf.capacity() { + xattrs_value_buf.resize(size, 0); + size = lgetxattr(&path, name_buf, &mut xattrs_value_buf).context("lgetxattr")?; + } + + xattrs.borrow_mut().insert( + Box::::from(name), + Box::<[u8]>::from(&xattrs_value_buf[..size]), + ); + } + + Ok(xattrs) +} + +#[context("Copying xattrs")] +fn copy_xattrs(xattrs: &Xattrs, new_etc_fd: &CapStdDir, path: &Path) -> anyhow::Result<()> { + for (attr, value) in xattrs.borrow().iter() { + let fdpath = &Path::new(&format!("/proc/self/fd/{}", new_etc_fd.as_raw_fd())).join(path); + lsetxattr(fdpath, attr.as_ref(), value, XattrFlags::empty()) + .with_context(|| format!("setxattr {attr:?} for {fdpath:?}"))?; + } + + Ok(()) +} + +fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow::Result<()> { + for entry in dir.entries()? { + let entry = entry.context(format!("Getting entry"))?; + let entry_name = entry.file_name(); + + let entry_type = entry.file_type()?; + + let entry_meta = entry + .metadata() + .context(format!("Getting metadata for {entry_name:?}"))?; + + let xattrs = collect_xattrs(&dir, &entry_name)?; + + // Do symlinks first as we don't want to follow back up any symlinks + if entry_type.is_symlink() { + let readlinkat_result = readlinkat(&dir, &entry_name, vec![]) + .context(format!("readlinkat {entry_name:?}"))?; + + let os_str = OsStr::from_bytes(readlinkat_result.as_bytes()); + + root.insert( + &entry_name, + Inode::Leaf(Rc::new(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Symlink(Box::from(os_str)), + })), + ); + + continue; + } + + if entry_type.is_dir() { + let dir = dir + .open_dir(&entry_name) + .with_context(|| format!("Opening dir {entry_name:?} inside {dir:?}"))?; + + let mut directory = Directory::new(MyStat::from((&entry_meta, xattrs)).0); + + recurse_dir(&dir, &mut directory)?; + + root.insert(&entry_name, Inode::Directory(Box::new(directory))); + + continue; + } + + if !(entry_type.is_symlink() || entry_type.is_file()) { + // We cannot read any other device like socket, pipe, fifo. + // We shouldn't really find these in /etc in the first place + tracing::debug!("Ignoring non-regular/non-symlink file: {:?}", entry_name); + continue; + } + + // TODO: Another generic here but constrained to Sha256HashValue + // Regarding this, we'll definitely get DigestMismatch error if SHA512 is being used + // So we query the verity again if we get a DigestMismatch error + let measured_verity = + composefs::fsverity::measure_verity_opt::(entry.open()?); + + let measured_verity = match measured_verity { + Ok(mv) => mv.map(|verity| verity.to_hex()), + + Err(composefs::fsverity::MeasureVerityError::InvalidDigestAlgorithm { .. }) => { + composefs::fsverity::measure_verity_opt::(entry.open()?)? + .map(|verity| verity.to_hex()) + } + + Err(e) => Err(e)?, + }; + + if let Some(measured_verity) = measured_verity { + root.insert( + &entry_name, + Inode::Leaf(Rc::new(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Regular(CustomMetadata::new( + "".into(), + Some(measured_verity), + )), + })), + ); + + continue; + } + + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + + let file = entry + .open() + .context(format!("Opening entry {entry_name:?}"))?; + + let mut reader = BufReader::new(file); + std::io::copy(&mut reader, &mut hasher)?; + + let content_digest = hex::encode(hasher.finish()?); + + root.insert( + &entry_name, + Inode::Leaf(Rc::new(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Regular(CustomMetadata::new(content_digest, None)), + })), + ); + } + + Ok(()) +} + +#[derive(Debug)] +enum ModificationType { + Added, + Modified, + Removed, +} + +impl std::fmt::Display for ModificationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl ModificationType { + fn symbol(&self) -> &'static str { + match self { + ModificationType::Added => "+", + ModificationType::Modified => "~", + ModificationType::Removed => "-", + } + } +} + +fn create_dir_with_perms( + new_etc_fd: &CapStdDir, + dir_name: &PathBuf, + stat: &Stat, + new_inode: Option<&Inode>, +) -> anyhow::Result<()> { + // The new directory is not present in the new_etc, so we create it, else we only copy the + // metadata + if new_inode.is_none() { + // Here we use `create_dir_all` to create every parent as we will set the permissions later + // on. Due to the fact that we have an ordered (sorted) list of directories and directory + // entries and we have a DFS traversal, we will aways have directory creation starting from + // the parent anyway. + // + // The exception being, if a directory is modified in the current_etc, and a new directory + // is added inside the modified directory, say `dir/prems` has its premissions modified and + // `dir/prems/new` is the new directory created. Since we handle added files/directories first, + // we will create the directories `perms/new` with directory `new` also getting its + // permissions set, but `perms` will not. `perms` will have its premissions set up when we + // handle the modified directories. + new_etc_fd + .create_dir_all(&dir_name) + .context(format!("Failed to create dir {dir_name:?}"))?; + } + + new_etc_fd + .set_permissions(&dir_name, Permissions::from_mode(stat.st_mode)) + .context(format!("Changing permissions for dir {dir_name:?}"))?; + + rustix::fs::chownat( + &new_etc_fd, + dir_name, + Some(Uid::from_raw(stat.st_uid)), + Some(Gid::from_raw(stat.st_gid)), + AtFlags::SYMLINK_NOFOLLOW, + ) + .context(format!("chown {dir_name:?}"))?; + + copy_xattrs(&stat.xattrs, new_etc_fd, dir_name)?; + + Ok(()) +} + +fn merge_leaf( + current_etc_fd: &CapStdDir, + new_etc_fd: &CapStdDir, + leaf: &Rc>, + new_inode: Option<&Inode>, + file: &PathBuf, +) -> anyhow::Result<()> { + let symlink = match &leaf.content { + LeafContent::Regular(..) => None, + LeafContent::Symlink(target) => Some(target), + + _ => { + tracing::debug!("Found non file/symlink while merging. Ignoring"); + return Ok(()); + } + }; + + if matches!(new_inode, Some(Inode::Directory(..))) { + anyhow::bail!("Modified config file {file:?} newly defaults to directory. Cannot merge") + }; + + // If a new file with the same path exists, we delete it + new_etc_fd + .remove_all_optional(&file) + .context(format!("Deleting {file:?}"))?; + + if let Some(target) = symlink { + // Using rustix's symlinkat here as we might have absolute symlinks which clash with ambient_authority + symlinkat(&**target, new_etc_fd, file).context(format!("Creating symlink {file:?}"))?; + } else { + current_etc_fd + .copy(&file, new_etc_fd, &file) + .context(format!("Copying file {file:?}"))?; + }; + + rustix::fs::chownat( + &new_etc_fd, + file, + Some(Uid::from_raw(leaf.stat.st_uid)), + Some(Gid::from_raw(leaf.stat.st_gid)), + AtFlags::SYMLINK_NOFOLLOW, + ) + .context(format!("chown {file:?}"))?; + + copy_xattrs(&leaf.stat.xattrs, new_etc_fd, file)?; + + Ok(()) +} + +fn merge_modified_files( + files: &Vec, + current_etc_fd: &CapStdDir, + current_etc_dirtree: &Directory, + new_etc_fd: &CapStdDir, + new_etc_dirtree: &Directory, +) -> anyhow::Result<()> { + for file in files { + let (dir, filename) = current_etc_dirtree + .split(OsStr::new(&file)) + .context("Getting directory and file")?; + + let current_inode = dir + .lookup(filename) + .ok_or_else(|| anyhow::anyhow!("{filename:?} not found"))?; + + // This will error out if some directory in a chain does not exist + let res = new_etc_dirtree.split(OsStr::new(&file)); + + match res { + Ok((new_dir, filename)) => { + let new_inode = new_dir.lookup(filename); + + match current_inode { + Inode::Directory(..) => { + create_dir_with_perms(new_etc_fd, file, current_inode.stat(), new_inode)?; + } + + Inode::Leaf(leaf) => { + merge_leaf(current_etc_fd, new_etc_fd, leaf, new_inode, file)? + } + }; + } + + // Directory/File does not exist in the new /etc + Err(ImageError::NotFound(..)) => match current_inode { + Inode::Directory(..) => { + create_dir_with_perms(new_etc_fd, file, current_inode.stat(), None)? + } + + Inode::Leaf(leaf) => { + merge_leaf(current_etc_fd, new_etc_fd, leaf, None, file)?; + } + }, + + Err(e) => Err(e)?, + }; + } + + Ok(()) +} + +/// Goes through the added, modified, removed files and apply those changes to the new_etc +/// This will overwrite, remove, modify files in new_etc +/// Paths in `diff` are relative to `etc` +#[context("Merging")] +pub fn merge( + current_etc_fd: &CapStdDir, + current_etc_dirtree: &Directory, + new_etc_fd: &CapStdDir, + new_etc_dirtree: &Directory, + diff: Diff, +) -> anyhow::Result<()> { + merge_modified_files( + &diff.added, + current_etc_fd, + current_etc_dirtree, + new_etc_fd, + new_etc_dirtree, + ) + .context("Merging added files")?; + + merge_modified_files( + &diff.modified, + current_etc_fd, + current_etc_dirtree, + new_etc_fd, + new_etc_dirtree, + ) + .context("Merging modified files")?; + + for removed in diff.removed { + let stat = new_etc_fd.metadata_optional(&removed)?; + + let Some(stat) = stat else { + // File/dir doesn't exist in new_etc + // Basically a no-op + continue; + }; + + if stat.is_file() || stat.is_symlink() { + new_etc_fd.remove_file(&removed)?; + } else if stat.is_dir() { + // We only add the directory to the removed array, if the entire directory was deleted + // So `remove_dir_all` should be okay here + new_etc_fd.remove_dir_all(&removed)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use cap_std::fs::PermissionsExt; + use cap_std_ext::cap_std::fs::Metadata; + + use super::*; + + const FILES: &[(&str, &str)] = &[ + ("a/file1", "a-file1"), + ("a/file2", "a-file2"), + ("a/b/file1", "ab-file1"), + ("a/b/file2", "ab-file2"), + ("a/b/c/fileabc", "abc-file1"), + ("a/b/c/modify-perms", "modify-perms"), + ("a/b/c/to-be-removed", "remove this"), + ("to-be-removed", "remove this 2"), + ]; + + #[test] + fn test_etc_diff() -> anyhow::Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + tempdir.create_dir("pristine_etc")?; + tempdir.create_dir("current_etc")?; + tempdir.create_dir("new_etc")?; + + let p = tempdir.open_dir("pristine_etc")?; + let c = tempdir.open_dir("current_etc")?; + let n = tempdir.open_dir("new_etc")?; + + p.create_dir_all("a/b/c")?; + c.create_dir_all("a/b/c")?; + + for (file, content) in FILES { + p.write(file, content.as_bytes())?; + c.write(file, content.as_bytes())?; + } + + let new_files = ["new_file", "a/new_file", "a/b/c/new_file"]; + + // Add some new files + for file in new_files { + c.write(file, b"hello")?; + } + + let overwritten_files = [FILES[1].0, FILES[4].0]; + let perm_changed_files = [FILES[5].0]; + + // Modify some files + c.write(overwritten_files[0], b"some new content")?; + c.write(overwritten_files[1], b"some newer content")?; + + // Modify permissions + let file = c.open(perm_changed_files[0])?; + // This should be enough as the usual files have permission 644 + file.set_permissions(cap_std::fs::Permissions::from_mode(0o400))?; + + // Remove some files + let deleted_files = [FILES[6].0, FILES[7].0]; + c.remove_file(deleted_files[0])?; + c.remove_file(deleted_files[1])?; + + let (pristine_etc_files, current_etc_files, _) = traverse_etc(&p, &c, Some(&n))?; + let res = compute_diff(&pristine_etc_files, ¤t_etc_files)?; + + // Test added files + assert_eq!(res.added.len(), new_files.len()); + assert!(res.added.iter().all(|file| { + new_files + .iter() + .find(|x| PathBuf::from(*x) == *file) + .is_some() + })); + + // Test modified files + let all_modified_files = overwritten_files + .iter() + .chain(&perm_changed_files) + .collect::>(); + + assert_eq!(res.modified.len(), all_modified_files.len()); + assert!(res.modified.iter().all(|file| { + all_modified_files + .iter() + .find(|x| PathBuf::from(*x) == *file) + .is_some() + })); + + // Test removed files + assert_eq!(res.removed.len(), deleted_files.len()); + assert!(res.removed.iter().all(|file| { + deleted_files + .iter() + .find(|x| PathBuf::from(*x) == *file) + .is_some() + })); + + Ok(()) + } + + fn compare_meta(meta1: Metadata, meta2: Metadata) -> bool { + return meta1.is_file() == meta2.is_file() + && meta1.is_dir() == meta2.is_dir() + && meta1.is_symlink() == meta2.is_symlink() + && meta1.mode() == meta2.mode() + && meta1.uid() == meta2.uid() + && meta1.gid() == meta2.gid(); + } + + fn files_eq(current_etc: &CapStdDir, new_etc: &CapStdDir, path: &str) -> anyhow::Result { + return Ok( + compare_meta(current_etc.metadata(path)?, new_etc.metadata(path)?) + && current_etc.read(path)? == new_etc.read(path)?, + ); + } + + #[test] + fn test_merge() -> anyhow::Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + tempdir.create_dir("pristine_etc")?; + tempdir.create_dir("current_etc")?; + tempdir.create_dir("new_etc")?; + + let p = tempdir.open_dir("pristine_etc")?; + let c = tempdir.open_dir("current_etc")?; + let n = tempdir.open_dir("new_etc")?; + + p.create_dir_all("a/b")?; + c.create_dir_all("a/b")?; + n.create_dir_all("a/b")?; + + // File added in current_etc, with file NOT present in new_etc + // arbitrary nesting + c.write("new_file.txt", "text1")?; + c.write("a/new_file.txt", "text2")?; + c.write("a/b/new_file.txt", "text3")?; + + // File added in current_etc, with file present in new_etc + c.write("present_file.txt", "new-present-text1")?; + c.write("a/present_file.txt", "new-present-text2")?; + c.write("a/b/present_file.txt", "new-present-text3")?; + + n.write("present_file.txt", "present-text1")?; + n.write("a/present_file.txt", "present-text2")?; + n.write("a/b/present_file.txt", "present-text3")?; + + // File (content) modified in current_etc, with file NOT PRESENT in new_etc + p.write("content-modify.txt", "old-content1")?; + p.write("a/content-modify.txt", "old-content2")?; + p.write("a/b/content-modify.txt", "old-content3")?; + + c.write("content-modify.txt", "new-content1")?; + c.write("a/content-modify.txt", "new-content2")?; + c.write("a/b/content-modify.txt", "new-content3")?; + + // File (content) modified in current_etc, with file PRESENT in new_etc + p.write("content-modify-present.txt", "old-present-content1")?; + p.write("a/content-modify-present.txt", "old-present-content2")?; + p.write("a/b/content-modify-present.txt", "old-present-content3")?; + + c.write("content-modify-present.txt", "current-present-content1")?; + c.write("a/content-modify-present.txt", "current-present-content2")?; + c.write("a/b/content-modify-present.txt", "current-present-content3")?; + + n.write("content-modify-present.txt", "new-present-content1")?; + n.write("a/content-modify-present.txt", "new-present-content2")?; + n.write("a/b/content-modify-present.txt", "new-present-content3")?; + + // File (permission) modified in current_etc, with file NOT PRESENT in new_etc + p.write("permission-modify.txt", "old-content1")?; + p.write("a/permission-modify.txt", "old-content2")?; + p.write("a/b/permission-modify.txt", "old-content3")?; + + c.atomic_write_with_perms( + "permission-modify.txt", + "old-content1", + Permissions::from_mode(0o755), + )?; + c.atomic_write_with_perms( + "a/permission-modify.txt", + "old-content2", + Permissions::from_mode(0o766), + )?; + c.atomic_write_with_perms( + "a/b/permission-modify.txt", + "old-content3", + Permissions::from_mode(0o744), + )?; + + // File (permission) modified in current_etc, with file PRESENT in new_etc + p.write("permission-modify-present.txt", "old-present-content1")?; + p.write("a/permission-modify-present.txt", "old-present-content2")?; + p.write("a/b/permission-modify-present.txt", "old-present-content3")?; + + c.atomic_write_with_perms( + "permission-modify-present.txt", + "old-present-content1", + Permissions::from_mode(0o755), + )?; + c.atomic_write_with_perms( + "a/permission-modify-present.txt", + "old-present-content2", + Permissions::from_mode(0o766), + )?; + c.atomic_write_with_perms( + "a/b/permission-modify-present.txt", + "old-present-content3", + Permissions::from_mode(0o744), + )?; + + n.write("permission-modify-present.txt", "new-present-content1")?; + n.write("a/permission-modify-present.txt", "old-present-content2")?; + n.write("a/b/permission-modify-present.txt", "new-present-content3")?; + + // Create a new dirtree + c.create_dir_all("new/dir/tree/here")?; + + // Create a new dirtree in an already existing dirtree + p.create_dir_all("existing/tree")?; + c.create_dir_all("existing/tree/another/dir/tree")?; + c.write( + "existing/tree/another/dir/tree/file.txt", + "dir-tree-contents", + )?; + + // Directory permissions + p.create_dir_all("dir/perms")?; + p.create_dir_all("dir/perms/wo")?; + p.create_dir_all("dir/perms/wo/ro")?; + + c.create_dir_all("dir/perms")?; + c.set_permissions("dir/perms", Permissions::from_mode(0o777))?; + + c.create_dir_all("dir/perms/rwx")?; + c.set_permissions("dir/perms/rwx", Permissions::from_mode(0o777))?; + + c.create_dir_all("dir/perms/wo")?; + c.set_permissions("dir/perms/wo", Permissions::from_mode(0o733))?; + + c.create_dir_all("dir/perms/wo/ro")?; + c.set_permissions("dir/perms/wo/ro", Permissions::from_mode(0o775))?; + + n.create_dir_all("dir/perms")?; + n.write("dir/perms/some-file", "Some-file")?; + + let (pristine_etc_files, current_etc_files, new_etc_files) = + traverse_etc(&p, &c, Some(&n))?; + let diff = compute_diff(&pristine_etc_files, ¤t_etc_files)?; + merge(&c, ¤t_etc_files, &n, &new_etc_files.unwrap(), diff)?; + + assert!(files_eq(&c, &n, "new_file.txt")?); + assert!(files_eq(&c, &n, "a/new_file.txt")?); + assert!(files_eq(&c, &n, "a/b/new_file.txt")?); + + assert!(files_eq(&c, &n, "present_file.txt")?); + assert!(files_eq(&c, &n, "a/present_file.txt")?); + assert!(files_eq(&c, &n, "a/b/present_file.txt")?); + + assert!(files_eq(&c, &n, "content-modify.txt")?); + assert!(files_eq(&c, &n, "a/content-modify.txt")?); + assert!(files_eq(&c, &n, "a/b/content-modify.txt")?); + + assert!(files_eq(&c, &n, "content-modify-present.txt")?); + assert!(files_eq(&c, &n, "a/content-modify-present.txt")?); + assert!(files_eq(&c, &n, "a/b/content-modify-present.txt")?); + + assert!(files_eq(&c, &n, "permission-modify.txt")?); + assert!(files_eq(&c, &n, "a/permission-modify.txt")?); + assert!(files_eq(&c, &n, "a/b/permission-modify.txt")?); + + assert!(files_eq(&c, &n, "permission-modify-present.txt")?); + assert!(files_eq(&c, &n, "a/permission-modify-present.txt")?); + assert!(files_eq(&c, &n, "a/b/permission-modify-present.txt")?); + + assert!(n.exists("new/dir/tree/here")); + assert!(n.exists("existing/tree/another/dir/tree")); + assert!(files_eq(&c, &n, "existing/tree/another/dir/tree/file.txt")?); + + assert!(compare_meta( + c.metadata("dir/perms")?, + n.metadata("dir/perms")? + )); + + // Make sure nothing is deleted from a directory + assert!(n.exists("dir/perms/some-file")); + + const DIR_BITS: u32 = 0o040000; + + assert_eq!( + n.metadata("dir/perms/rwx").unwrap().mode(), + DIR_BITS | 0o777 + ); + assert_eq!(n.metadata("dir/perms/wo").unwrap().mode(), DIR_BITS | 0o733); + assert_eq!( + n.metadata("dir/perms/wo/ro").unwrap().mode(), + DIR_BITS | 0o775 + ); + + Ok(()) + } + + #[test] + fn file_to_dir() -> anyhow::Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + tempdir.create_dir("pristine_etc")?; + tempdir.create_dir("current_etc")?; + tempdir.create_dir("new_etc")?; + + let p = tempdir.open_dir("pristine_etc")?; + let c = tempdir.open_dir("current_etc")?; + let n = tempdir.open_dir("new_etc")?; + + p.write("file-to-dir", "some text")?; + c.write("file-to-dir", "some text 1")?; + + n.create_dir_all("file-to-dir")?; + + let (pristine_etc_files, current_etc_files, new_etc_files) = + traverse_etc(&p, &c, Some(&n))?; + let diff = compute_diff(&pristine_etc_files, ¤t_etc_files)?; + + let merge_res = merge(&c, ¤t_etc_files, &n, &new_etc_files.unwrap(), diff); + + assert!(merge_res.is_err()); + assert_eq!( + merge_res.unwrap_err().root_cause().to_string(), + "Modified config file \"file-to-dir\" newly defaults to directory. Cannot merge" + ); + + Ok(()) + } +} diff --git a/crates/initramfs/Cargo.toml b/crates/initramfs/Cargo.toml new file mode 100644 index 000000000..94bebd858 --- /dev/null +++ b/crates/initramfs/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bootc-initramfs-setup" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, features = ["std", "help", "usage", "derive"] } +libc.workspace = true +rustix.workspace = true +serde = { workspace = true, features = ["derive"] } +composefs.workspace = true +composefs-boot.workspace = true +toml.workspace = true +fn-error-context.workspace = true + +[lints] +workspace = true + +[features] +default = ['pre-6.15'] +rhel9 = ['composefs/rhel9'] +'pre-6.15' = ['composefs/pre-6.15'] diff --git a/crates/initramfs/bootc-root-setup.service b/crates/initramfs/bootc-root-setup.service new file mode 100644 index 000000000..23525c7bc --- /dev/null +++ b/crates/initramfs/bootc-root-setup.service @@ -0,0 +1,21 @@ +[Unit] +Description=bootc setup root +Documentation=man:bootc(1) +DefaultDependencies=no +ConditionKernelCommandLine=composefs +ConditionPathExists=/etc/initrd-release +After=sysroot.mount +After=ostree-prepare-root.service +Requires=sysroot.mount +Before=initrd-root-fs.target + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/lib/bootc/initramfs-setup setup-root +StandardInput=null +StandardOutput=journal +StandardError=journal+console +RemainAfterExit=yes diff --git a/crates/initramfs/dracut/module-setup.sh b/crates/initramfs/dracut/module-setup.sh new file mode 100755 index 000000000..2e5187dfd --- /dev/null +++ b/crates/initramfs/dracut/module-setup.sh @@ -0,0 +1,20 @@ +#!/bin/bash +installkernel() { + instmods erofs overlay +} +check() { + # We are never installed by default; see 10-bootc-base.conf + # for how base images can opt in. + return 255 +} +depends() { + return 0 +} +install() { + local service=bootc-root-setup.service + dracut_install /usr/lib/bootc/initramfs-setup + inst_simple "${systemdsystemunitdir}/${service}" + mkdir -p "${initdir}${systemdsystemconfdir}/initrd-root-fs.target.wants" + ln_r "${systemdsystemunitdir}/${service}" \ + "${systemdsystemconfdir}/initrd-root-fs.target.wants/${service}" +} diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs new file mode 100644 index 000000000..9c957e87c --- /dev/null +++ b/crates/initramfs/src/lib.rs @@ -0,0 +1,373 @@ +//! Mount helpers for bootc-initramfs + +use std::{ + ffi::OsString, + fmt::Debug, + io::ErrorKind, + os::fd::{AsFd, AsRawFd, OwnedFd}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use clap::Parser; +use rustix::{ + fs::{major, minor, mkdirat, openat, stat, symlink, Mode, OFlags, CWD}, + io::Errno, + mount::{ + fsconfig_create, fsconfig_set_string, fsmount, open_tree, unmount, FsMountFlags, + MountAttrFlags, OpenTreeFlags, UnmountFlags, + }, + path, +}; +use serde::Deserialize; + +use composefs::{ + fsverity::{FsVerityHashValue, Sha512HashValue}, + mount::FsHandle, + mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount}, + repository::Repository, +}; +use composefs_boot::cmdline::get_cmdline_composefs; + +use fn_error_context::context; + +// mount_setattr syscall support +const MOUNT_ATTR_RDONLY: u64 = 0x00000001; + +#[repr(C)] +struct MountAttr { + attr_set: u64, + attr_clr: u64, + propagation: u64, + userns_fd: u64, +} + +/// Set mount attributes using mount_setattr syscall +#[context("Setting mount attributes")] +#[allow(unsafe_code)] +fn mount_setattr(fd: impl AsFd, flags: libc::c_int, attr: &MountAttr) -> Result<()> { + let ret = unsafe { + libc::syscall( + libc::SYS_mount_setattr, + fd.as_fd().as_raw_fd(), + c"".as_ptr(), + flags, + attr as *const MountAttr, + std::mem::size_of::(), + ) + }; + if ret == -1 { + Err(std::io::Error::last_os_error())?; + } + Ok(()) +} + +/// Set mount to readonly +#[context("Setting mount readonly")] +fn set_mount_readonly(fd: impl AsFd) -> Result<()> { + let attr = MountAttr { + attr_set: MOUNT_ATTR_RDONLY, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + mount_setattr(fd, libc::AT_EMPTY_PATH, &attr) +} + +// Config file +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MountType { + None, + Bind, + Overlay, + Transient, +} + +#[derive(Debug, Default, Deserialize)] +struct RootConfig { + #[serde(default)] + transient: bool, +} + +#[derive(Debug, Default, Deserialize)] +struct MountConfig { + mount: Option, + #[serde(default)] + transient: bool, +} + +#[derive(Deserialize, Default)] +struct Config { + #[serde(default)] + etc: MountConfig, + #[serde(default)] + var: MountConfig, + #[serde(default)] + root: RootConfig, +} + +/// Command-line arguments +#[derive(Parser, Debug)] +#[command(version)] +pub struct Args { + #[arg(help = "Execute this command (for testing)")] + /// Execute this command (for testing) + pub cmd: Vec, + + #[arg( + long, + default_value = "/sysroot", + help = "sysroot directory in initramfs" + )] + /// sysroot directory in initramfs + pub sysroot: PathBuf, + + #[arg( + long, + default_value = "/usr/lib/composefs/setup-root-conf.toml", + help = "Config path (for testing)" + )] + /// Config path (for testing) + pub config: PathBuf, + + // we want to test in a userns, but can't mount erofs there + #[arg(long, help = "Bind mount root-fs from (for testing)")] + /// Bind mount root-fs from (for testing) + pub root_fs: Option, + + #[arg(long, help = "Kernel commandline args (for testing)")] + /// Kernel commandline args (for testing) + pub cmdline: Option, + + #[arg(long, help = "Mountpoint (don't replace sysroot, for testing)")] + /// Mountpoint (don't replace sysroot, for testing) + pub target: Option, +} + +/// Wrapper around [`composefs::mount::mount_at`] +pub fn mount_at_wrapper( + fs_fd: impl AsFd, + dirfd: impl AsFd, + path: impl path::Arg + Debug + Clone, +) -> Result<()> { + composefs::mount::mount_at(fs_fd, dirfd, path.clone()) + .with_context(|| format!("Mounting at path {path:?}")) +} + +/// Wrapper around [`rustix::fs::openat`] +#[context("Opening dir {name:?}")] +pub fn open_dir(dirfd: impl AsFd, name: impl AsRef + Debug) -> Result { + let res = openat( + dirfd, + name.as_ref(), + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ); + + Ok(res?) +} + +#[context("Ensure dir")] +fn ensure_dir(dirfd: impl AsFd, name: &str) -> Result { + match mkdirat(dirfd.as_fd(), name, 0o700.into()) { + Ok(()) | Err(Errno::EXIST) => {} + Err(err) => Err(err).with_context(|| format!("Creating dir {name}"))?, + } + + open_dir(dirfd, name) +} + +#[context("Bind mounting to path {path}")] +fn bind_mount(fd: impl AsFd, path: &str) -> Result { + let res = open_tree( + fd.as_fd(), + path, + OpenTreeFlags::OPEN_TREE_CLONE + | OpenTreeFlags::OPEN_TREE_CLOEXEC + | OpenTreeFlags::AT_EMPTY_PATH, + ); + + Ok(res?) +} + +#[context("Mounting tmpfs")] +fn mount_tmpfs() -> Result { + let tmpfs = FsHandle::open("tmpfs")?; + fsconfig_create(tmpfs.as_fd())?; + Ok(fsmount( + tmpfs.as_fd(), + FsMountFlags::FSMOUNT_CLOEXEC, + MountAttrFlags::empty(), + )?) +} + +#[context("Mounting state as overlay")] +fn overlay_state(base: impl AsFd, state: impl AsFd, source: &str) -> Result<()> { + let upper = ensure_dir(state.as_fd(), "upper")?; + let work = ensure_dir(state.as_fd(), "work")?; + + let overlayfs = FsHandle::open("overlay")?; + fsconfig_set_string(overlayfs.as_fd(), "source", source)?; + overlayfs_set_fd(overlayfs.as_fd(), "workdir", work.as_fd())?; + overlayfs_set_fd(overlayfs.as_fd(), "upperdir", upper.as_fd())?; + overlayfs_set_lower_and_data_fds(&overlayfs, base.as_fd(), None::)?; + fsconfig_create(overlayfs.as_fd())?; + let fs = fsmount( + overlayfs.as_fd(), + FsMountFlags::FSMOUNT_CLOEXEC, + MountAttrFlags::empty(), + )?; + + mount_at_wrapper(fs, base, ".").context("Moving mount") +} + +/// Mounts a transient overlayfs with passed in fd as the lowerdir +#[context("Mounting transient overlayfs")] +pub fn overlay_transient(base: impl AsFd) -> Result<()> { + overlay_state(base, prepare_mount(mount_tmpfs()?)?, "transient") +} + +#[context("Opening rootfs")] +fn open_root_fs(path: &Path) -> Result { + let rootfs = open_tree( + CWD, + path, + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, + )?; + + set_mount_readonly(&rootfs)?; + + Ok(rootfs) +} + +/// Prepares a floating mount for composefs and returns the fd +/// +/// # Arguments +/// * sysroot - fd for /sysroot +/// * name - Name of the EROFS image to be mounted +/// * insecure - Whether fsverity is optional or not +#[context("Mounting composefs image")] +pub fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Result { + let mut repo = Repository::::open_path(sysroot, "composefs")?; + repo.set_insecure(insecure); + let rootfs = repo + .mount(name) + .context("Failed to mount composefs image")?; + + set_mount_readonly(&rootfs)?; + + Ok(rootfs) +} + +#[context("Mounting subdirectory")] +fn mount_subdir( + new_root: impl AsFd, + state: impl AsFd, + subdir: &str, + config: MountConfig, + default: MountType, +) -> Result<()> { + let mount_type = match config.mount { + Some(mt) => mt, + None => match config.transient { + true => MountType::Transient, + false => default, + }, + }; + + match mount_type { + MountType::None => Ok(()), + MountType::Bind => Ok(mount_at_wrapper( + bind_mount(&state, subdir)?, + &new_root, + subdir, + )?), + MountType::Overlay => overlay_state( + open_dir(&new_root, subdir)?, + open_dir(&state, subdir)?, + "overlay", + ), + MountType::Transient => overlay_transient(open_dir(&new_root, subdir)?), + } +} + +#[context("GPT workaround")] +/// Workaround for /dev/gpt-auto-root +pub fn gpt_workaround() -> Result<()> { + // https://github.com/systemd/systemd/issues/35017 + let rootdev = stat("/dev/gpt-auto-root"); + + let rootdev = match rootdev { + Ok(r) => r, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()), + Err(e) => Err(e)?, + }; + + let target = format!( + "/dev/block/{}:{}", + major(rootdev.st_rdev), + minor(rootdev.st_rdev) + ); + symlink(target, "/run/systemd/volatile-root")?; + Ok(()) +} + +/// Sets up /sysroot for switch-root +#[context("Setting up /sysroot")] +pub fn setup_root(args: Args) -> Result<()> { + let config = match std::fs::read_to_string(args.config) { + Ok(text) => toml::from_str(&text)?, + Err(err) if err.kind() == ErrorKind::NotFound => Config::default(), + Err(err) => Err(err)?, + }; + + let sysroot = open_dir(CWD, &args.sysroot) + .with_context(|| format!("Failed to open sysroot {:?}", args.sysroot))?; + + let cmdline = match &args.cmdline { + Some(cmdline) => cmdline, + // TODO: Deduplicate this with composefs branch karg parser + None => &std::fs::read_to_string("/proc/cmdline")?, + }; + let (image, insecure) = get_cmdline_composefs::(cmdline)?; + + let new_root = match args.root_fs { + Some(path) => open_root_fs(&path).context("Failed to clone specified root fs")?, + None => mount_composefs_image(&sysroot, &image.to_hex(), insecure)?, + }; + + // we need to clone this before the next step to make sure we get the old one + let sysroot_clone = bind_mount(&sysroot, "")?; + + set_mount_readonly(&sysroot_clone)?; + + // Ideally we build the new root filesystem together before we mount it, but that only works on + // 6.15 and later. Before 6.15 we can't mount into a floating tree, so mount it first. This + // will leave an abandoned clone of the sysroot mounted under it, but that's OK for now. + if cfg!(feature = "pre-6.15") { + mount_at_wrapper(&new_root, CWD, &args.sysroot)?; + } + + if config.root.transient { + overlay_transient(&new_root)?; + } + + match composefs::mount::mount_at(&sysroot_clone, &new_root, "sysroot") { + Ok(()) | Err(Errno::NOENT) => {} + Err(err) => Err(err)?, + } + + // etc + var + let state = open_dir(open_dir(&sysroot, "state/deploy")?, image.to_hex())?; + mount_subdir(&new_root, &state, "etc", config.etc, MountType::Bind)?; + mount_subdir(&new_root, &state, "var", config.var, MountType::Bind)?; + + if cfg!(not(feature = "pre-6.15")) { + // Replace the /sysroot with the new composed root filesystem + unmount(&args.sysroot, UnmountFlags::DETACH)?; + mount_at_wrapper(&new_root, CWD, &args.sysroot)?; + } + + Ok(()) +} diff --git a/crates/initramfs/src/main.rs b/crates/initramfs/src/main.rs new file mode 100644 index 000000000..ebe266a8e --- /dev/null +++ b/crates/initramfs/src/main.rs @@ -0,0 +1,13 @@ +//! Code for bootc that goes into the initramfs. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::Result; + +use bootc_initramfs_setup::{gpt_workaround, setup_root, Args}; +use clap::Parser; + +fn main() -> Result<()> { + let args = Args::parse(); + gpt_workaround()?; + setup_root(args) +} diff --git a/crates/kernel_cmdline/Cargo.toml b/crates/kernel_cmdline/Cargo.toml new file mode 100644 index 000000000..548b1f82f --- /dev/null +++ b/crates/kernel_cmdline/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bootc-kernel-cmdline" +description = "Kernel command line parsing utilities for bootc" +version = "0.0.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/bootc-dev/bootc" + +[dependencies] +# Workspace dependencies +anyhow = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +similar-asserts = { workspace = true } +static_assertions = { workspace = true } + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/kernel_cmdline/src/bytes.rs b/crates/kernel_cmdline/src/bytes.rs new file mode 100644 index 000000000..ac6e70f66 --- /dev/null +++ b/crates/kernel_cmdline/src/bytes.rs @@ -0,0 +1,1127 @@ +//! Byte-based kernel command line parsing utilities. +//! +//! This module provides functionality for parsing and working with kernel command line +//! arguments, supporting both key-only switches and key-value pairs with proper quote handling. + +use std::borrow::Cow; +use std::cmp::Ordering; +use std::ops::Deref; + +use crate::{utf8, Action}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// A parsed kernel command line. +/// +/// Wraps the raw command line bytes and provides methods for parsing and iterating +/// over individual parameters. Uses copy-on-write semantics to avoid unnecessary +/// allocations when working with borrowed data. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Cmdline<'a>(Cow<'a, [u8]>); + +/// An owned Cmdline. Alias for `Cmdline<'static>`. +pub type CmdlineOwned = Cmdline<'static>; + +impl<'a, T: AsRef<[u8]> + ?Sized> From<&'a T> for Cmdline<'a> { + /// Creates a new `Cmdline` from any type that can be referenced as bytes. + /// + /// Uses borrowed data when possible to avoid unnecessary allocations. + fn from(input: &'a T) -> Self { + Self(Cow::Borrowed(input.as_ref())) + } +} + +impl Deref for Cmdline<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a, T> AsRef for Cmdline<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +impl From> for CmdlineOwned { + /// Creates a new `Cmdline` from an owned `Vec`. + fn from(input: Vec) -> Self { + Self(Cow::Owned(input)) + } +} + +/// An iterator over kernel command line parameters. +/// +/// This is created by the `iter` method on `Cmdline`. +#[derive(Debug)] +pub struct CmdlineIter<'a>(CmdlineIterBytes<'a>); + +impl<'a> Iterator for CmdlineIter<'a> { + type Item = Parameter<'a>; + + fn next(&mut self) -> Option { + self.0.next().and_then(Parameter::parse_internal) + } +} + +/// An iterator over kernel command line parameters as byte slices. +/// +/// This is created by the `iter_bytes` method on `Cmdline`. +#[derive(Debug)] +pub struct CmdlineIterBytes<'a>(&'a [u8]); + +impl<'a> Iterator for CmdlineIterBytes<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + let input = self.0.trim_ascii_start(); + + if input.is_empty() { + self.0 = input; + return None; + } + + let mut in_quotes = false; + let end = input.iter().position(move |c| { + if *c == b'"' { + in_quotes = !in_quotes; + } + !in_quotes && c.is_ascii_whitespace() + }); + + let end = end.unwrap_or(input.len()); + let (param, rest) = input.split_at(end); + self.0 = rest; + + Some(param) + } +} + +impl<'a> Cmdline<'a> { + /// Creates a new empty owned `Cmdline`. + /// + /// This is equivalent to `Cmdline::default()` but makes ownership explicit. + pub fn new() -> CmdlineOwned { + Cmdline::default() + } + + /// Reads the kernel command line from `/proc/cmdline`. + /// + /// Returns an error if the file cannot be read or if there are I/O issues. + pub fn from_proc() -> Result { + Ok(Self(Cow::Owned(std::fs::read("/proc/cmdline")?))) + } + + /// Returns an iterator over all parameters in the command line. + /// + /// Properly handles quoted values containing whitespace and splits on + /// unquoted whitespace characters. Parameters are parsed as either + /// key-only switches or key=value pairs. + pub fn iter(&'a self) -> CmdlineIter<'a> { + CmdlineIter(self.iter_bytes()) + } + + /// Returns an iterator over all parameters in the command line as byte slices. + /// + /// This is similar to `iter()` but yields `&[u8]` directly instead of `Parameter`, + /// which can be more convenient when you just need the raw byte representation. + pub fn iter_bytes(&self) -> CmdlineIterBytes<'_> { + CmdlineIterBytes(&self.0) + } + + /// Returns an iterator over all parameters in the command line + /// which are valid UTF-8. + pub fn iter_utf8(&'a self) -> impl Iterator> { + self.iter() + .filter_map(|p| utf8::Parameter::try_from(p).ok()) + } + + /// Locate a kernel argument with the given key name. + /// + /// Returns the first parameter matching the given key, or `None` if not found. + /// Key comparison treats dashes and underscores as equivalent. + pub fn find + ?Sized>(&'a self, key: &T) -> Option> { + let key = ParameterKey(key.as_ref()); + self.iter().find(|p| p.key == key) + } + + /// Locate a kernel argument with the given key name. + /// + /// Returns an error if a parameter with the given key name is + /// found, but the value is not valid UTF-8. + /// + /// Otherwise, returns the first parameter matching the given key, + /// or `None` if not found. Key comparison treats dashes and + /// underscores as equivalent. + pub fn find_utf8 + ?Sized>( + &'a self, + key: &T, + ) -> Result>> { + let bytes = match self.find(key.as_ref()) { + Some(p) => p, + None => return Ok(None), + }; + + Ok(Some(utf8::Parameter::try_from(bytes)?)) + } + + /// Find all kernel arguments starting with the given prefix. + /// + /// This is a variant of [`Self::find`]. + pub fn find_all_starting_with + ?Sized>( + &'a self, + prefix: &'a T, + ) -> impl Iterator> + 'a { + self.iter() + .filter(move |p| p.key.0.starts_with(prefix.as_ref())) + } + + /// Locate the value of the kernel argument with the given key name. + /// + /// Returns the first value matching the given key, or `None` if not found. + /// Key comparison treats dashes and underscores as equivalent. + pub fn value_of + ?Sized>(&'a self, key: &T) -> Option<&'a [u8]> { + self.find(&key).and_then(|p| p.value) + } + + /// Find the value of the kernel argument with the provided name, which must be present. + /// + /// Otherwise the same as [`Self::value_of`]. + pub fn require_value_of + ?Sized>(&'a self, key: &T) -> Result<&'a [u8]> { + let key = key.as_ref(); + self.value_of(key).ok_or_else(|| { + let key = String::from_utf8_lossy(key); + anyhow::anyhow!("Failed to find kernel argument '{key}'") + }) + } + + /// Add a parameter to the command line if it doesn't already exist + /// + /// Returns `Action::Added` if the parameter did not already exist + /// and was added. + /// + /// Returns `Action::Existed` if the exact parameter (same key and value) + /// already exists. No modification was made. + /// + /// Unlike `add_or_modify`, this method will not modify existing + /// parameters. If a parameter with the same key exists but has a + /// different value, the new parameter is still added, allowing + /// duplicate keys (e.g., multiple `console=` parameters). + pub fn add(&mut self, param: &Parameter) -> Action { + // Check if the exact parameter already exists + for p in self.iter() { + if p == *param { + // Exact match found, don't add duplicate + return Action::Existed; + } + } + + // The exact parameter was not found, so we append it. + let self_mut = self.0.to_mut(); + if self_mut + .last() + .filter(|v| !v.is_ascii_whitespace()) + .is_some() + { + self_mut.push(b' '); + } + self_mut.extend_from_slice(param.parameter); + Action::Added + } + + /// Add or modify a parameter to the command line + /// + /// Returns `Action::Added` if the parameter did not exist before + /// and was added. + /// + /// Returns `Action::Modified` if the parameter existed before, + /// but contained a different value. The value was updated to the + /// newly-requested value. + /// + /// Returns `Action::Existed` if the parameter existed before, and + /// contained the same value as the newly-requested value. No + /// modification was made. + pub fn add_or_modify(&mut self, param: &Parameter) -> Action { + let mut new_params = Vec::new(); + let mut modified = false; + let mut seen_key = false; + + for p in self.iter() { + if p.key == param.key { + if !seen_key { + // This is the first time we've seen this key. + // We will replace it with the new parameter. + if p != *param { + modified = true; + } + new_params.push(param.parameter); + } else { + // This is a subsequent parameter with the same key. + // We will remove it, which constitutes a modification. + modified = true; + } + seen_key = true; + } else { + new_params.push(p.parameter); + } + } + + if !seen_key { + // The parameter was not found, so we append it. + let self_mut = self.0.to_mut(); + if self_mut + .last() + .filter(|v| !v.is_ascii_whitespace()) + .is_some() + { + self_mut.push(b' '); + } + self_mut.extend_from_slice(param.parameter); + return Action::Added; + } + if modified { + self.0 = Cow::Owned(new_params.join(b" ".as_slice())); + Action::Modified + } else { + // The parameter already existed with the same content, and there were no duplicates. + Action::Existed + } + } + + /// Remove parameter(s) with the given key from the command line + /// + /// Returns `true` if parameter(s) were removed. + pub fn remove(&mut self, key: &ParameterKey) -> bool { + let mut removed = false; + let mut new_params = Vec::new(); + + for p in self.iter() { + if p.key == *key { + removed = true; + } else { + new_params.push(p.parameter); + } + } + + if removed { + self.0 = Cow::Owned(new_params.join(b" ".as_slice())); + } + + removed + } + + /// Remove all parameters that exactly match the given parameter + /// from the command line + /// + /// Returns `true` if parameter(s) were removed. + pub fn remove_exact(&mut self, param: &Parameter) -> bool { + let mut removed = false; + let mut new_params = Vec::new(); + + for p in self.iter() { + if p == *param { + removed = true; + } else { + new_params.push(p.parameter); + } + } + + if removed { + self.0 = Cow::Owned(new_params.join(b" ".as_slice())); + } + + removed + } + + #[cfg(test)] + pub(crate) fn is_owned(&self) -> bool { + matches!(self.0, Cow::Owned(_)) + } + + #[cfg(test)] + pub(crate) fn is_borrowed(&self) -> bool { + matches!(self.0, Cow::Borrowed(_)) + } +} + +impl<'a> IntoIterator for &'a Cmdline<'a> { + type Item = Parameter<'a>; + type IntoIter = CmdlineIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl<'a, 'other> Extend> for Cmdline<'a> { + fn extend>>(&mut self, iter: T) { + // Note this is O(N*M), but in practice this doesn't matter + // because kernel cmdlines are typically quite small (limited + // to at most 4k depending on arch). Using a hash-based + // structure to reduce this to O(N)+C would likely raise the C + // portion so much as to erase any benefit from removing the + // combinatorial complexity. Plus CPUs are good at + // caching/pipelining through contiguous memory. + for param in iter { + self.add(¶m); + } + } +} + +impl PartialEq for Cmdline<'_> { + fn eq(&self, other: &Self) -> bool { + let mut our_params = self.iter().collect::>(); + our_params.sort(); + let mut their_params = other.iter().collect::>(); + their_params.sort(); + + our_params == their_params + } +} + +impl Eq for Cmdline<'_> {} + +/// A single kernel command line parameter key +/// +/// Handles quoted values and treats dashes and underscores in keys as equivalent. +#[derive(Clone, Debug)] +pub struct ParameterKey<'a>(pub(crate) &'a [u8]); + +impl Deref for ParameterKey<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.0 + } +} + +impl<'a, T> AsRef for ParameterKey<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +impl<'a, T: AsRef<[u8]> + ?Sized> From<&'a T> for ParameterKey<'a> { + fn from(s: &'a T) -> Self { + Self(s.as_ref()) + } +} + +impl ParameterKey<'_> { + /// Returns an iterator over the canonicalized bytes of the + /// parameter, with dashes turned into underscores. + fn iter(&self) -> impl Iterator + use<'_> { + self.0 + .iter() + .map(|&c: &u8| if c == b'-' { b'_' } else { c }) + } +} + +impl PartialEq for ParameterKey<'_> { + /// Compares two parameter keys for equality. + /// + /// Keys are compared with dashes and underscores treated as equivalent. + /// This comparison is case-sensitive. + fn eq(&self, other: &Self) -> bool { + self.iter().eq(other.iter()) + } +} + +impl Eq for ParameterKey<'_> {} + +impl Ord for ParameterKey<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.iter().cmp(other.iter()) + } +} + +impl PartialOrd for ParameterKey<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A single kernel command line parameter. +#[derive(Clone, Debug)] +pub struct Parameter<'a> { + /// The full original value + parameter: &'a [u8], + /// The parameter key as raw bytes + key: ParameterKey<'a>, + /// The parameter value as raw bytes, if present + value: Option<&'a [u8]>, +} + +impl<'a> Parameter<'a> { + /// Attempt to parse a single command line parameter from a slice + /// of bytes. + /// + /// Returns `Some(Parameter)`, or `None` if a Parameter could not + /// be constructed from the input. This occurs when the input is + /// either empty or contains only whitespace. + /// + /// If the input contains multiple parameters, only the first one + /// is parsed and the rest is discarded. + pub fn parse + ?Sized>(input: &'a T) -> Option { + CmdlineIterBytes(input.as_ref()) + .next() + .and_then(Self::parse_internal) + } + + /// Parse a parameter from a byte slice that contains exactly one parameter. + /// + /// This is an internal method that assumes the input has already been + /// split into a single parameter (e.g., by CmdlineIterBytes). + fn parse_internal(input: &'a [u8]) -> Option { + // *Only* the first and last double quotes are stripped + let dequoted_input = input.strip_prefix(b"\"").unwrap_or(input); + let dequoted_input = dequoted_input.strip_suffix(b"\"").unwrap_or(dequoted_input); + + let equals = dequoted_input.iter().position(|b| *b == b'='); + + match equals { + None => Some(Self { + parameter: input, + key: ParameterKey(dequoted_input), + value: None, + }), + Some(i) => { + let (key, mut value) = dequoted_input.split_at(i); + let key = ParameterKey(key); + + // skip `=`, we know it's the first byte because we + // found it above + value = &value[1..]; + + // If there is a quote after the equals, skip it. If + // there was a closing quote at the end of the value, + // we would have already removed it in + // `dequoted_input` above + value = value.strip_prefix(b"\"").unwrap_or(value); + + Some(Self { + parameter: input, + key, + value: Some(value), + }) + } + } + } + + /// Returns the key part of the parameter + pub fn key(&self) -> ParameterKey<'a> { + self.key.clone() + } + + /// Returns the optional value part of the parameter + pub fn value(&self) -> Option<&'a [u8]> { + self.value + } +} + +impl PartialEq for Parameter<'_> { + fn eq(&self, other: &Self) -> bool { + // Note we don't compare parameter because we want hyphen-dash insensitivity for the key + self.key == other.key && self.value == other.value + } +} + +impl Eq for Parameter<'_> {} + +impl Ord for Parameter<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.key.cmp(&other.key).then(self.value.cmp(&other.value)) + } +} + +impl PartialOrd for Parameter<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Deref for Parameter<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.parameter + } +} + +impl<'a, T> AsRef for Parameter<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // convenience methods for tests + fn param(s: &str) -> Parameter<'_> { + Parameter::parse(s.as_bytes()).unwrap() + } + + fn param_utf8(s: &str) -> utf8::Parameter<'_> { + utf8::Parameter::parse(s).unwrap() + } + + #[test] + fn test_parameter_parse() { + let p = Parameter::parse(b"foo").unwrap(); + assert_eq!(p.key.0, b"foo"); + assert_eq!(p.value, None); + + // should parse only the first parameter and discard the rest of the input + let p = Parameter::parse(b"foo=bar baz").unwrap(); + assert_eq!(p.key.0, b"foo"); + assert_eq!(p.value, Some(b"bar".as_slice())); + + // should return None on empty or whitespace inputs + assert!(Parameter::parse(b"").is_none()); + assert!(Parameter::parse(b" ").is_none()); + } + + #[test] + fn test_parameter_simple() { + let switch = param("foo"); + assert_eq!(switch.key.0, b"foo"); + assert_eq!(switch.value, None); + + let kv = param("bar=baz"); + assert_eq!(kv.key.0, b"bar"); + assert_eq!(kv.value, Some(b"baz".as_slice())); + } + + #[test] + fn test_parameter_quoted() { + let p = param("foo=\"quoted value\""); + assert_eq!(p.value, Some(b"quoted value".as_slice())); + + let p = param("foo=\"unclosed quotes"); + assert_eq!(p.value, Some(b"unclosed quotes".as_slice())); + + let p = param("foo=trailing_quotes\""); + assert_eq!(p.value, Some(b"trailing_quotes".as_slice())); + + let outside_quoted = param("\"foo=quoted value\""); + let value_quoted = param("foo=\"quoted value\""); + assert_eq!(outside_quoted, value_quoted); + } + + #[test] + fn test_parameter_extra_whitespace() { + let p = param(" foo=bar "); + assert_eq!(p.key.0, b"foo"); + assert_eq!(p.value, Some(b"bar".as_slice())); + } + + #[test] + fn test_parameter_internal_key_whitespace() { + // parse should only consume the first parameter + let p = Parameter::parse("foo bar=baz".as_bytes()).unwrap(); + assert_eq!(p.key.0, b"foo"); + assert_eq!(p.value, None); + } + + #[test] + fn test_parameter_pathological() { + // valid things that certified insane people would do + + // you can quote just the key part in a key-value param, but + // the end quote is actually part of the key as far as the + // kernel is concerned... + let p = param("\"foo\"=bar"); + assert_eq!(p.key.0, b"foo\""); + assert_eq!(p.value, Some(b"bar".as_slice())); + // and it is definitely not equal to an unquoted foo ... + assert_ne!(p, param("foo=bar")); + + // ... but if you close the quote immediately after the + // equals sign, it does get removed. + let p = param("\"foo=\"bar"); + assert_eq!(p.key.0, b"foo"); + assert_eq!(p.value, Some(b"bar".as_slice())); + // ... so of course this makes sense ... + assert_eq!(p, param("foo=bar")); + + // quotes only get stripped from the absolute ends of values + let p = param("foo=\"internal\"quotes\"are\"ok\""); + assert_eq!(p.value, Some(b"internal\"quotes\"are\"ok".as_slice())); + + // non-UTF8 things are in fact valid + let non_utf8_byte = b"\xff"; + #[allow(invalid_from_utf8)] + let failed_conversion = str::from_utf8(non_utf8_byte); + assert!(failed_conversion.is_err()); + let mut p = b"foo=".to_vec(); + p.push(non_utf8_byte[0]); + let p = Parameter::parse(&p).unwrap(); + assert_eq!(p.value, Some(non_utf8_byte.as_slice())); + } + + #[test] + fn test_parameter_equality() { + // substrings are not equal + let foo = param("foo"); + let bar = param("foobar"); + assert_ne!(foo, bar); + assert_ne!(bar, foo); + + // dashes and underscores are treated equally + let dashes = param("a-delimited-param"); + let underscores = param("a_delimited_param"); + assert_eq!(dashes, underscores); + + // same key, same values is equal + let dashes = param("a-delimited-param=same_values"); + let underscores = param("a_delimited_param=same_values"); + assert_eq!(dashes, underscores); + + // same key, different values is not equal + let dashes = param("a-delimited-param=different_values"); + let underscores = param("a_delimited_param=DiFfErEnT_valUEZ"); + assert_ne!(dashes, underscores); + + // mixed variants are never equal + let switch = param("same_key"); + let keyvalue = param("same_key=but_with_a_value"); + assert_ne!(switch, keyvalue); + } + + #[test] + fn test_kargs_simple() { + // example taken lovingly from: + // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/params.c?id=89748acdf226fd1a8775ff6fa2703f8412b286c8#n160 + let kargs = Cmdline::from(b"foo=bar,bar2 baz=fuz wiz".as_slice()); + let mut iter = kargs.iter(); + + assert_eq!(iter.next(), Some(param("foo=bar,bar2"))); + assert_eq!(iter.next(), Some(param("baz=fuz"))); + assert_eq!(iter.next(), Some(param("wiz"))); + assert_eq!(iter.next(), None); + + // Test the find API + assert_eq!(kargs.find("foo").unwrap().value.unwrap(), b"bar,bar2"); + assert!(kargs.find("nothing").is_none()); + } + + #[test] + fn test_cmdline_default() { + let kargs: Cmdline = Default::default(); + assert_eq!(kargs.iter().next(), None); + } + + #[test] + fn test_cmdline_new() { + let kargs = Cmdline::new(); + assert_eq!(kargs.iter().next(), None); + assert!(kargs.is_owned()); + + // Verify we can store it in an owned ('static) context + let _static_kargs: CmdlineOwned = Cmdline::new(); + } + + #[test] + fn test_kargs_iter_utf8() { + let kargs = Cmdline::from(b"foo=bar,bar2 \xff baz=fuz bad=oh\xffno wiz"); + let mut iter = kargs.iter_utf8(); + + assert_eq!(iter.next(), Some(param_utf8("foo=bar,bar2"))); + assert_eq!(iter.next(), Some(param_utf8("baz=fuz"))); + assert_eq!(iter.next(), Some(param_utf8("wiz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_kargs_find_utf8() { + let kargs = Cmdline::from(b"foo=bar,bar2 \xff baz=fuz bad=oh\xffno wiz"); + + // found it + assert_eq!( + kargs.find_utf8("foo").unwrap().unwrap().value().unwrap(), + "bar,bar2" + ); + + // didn't find it + assert!(kargs.find_utf8("nothing").unwrap().is_none()); + + // found it but key is invalid + let p = kargs.find_utf8("bad"); + assert_eq!( + p.unwrap_err().to_string(), + "Parameter value is not valid UTF-8" + ); + } + + #[test] + fn test_kargs_from_proc() { + let kargs = Cmdline::from_proc().unwrap(); + + // Not really a good way to test this other than assume + // there's at least one argument in /proc/cmdline wherever the + // tests are running + assert!(kargs.iter().count() > 0); + } + + #[test] + fn test_kargs_find_dash_hyphen() { + let kargs = Cmdline::from(b"a-b=1 a_b=2".as_slice()); + // find should find the first one, which is a-b=1 + let p = kargs.find("a_b").unwrap(); + assert_eq!(p.key.0, b"a-b"); + assert_eq!(p.value.unwrap(), b"1"); + let p = kargs.find("a-b").unwrap(); + assert_eq!(p.key.0, b"a-b"); + assert_eq!(p.value.unwrap(), b"1"); + + let kargs = Cmdline::from(b"a_b=2 a-b=1".as_slice()); + // find should find the first one, which is a_b=2 + let p = kargs.find("a_b").unwrap(); + assert_eq!(p.key.0, b"a_b"); + assert_eq!(p.value.unwrap(), b"2"); + let p = kargs.find("a-b").unwrap(); + assert_eq!(p.key.0, b"a_b"); + assert_eq!(p.value.unwrap(), b"2"); + } + + #[test] + fn test_kargs_extra_whitespace() { + let kargs = Cmdline::from(b" foo=bar baz=fuz wiz ".as_slice()); + let mut iter = kargs.iter(); + + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz=fuz"))); + assert_eq!(iter.next(), Some(param("wiz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_value_of() { + let kargs = Cmdline::from(b"foo=bar baz=qux switch".as_slice()); + + // Test existing key with value + assert_eq!(kargs.value_of("foo"), Some(b"bar".as_slice())); + assert_eq!(kargs.value_of("baz"), Some(b"qux".as_slice())); + + // Test key without value + assert_eq!(kargs.value_of("switch"), None); + + // Test non-existent key + assert_eq!(kargs.value_of("missing"), None); + + // Test dash/underscore equivalence + let kargs = Cmdline::from(b"dash-key=value1 under_key=value2".as_slice()); + assert_eq!(kargs.value_of("dash_key"), Some(b"value1".as_slice())); + assert_eq!(kargs.value_of("under-key"), Some(b"value2".as_slice())); + } + + #[test] + fn test_require_value_of() { + let kargs = Cmdline::from(b"foo=bar baz=qux switch".as_slice()); + + // Test existing key with value + assert_eq!(kargs.require_value_of("foo").unwrap(), b"bar"); + assert_eq!(kargs.require_value_of("baz").unwrap(), b"qux"); + + // Test key without value should fail + let err = kargs.require_value_of("switch").unwrap_err(); + assert!(err + .to_string() + .contains("Failed to find kernel argument 'switch'")); + + // Test non-existent key should fail + let err = kargs.require_value_of("missing").unwrap_err(); + assert!(err + .to_string() + .contains("Failed to find kernel argument 'missing'")); + + // Test dash/underscore equivalence + let kargs = Cmdline::from(b"dash-key=value1 under_key=value2".as_slice()); + assert_eq!(kargs.require_value_of("dash_key").unwrap(), b"value1"); + assert_eq!(kargs.require_value_of("under-key").unwrap(), b"value2"); + } + + #[test] + fn test_find_all() { + let kargs = + Cmdline::from(b"foo=bar rd.foo=a rd.bar=b rd.baz rd.qux=c notrd.val=d".as_slice()); + let mut rd_args: Vec<_> = kargs.find_all_starting_with(b"rd.".as_slice()).collect(); + rd_args.sort_by(|a, b| a.key.0.cmp(b.key.0)); + assert_eq!(rd_args.len(), 4); + assert_eq!(rd_args[0], param("rd.bar=b")); + assert_eq!(rd_args[1], param("rd.baz")); + assert_eq!(rd_args[2], param("rd.foo=a")); + assert_eq!(rd_args[3], param("rd.qux=c")); + } + + #[test] + fn test_add() { + let mut kargs = Cmdline::from(b"console=tty0 console=ttyS1"); + + // add new parameter with duplicate key but different value + assert!(matches!(kargs.add(¶m("console=ttyS2")), Action::Added)); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), None); + + // try to add exact duplicate - should return Existed + assert!(matches!( + kargs.add(¶m("console=ttyS1")), + Action::Existed + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), None); + + // add completely new parameter + assert!(matches!(kargs.add(¶m("quiet")), Action::Added)); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), Some(param("quiet"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_add_empty_cmdline() { + let mut kargs = Cmdline::from(b""); + assert!(matches!(kargs.add(¶m("foo")), Action::Added)); + assert_eq!(kargs.0, b"foo".as_slice()); + } + + #[test] + fn test_add_or_modify() { + let mut kargs = Cmdline::from(b"foo=bar"); + + // add new + assert!(matches!(kargs.add_or_modify(¶m("baz")), Action::Added)); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // modify existing + assert!(matches!( + kargs.add_or_modify(¶m("foo=fuz")), + Action::Modified + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=fuz"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // already exists with same value returns false and doesn't + // modify anything + assert!(matches!( + kargs.add_or_modify(¶m("foo=fuz")), + Action::Existed + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=fuz"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_add_or_modify_empty_cmdline() { + let mut kargs = Cmdline::from(b""); + assert!(matches!(kargs.add_or_modify(¶m("foo")), Action::Added)); + assert_eq!(kargs.0, b"foo".as_slice()); + } + + #[test] + fn test_add_or_modify_duplicate_parameters() { + let mut kargs = Cmdline::from(b"a=1 a=2"); + assert!(matches!( + kargs.add_or_modify(¶m("a=3")), + Action::Modified + )); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("a=3"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove() { + let mut kargs = Cmdline::from(b"foo bar baz"); + + // remove existing + assert!(kargs.remove(&"bar".into())); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // doesn't exist? returns false and doesn't modify anything + assert!(!kargs.remove(&"missing".into())); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove_duplicates() { + let mut kargs = Cmdline::from(b"a=1 b=2 a=3"); + assert!(kargs.remove(&"a".into())); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("b=2"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove_exact() { + let mut kargs = Cmdline::from(b"foo foo=bar foo=baz"); + + // remove existing + assert!(kargs.remove_exact(¶m("foo=bar"))); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("foo=baz"))); + assert_eq!(iter.next(), None); + + // doesn't exist? returns false and doesn't modify anything + assert!(!kargs.remove_exact(¶m("foo=wuz"))); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("foo=baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_extend() { + let mut kargs = Cmdline::from(b"foo=bar baz"); + let other = Cmdline::from(b"qux=quux foo=updated"); + + kargs.extend(&other); + + // Sanity check that the lifetimes of the two Cmdlines are not + // tied to each other. + drop(other); + + // Should have preserved the original foo, added qux, baz + // unchanged, and added the second (duplicate key) foo + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), Some(param("qux=quux"))); + assert_eq!(iter.next(), Some(param("foo=updated"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_extend_empty() { + let mut kargs = Cmdline::from(b""); + let other = Cmdline::from(b"foo=bar baz"); + + kargs.extend(&other); + + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_into_iterator() { + let kargs = Cmdline::from(b"foo=bar baz=qux wiz"); + let params: Vec<_> = (&kargs).into_iter().collect(); + + assert_eq!(params.len(), 3); + assert_eq!(params[0], param("foo=bar")); + assert_eq!(params[1], param("baz=qux")); + assert_eq!(params[2], param("wiz")); + } + + #[test] + fn test_iter_bytes_simple() { + let kargs = Cmdline::from(b"foo bar baz"); + let params: Vec<_> = kargs.iter_bytes().collect(); + + assert_eq!(params.len(), 3); + assert_eq!(params[0], b"foo"); + assert_eq!(params[1], b"bar"); + assert_eq!(params[2], b"baz"); + } + + #[test] + fn test_iter_bytes_with_values() { + let kargs = Cmdline::from(b"foo=bar baz=qux wiz"); + let params: Vec<_> = kargs.iter_bytes().collect(); + + assert_eq!(params.len(), 3); + assert_eq!(params[0], b"foo=bar"); + assert_eq!(params[1], b"baz=qux"); + assert_eq!(params[2], b"wiz"); + } + + #[test] + fn test_iter_bytes_with_quotes() { + let kargs = Cmdline::from(b"foo=\"bar baz\" qux"); + let params: Vec<_> = kargs.iter_bytes().collect(); + + assert_eq!(params.len(), 2); + assert_eq!(params[0], b"foo=\"bar baz\""); + assert_eq!(params[1], b"qux"); + } + + #[test] + fn test_iter_bytes_extra_whitespace() { + let kargs = Cmdline::from(b" foo bar "); + let params: Vec<_> = kargs.iter_bytes().collect(); + + assert_eq!(params.len(), 2); + assert_eq!(params[0], b"foo"); + assert_eq!(params[1], b"bar"); + } + + #[test] + fn test_iter_bytes_empty() { + let kargs = Cmdline::from(b""); + let params: Vec<_> = kargs.iter_bytes().collect(); + + assert_eq!(params.len(), 0); + } + + #[test] + fn test_cmdline_eq() { + // Ordering, quoting, and the whole dash-underscore + // equivalence thing shouldn't affect whether these are + // semantically equal + assert_eq!( + Cmdline::from("foo bar-with-delim=\"with spaces\""), + Cmdline::from("\"bar_with_delim=with spaces\" foo") + ); + + // Uneven lengths are not equal even if the parameters are. Or + // to put it another way, duplicate parameters break equality. + // Check with both orderings. + assert_ne!(Cmdline::from("foo"), Cmdline::from("foo foo")); + assert_ne!(Cmdline::from("foo foo"), Cmdline::from("foo")); + + // Equal lengths but differing duplicates are also not equal + assert_ne!(Cmdline::from("a a b"), Cmdline::from("a b b")); + } +} diff --git a/crates/kernel_cmdline/src/lib.rs b/crates/kernel_cmdline/src/lib.rs new file mode 100644 index 000000000..5f2d85c29 --- /dev/null +++ b/crates/kernel_cmdline/src/lib.rs @@ -0,0 +1,32 @@ +//! Kernel command line parsing utilities. +//! +//! This module provides functionality for parsing and working with kernel command line +//! arguments, supporting both key-only switches and key-value pairs with proper quote handling. +//! +//! The kernel command line is not required to be UTF-8. The `bytes` +//! module works on arbitrary byte data and attempts to parse the +//! command line in the same manner as the kernel itself. +//! +//! The `utf8` module performs the same functionality, but requires +//! all data to be valid UTF-8. + +pub mod bytes; +pub mod utf8; + +/// This is used by dracut. +pub const INITRD_ARG_PREFIX: &str = "rd."; +/// The kernel argument for configuring the rootfs flags. +pub const ROOTFLAGS: &str = "rootflags"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Possible outcomes for `add_or_modify` operations. +pub enum Action { + /// The parameter did not exist before and was added + Added, + /// The parameter existed before, but contained a different value. + /// The value was updated to the newly-requested value. + Modified, + /// The parameter existed before, and contained the same value as + /// the newly-requested value. No modification was made. + Existed, +} diff --git a/crates/kernel_cmdline/src/utf8.rs b/crates/kernel_cmdline/src/utf8.rs new file mode 100644 index 000000000..408ea2c41 --- /dev/null +++ b/crates/kernel_cmdline/src/utf8.rs @@ -0,0 +1,951 @@ +//! UTF-8-based kernel command line parsing utilities. +//! +//! This module provides functionality for parsing and working with kernel command line +//! arguments, supporting both key-only switches and key-value pairs with proper quote handling. + +use std::ops::Deref; + +use crate::{bytes, Action}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// A parsed UTF-8 kernel command line. +/// +/// Wraps the raw command line bytes and provides methods for parsing and iterating +/// over individual parameters. Uses copy-on-write semantics to avoid unnecessary +/// allocations when working with borrowed data. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cmdline<'a>(bytes::Cmdline<'a>); + +/// An owned `Cmdline`. Alias for `Cmdline<'static>`. +pub type CmdlineOwned = Cmdline<'static>; + +impl<'a, T: AsRef + ?Sized> From<&'a T> for Cmdline<'a> { + /// Creates a new `Cmdline` from any type that can be referenced as `str`. + /// + /// Uses borrowed data when possible to avoid unnecessary allocations. + fn from(input: &'a T) -> Self { + Self(bytes::Cmdline::from(input.as_ref().as_bytes())) + } +} + +impl From for CmdlineOwned { + /// Creates a new `Cmdline` from a `String`. + /// + /// Takes ownership of input and maintains it for internal owned data. + fn from(input: String) -> Self { + Self(bytes::Cmdline::from(input.into_bytes())) + } +} + +/// An iterator over UTF-8 kernel command line parameters. +/// +/// This is created by the `iter` method on `CmdlineUTF8`. +#[derive(Debug)] +pub struct CmdlineIter<'a>(bytes::CmdlineIter<'a>); + +impl<'a> Iterator for CmdlineIter<'a> { + type Item = Parameter<'a>; + + fn next(&mut self) -> Option { + self.0.next().map(Parameter::from_bytes) + } +} + +/// An iterator over UTF-8 kernel command line parameters as string slices. +/// +/// This is created by the `iter_str` method on `Cmdline`. +#[derive(Debug)] +pub struct CmdlineIterStr<'a>(bytes::CmdlineIterBytes<'a>); + +impl<'a> Iterator for CmdlineIterStr<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + // Get the next byte slice from the underlying iterator + let bytes = self.0.next()?; + + // Convert to UTF-8 string slice + // SAFETY: We know this is valid UTF-8 since the Cmdline was constructed from valid UTF-8 + Some(str::from_utf8(bytes).expect("Parameter bytes come from valid UTF-8 cmdline")) + } +} + +impl<'a> Cmdline<'a> { + /// Creates a new empty owned `Cmdline`. + /// + /// This is equivalent to `Cmdline::default()` but makes ownership explicit. + pub fn new() -> CmdlineOwned { + Cmdline::default() + } + + /// Reads the kernel command line from `/proc/cmdline`. + /// + /// Returns an error if: + /// - The file cannot be read + /// - There are I/O issues + /// - The cmdline from proc is not valid UTF-8 + pub fn from_proc() -> Result { + let cmdline = std::fs::read("/proc/cmdline")?; + + // SAFETY: validate the value from proc is valid UTF-8. We + // don't need to save this, but checking now will ensure we + // can safely convert from the underlying bytes back to UTF-8 + // later. + str::from_utf8(&cmdline)?; + + Ok(Self(bytes::Cmdline::from(cmdline))) + } + + /// Returns an iterator over all parameters in the command line. + /// + /// Properly handles quoted values containing whitespace and splits on + /// unquoted whitespace characters. Parameters are parsed as either + /// key-only switches or key=value pairs. + pub fn iter(&'a self) -> CmdlineIter<'a> { + CmdlineIter(self.0.iter()) + } + + /// Returns an iterator over all parameters in the command line as string slices. + /// + /// This is similar to `iter()` but yields `&str` directly instead of `Parameter`, + /// which can be more convenient when you just need the string representation. + pub fn iter_str(&self) -> CmdlineIterStr<'_> { + CmdlineIterStr(self.0.iter_bytes()) + } + + /// Locate a kernel argument with the given key name. + /// + /// Returns the first parameter matching the given key, or `None` if not found. + /// Key comparison treats dashes and underscores as equivalent. + pub fn find + ?Sized>(&'a self, key: &T) -> Option> { + let key = ParameterKey::from(key.as_ref()); + self.iter().find(|p| p.key() == key) + } + + /// Find all kernel arguments starting with the given UTF-8 prefix. + /// + /// This is a variant of [`Self::find`]. + pub fn find_all_starting_with + ?Sized>( + &'a self, + prefix: &'a T, + ) -> impl Iterator> + 'a { + self.iter() + .filter(move |p| p.key().starts_with(prefix.as_ref())) + } + + /// Locate the value of the kernel argument with the given key name. + /// + /// Returns the first value matching the given key, or `None` if not found. + /// Key comparison treats dashes and underscores as equivalent. + pub fn value_of + ?Sized>(&'a self, key: &T) -> Option<&'a str> { + self.0.value_of(key.as_ref().as_bytes()).map(|v| { + // SAFETY: We know this is valid UTF-8 since we only + // construct the underlying `bytes` from valid UTF-8 + str::from_utf8(v).expect("We only construct the underlying bytes from valid UTF-8") + }) + } + + /// Find the value of the kernel argument with the provided name, which must be present. + /// + /// Otherwise the same as [`Self::value_of`]. + pub fn require_value_of + ?Sized>(&'a self, key: &T) -> Result<&'a str> { + let key = key.as_ref(); + self.value_of(key) + .ok_or_else(|| anyhow::anyhow!("Failed to find kernel argument '{key}'")) + } + + /// Add a parameter to the command line if it doesn't already exist + /// + /// Returns `Action::Added` if the parameter did not already exist + /// and was added. + /// + /// Returns `Action::Existed` if the exact parameter (same key and value) + /// already exists. No modification was made. + /// + /// Unlike `add_or_modify`, this method will not modify existing + /// parameters. If a parameter with the same key exists but has a + /// different value, the new parameter is still added, allowing + /// duplicate keys (e.g., multiple `console=` parameters). + pub fn add(&mut self, param: &Parameter) -> Action { + self.0.add(¶m.0) + } + + /// Add or modify a parameter to the command line + /// + /// Returns `Action::Added` if the parameter did not exist before + /// and was added. + /// + /// Returns `Action::Modified` if the parameter existed before, + /// but contained a different value. The value was updated to the + /// newly-requested value. + /// + /// Returns `Action::Existed` if the parameter existed before, and + /// contained the same value as the newly-requested value. No + /// modification was made. + pub fn add_or_modify(&mut self, param: &Parameter) -> Action { + self.0.add_or_modify(¶m.0) + } + + /// Remove parameter(s) with the given key from the command line + /// + /// Returns `true` if parameter(s) were removed. + pub fn remove(&mut self, key: &ParameterKey) -> bool { + self.0.remove(&key.0) + } + + /// Remove all parameters that exactly match the given parameter + /// from the command line + /// + /// Returns `true` if parameter(s) were removed. + pub fn remove_exact(&mut self, param: &Parameter) -> bool { + self.0.remove_exact(¶m.0) + } + + #[cfg(test)] + pub(crate) fn is_owned(&self) -> bool { + self.0.is_owned() + } + + #[cfg(test)] + pub(crate) fn is_borrowed(&self) -> bool { + self.0.is_borrowed() + } +} + +impl Deref for Cmdline<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + // SAFETY: We know this is valid UTF-8 since we only + // construct the underlying `bytes` from valid UTF-8 + str::from_utf8(&self.0).expect("We only construct the underlying bytes from valid UTF-8") + } +} + +impl<'a, T> AsRef for Cmdline<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +impl<'a> std::fmt::Display for Cmdline<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(self) + } +} + +impl<'a> IntoIterator for &'a Cmdline<'a> { + type Item = Parameter<'a>; + type IntoIter = CmdlineIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl<'a, 'other> Extend> for Cmdline<'a> { + // Note this is O(N*M), but in practice this doesn't matter + // because kernel cmdlines are typically quite small (limited + // to at most 4k depending on arch). Using a hash-based + // structure to reduce this to O(N)+C would likely raise the C + // portion so much as to erase any benefit from removing the + // combinatorial complexity. Plus CPUs are good at + // caching/pipelining through contiguous memory. + fn extend>>(&mut self, iter: T) { + for param in iter { + self.add(¶m); + } + } +} + +/// A single kernel command line parameter key +/// +/// Handles quoted values and treats dashes and underscores in keys as equivalent. +#[derive(Clone, Debug, Eq)] +pub struct ParameterKey<'a>(bytes::ParameterKey<'a>); + +impl Deref for ParameterKey<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + // SAFETY: We know this is valid UTF-8 since we only + // construct the underlying `bytes` from valid UTF-8 + str::from_utf8(&self.0).expect("We only construct the underlying bytes from valid UTF-8") + } +} + +impl<'a, T> AsRef for ParameterKey<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +impl<'a> ParameterKey<'a> { + /// Construct a utf8::ParameterKey from a bytes::ParameterKey + /// + /// This is non-public and should only be used when the underlying + /// bytes are known to be valid UTF-8. + fn from_bytes(input: bytes::ParameterKey<'a>) -> Self { + Self(input) + } +} + +impl<'a, T: AsRef + ?Sized> From<&'a T> for ParameterKey<'a> { + fn from(input: &'a T) -> Self { + Self(bytes::ParameterKey(input.as_ref().as_bytes())) + } +} + +impl<'a> std::fmt::Display for ParameterKey<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(self) + } +} + +impl PartialEq for ParameterKey<'_> { + /// Compares two parameter keys for equality. + /// + /// Keys are compared with dashes and underscores treated as equivalent. + /// This comparison is case-sensitive. + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +/// A single kernel command line parameter. +#[derive(Clone, Debug, Eq)] +pub struct Parameter<'a>(bytes::Parameter<'a>); + +impl<'a> Parameter<'a> { + /// Attempt to parse a single command line parameter from a UTF-8 + /// string. + /// + /// Returns `Some(Parameter)`, or `None` if a Parameter could not + /// be constructed from the input. This occurs when the input is + /// either empty or contains only whitespace. + pub fn parse + ?Sized>(input: &'a T) -> Option { + bytes::Parameter::parse(input.as_ref().as_bytes()).map(Self) + } + + /// Construct a utf8::Parameter from a bytes::Parameter + /// + /// This is non-public and should only be used when the underlying + /// bytes are known to be valid UTF-8. + fn from_bytes(bytes: bytes::Parameter<'a>) -> Self { + Self(bytes) + } + + /// Returns the key part of the parameter + pub fn key(&'a self) -> ParameterKey<'a> { + ParameterKey::from_bytes(self.0.key()) + } + + /// Returns the optional value part of the parameter + pub fn value(&'a self) -> Option<&'a str> { + self.0.value().map(|p| { + // SAFETY: We know this is valid UTF-8 since we only + // construct the underlying `bytes` from valid UTF-8 + str::from_utf8(p).expect("We only construct the underlying bytes from valid UTF-8") + }) + } +} + +impl<'a> TryFrom> for Parameter<'a> { + type Error = anyhow::Error; + + fn try_from(bytes: bytes::Parameter<'a>) -> Result { + if str::from_utf8(bytes.key().deref()).is_err() { + anyhow::bail!("Parameter key is not valid UTF-8"); + } + + if let Some(value) = bytes.value() { + if str::from_utf8(value).is_err() { + anyhow::bail!("Parameter value is not valid UTF-8"); + } + } + + Ok(Self(bytes)) + } +} + +impl<'a> std::fmt::Display for Parameter<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(self) + } +} + +impl Deref for Parameter<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + // SAFETY: We know this is valid UTF-8 since we only + // construct the underlying `bytes` from valid UTF-8 + str::from_utf8(&self.0).expect("We only construct the underlying bytes from valid UTF-8") + } +} + +impl<'a, T> AsRef for Parameter<'a> +where + T: ?Sized, + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &T { + self.deref().as_ref() + } +} + +impl<'a> PartialEq for Parameter<'a> { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // convenience method for tests + fn param(s: &str) -> Parameter<'_> { + Parameter::parse(s).unwrap() + } + + #[test] + fn test_parameter_parse() { + let p = Parameter::parse("foo").unwrap(); + assert_eq!(p.key(), "foo".into()); + assert_eq!(p.value(), None); + + // should parse only the first parameter and discard the rest of the input + let p = Parameter::parse("foo=bar baz").unwrap(); + assert_eq!(p.key(), "foo".into()); + assert_eq!(p.value(), Some("bar")); + + // should return None on empty or whitespace inputs + assert!(Parameter::parse("").is_none()); + assert!(Parameter::parse(" ").is_none()); + } + + #[test] + fn test_parameter_simple() { + let switch = param("foo"); + assert_eq!(switch.key(), "foo".into()); + assert_eq!(switch.value(), None); + + let kv = param("bar=baz"); + assert_eq!(kv.key(), "bar".into()); + assert_eq!(kv.value(), Some("baz")); + } + + #[test] + fn test_parameter_quoted() { + let p = param("foo=\"quoted value\""); + assert_eq!(p.value(), Some("quoted value")); + + let p = param("foo=\"unclosed quotes"); + assert_eq!(p.value(), Some("unclosed quotes")); + + let p = param("foo=trailing_quotes\""); + assert_eq!(p.value(), Some("trailing_quotes")); + + let outside_quoted = param("\"foo=quoted value\""); + let value_quoted = param("foo=\"quoted value\""); + assert_eq!(outside_quoted, value_quoted); + } + + #[test] + fn test_parameter_display() { + // Basically this should always return the original data + // without modification. + + // unquoted stays unquoted + assert_eq!(param("foo").to_string(), "foo"); + + // quoted stays quoted + assert_eq!(param("\"foo\"").to_string(), "\"foo\""); + } + + #[test] + fn test_parameter_extra_whitespace() { + let p = param(" foo=bar "); + assert_eq!(p.key(), "foo".into()); + assert_eq!(p.value(), Some("bar")); + } + + #[test] + fn test_parameter_internal_key_whitespace() { + // parse should only consume the first parameter + let p = Parameter::parse("foo bar=baz").unwrap(); + assert_eq!(p.key(), "foo".into()); + assert_eq!(p.value(), None); + } + + #[test] + fn test_parameter_pathological() { + // valid things that certified insane people would do + + // you can quote just the key part in a key-value param, but + // the end quote is actually part of the key as far as the + // kernel is concerned... + let p = param("\"foo\"=bar"); + assert_eq!(p.key(), ParameterKey::from("foo\"")); + assert_eq!(p.value(), Some("bar")); + // and it is definitely not equal to an unquoted foo ... + assert_ne!(p, param("foo=bar")); + + // ... but if you close the quote immediately after the + // equals sign, it does get removed. + let p = param("\"foo=\"bar"); + assert_eq!(p.key(), ParameterKey::from("foo")); + assert_eq!(p.value(), Some("bar")); + // ... so of course this makes sense ... + assert_eq!(p, param("foo=bar")); + + // quotes only get stripped from the absolute ends of values + let p = param("foo=\"internal\"quotes\"are\"ok\""); + assert_eq!(p.value(), Some("internal\"quotes\"are\"ok")); + } + + #[test] + fn test_parameter_equality() { + // substrings are not equal + let foo = param("foo"); + let bar = param("foobar"); + assert_ne!(foo, bar); + assert_ne!(bar, foo); + + // dashes and underscores are treated equally + let dashes = param("a-delimited-param"); + let underscores = param("a_delimited_param"); + assert_eq!(dashes, underscores); + + // same key, same values is equal + let dashes = param("a-delimited-param=same_values"); + let underscores = param("a_delimited_param=same_values"); + assert_eq!(dashes, underscores); + + // same key, different values is not equal + let dashes = param("a-delimited-param=different_values"); + let underscores = param("a_delimited_param=DiFfErEnT_valUEZ"); + assert_ne!(dashes, underscores); + + // mixed variants are never equal + let switch = param("same_key"); + let keyvalue = param("same_key=but_with_a_value"); + assert_ne!(switch, keyvalue); + } + + #[test] + fn test_parameter_tryfrom() { + // ok switch + let p = bytes::Parameter::parse(b"foo").unwrap(); + let utf = Parameter::try_from(p).unwrap(); + assert_eq!(utf.key(), "foo".into()); + assert_eq!(utf.value(), None); + + // ok key/value + let p = bytes::Parameter::parse(b"foo=bar").unwrap(); + let utf = Parameter::try_from(p).unwrap(); + assert_eq!(utf.key(), "foo".into()); + assert_eq!(utf.value(), Some("bar".into())); + + // bad switch + let p = bytes::Parameter::parse(b"f\xffoo").unwrap(); + let e = Parameter::try_from(p); + assert_eq!( + e.unwrap_err().to_string(), + "Parameter key is not valid UTF-8" + ); + + // bad key/value + let p = bytes::Parameter::parse(b"foo=b\xffar").unwrap(); + let e = Parameter::try_from(p); + assert_eq!( + e.unwrap_err().to_string(), + "Parameter value is not valid UTF-8" + ); + } + + #[test] + fn test_kargs_simple() { + // example taken lovingly from: + // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/params.c?id=89748acdf226fd1a8775ff6fa2703f8412b286c8#n160 + let kargs = Cmdline::from("foo=bar,bar2 baz=fuz wiz"); + assert!(kargs.is_borrowed()); + let mut iter = kargs.iter(); + + assert_eq!(iter.next(), Some(param("foo=bar,bar2"))); + assert_eq!(iter.next(), Some(param("baz=fuz"))); + assert_eq!(iter.next(), Some(param("wiz"))); + assert_eq!(iter.next(), None); + + // Test the find API + assert_eq!(kargs.find("foo").unwrap().value().unwrap(), "bar,bar2"); + assert!(kargs.find("nothing").is_none()); + } + + #[test] + fn test_cmdline_default() { + let kargs: Cmdline = Default::default(); + assert_eq!(kargs.iter().next(), None); + } + + #[test] + fn test_cmdline_new() { + let kargs = Cmdline::new(); + assert_eq!(kargs.iter().next(), None); + assert!(kargs.is_owned()); + + // Verify we can store it in an owned ('static) context + let _static_kargs: CmdlineOwned = Cmdline::new(); + } + + #[test] + fn test_kargs_simple_from_string() { + let kargs = Cmdline::from("foo=bar,bar2 baz=fuz wiz".to_string()); + assert!(kargs.is_owned()); + let mut iter = kargs.iter(); + + assert_eq!(iter.next(), Some(param("foo=bar,bar2"))); + assert_eq!(iter.next(), Some(param("baz=fuz"))); + assert_eq!(iter.next(), Some(param("wiz"))); + assert_eq!(iter.next(), None); + + // Test the find API + assert_eq!(kargs.find("foo").unwrap().value().unwrap(), "bar,bar2"); + assert!(kargs.find("nothing").is_none()); + } + + #[test] + fn test_kargs_from_proc() { + let kargs = Cmdline::from_proc().unwrap(); + + // Not really a good way to test this other than assume + // there's at least one argument in /proc/cmdline wherever the + // tests are running + assert!(kargs.iter().count() > 0); + } + + #[test] + fn test_kargs_find_dash_hyphen() { + let kargs = Cmdline::from("a-b=1 a_b=2"); + // find should find the first one, which is a-b=1 + let p = kargs.find("a_b").unwrap(); + assert_eq!(p.key(), "a-b".into()); + assert_eq!(p.value().unwrap(), "1"); + let p = kargs.find("a-b").unwrap(); + assert_eq!(p.key(), "a-b".into()); + assert_eq!(p.value().unwrap(), "1"); + + let kargs = Cmdline::from("a_b=2 a-b=1"); + // find should find the first one, which is a_b=2 + let p = kargs.find("a_b").unwrap(); + assert_eq!(p.key(), "a_b".into()); + assert_eq!(p.value().unwrap(), "2"); + let p = kargs.find("a-b").unwrap(); + assert_eq!(p.key(), "a_b".into()); + assert_eq!(p.value().unwrap(), "2"); + } + + #[test] + fn test_kargs_extra_whitespace() { + let kargs = Cmdline::from(" foo=bar baz=fuz wiz "); + let mut iter = kargs.iter(); + + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz=fuz"))); + assert_eq!(iter.next(), Some(param("wiz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_value_of() { + let kargs = Cmdline::from("foo=bar baz=qux switch"); + + // Test existing key with value + assert_eq!(kargs.value_of("foo"), Some("bar")); + assert_eq!(kargs.value_of("baz"), Some("qux")); + + // Test key without value + assert_eq!(kargs.value_of("switch"), None); + + // Test non-existent key + assert_eq!(kargs.value_of("missing"), None); + + // Test dash/underscore equivalence + let kargs = Cmdline::from("dash-key=value1 under_key=value2"); + assert_eq!(kargs.value_of("dash_key"), Some("value1")); + assert_eq!(kargs.value_of("under-key"), Some("value2")); + } + + #[test] + fn test_require_value_of() { + let kargs = Cmdline::from("foo=bar baz=qux switch"); + + // Test existing key with value + assert_eq!(kargs.require_value_of("foo").unwrap(), "bar"); + assert_eq!(kargs.require_value_of("baz").unwrap(), "qux"); + + // Test key without value should fail + let err = kargs.require_value_of("switch").unwrap_err(); + assert!(err + .to_string() + .contains("Failed to find kernel argument 'switch'")); + + // Test non-existent key should fail + let err = kargs.require_value_of("missing").unwrap_err(); + assert!(err + .to_string() + .contains("Failed to find kernel argument 'missing'")); + + // Test dash/underscore equivalence + let kargs = Cmdline::from("dash-key=value1 under_key=value2"); + assert_eq!(kargs.require_value_of("dash_key").unwrap(), "value1"); + assert_eq!(kargs.require_value_of("under-key").unwrap(), "value2"); + } + + #[test] + fn test_find_str() { + let kargs = Cmdline::from("foo=bar baz=qux switch rd.break"); + let p = kargs.find("foo").unwrap(); + assert_eq!(p, param("foo=bar")); + let p = kargs.find("rd.break").unwrap(); + assert_eq!(p, param("rd.break")); + assert!(kargs.find("missing").is_none()); + } + + #[test] + fn test_find_all_str() { + let kargs = Cmdline::from("foo=bar rd.foo=a rd.bar=b rd.baz rd.qux=c notrd.val=d"); + let mut rd_args: Vec<_> = kargs.find_all_starting_with("rd.").collect(); + rd_args.sort_by(|a, b| a.key().cmp(&b.key())); + assert_eq!(rd_args.len(), 4); + assert_eq!(rd_args[0], param("rd.bar=b")); + assert_eq!(rd_args[1], param("rd.baz")); + assert_eq!(rd_args[2], param("rd.foo=a")); + assert_eq!(rd_args[3], param("rd.qux=c")); + } + + #[test] + fn test_param_key_eq() { + let k1 = ParameterKey::from("a-b"); + let k2 = ParameterKey::from("a_b"); + assert_eq!(k1, k2); + let k1 = ParameterKey::from("a-b"); + let k2 = ParameterKey::from("a-c"); + assert_ne!(k1, k2); + } + + #[test] + fn test_add() { + let mut kargs = Cmdline::from("console=tty0 console=ttyS1"); + + // add new parameter with duplicate key but different value + assert!(matches!(kargs.add(¶m("console=ttyS2")), Action::Added)); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), None); + + // try to add exact duplicate - should return Existed + assert!(matches!( + kargs.add(¶m("console=ttyS1")), + Action::Existed + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), None); + + // add completely new parameter + assert!(matches!(kargs.add(¶m("quiet")), Action::Added)); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("console=tty0"))); + assert_eq!(iter.next(), Some(param("console=ttyS1"))); + assert_eq!(iter.next(), Some(param("console=ttyS2"))); + assert_eq!(iter.next(), Some(param("quiet"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_add_empty_cmdline() { + let mut kargs = Cmdline::from(""); + assert!(matches!(kargs.add(¶m("foo")), Action::Added)); + assert_eq!(&*kargs, "foo"); + } + + #[test] + fn test_add_or_modify() { + let mut kargs = Cmdline::from("foo=bar"); + + // add new + assert!(matches!(kargs.add_or_modify(¶m("baz")), Action::Added)); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // modify existing + assert!(matches!( + kargs.add_or_modify(¶m("foo=fuz")), + Action::Modified + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=fuz"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // already exists with same value returns false and doesn't + // modify anything + assert!(matches!( + kargs.add_or_modify(¶m("foo=fuz")), + Action::Existed + )); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=fuz"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_add_or_modify_empty_cmdline() { + let mut kargs = Cmdline::from(""); + assert!(matches!(kargs.add_or_modify(¶m("foo")), Action::Added)); + assert_eq!(&*kargs, "foo"); + } + + #[test] + fn test_add_or_modify_duplicate_parameters() { + let mut kargs = Cmdline::from("a=1 a=2"); + assert!(matches!( + kargs.add_or_modify(¶m("a=3")), + Action::Modified + )); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("a=3"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove() { + let mut kargs = Cmdline::from("foo bar baz"); + + // remove existing + assert!(kargs.remove(&"bar".into())); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + + // doesn't exist? returns false and doesn't modify anything + assert!(!kargs.remove(&"missing".into())); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove_duplicates() { + let mut kargs = Cmdline::from("a=1 b=2 a=3"); + assert!(kargs.remove(&"a".into())); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("b=2"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_remove_exact() { + let mut kargs = Cmdline::from("foo foo=bar foo=baz"); + + // remove existing + assert!(kargs.remove_exact(¶m("foo=bar"))); + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("foo=baz"))); + assert_eq!(iter.next(), None); + + // doesn't exist? returns false and doesn't modify anything + assert!(!kargs.remove_exact(¶m("foo=wuz"))); + iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo"))); + assert_eq!(iter.next(), Some(param("foo=baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_extend() { + let mut kargs = Cmdline::from("foo=bar baz"); + let other = Cmdline::from("qux=quux foo=updated"); + + kargs.extend(&other); + + // Sanity check that the lifetimes of the two Cmdlines are not + // tied to each other. + drop(other); + + // Should have preserved the original foo, added qux, baz + // unchanged, and added the second (duplicate key) foo + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), Some(param("qux=quux"))); + assert_eq!(iter.next(), Some(param("foo=updated"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_extend_empty() { + let mut kargs = Cmdline::from(""); + let other = Cmdline::from("foo=bar baz"); + + kargs.extend(&other); + + let mut iter = kargs.iter(); + assert_eq!(iter.next(), Some(param("foo=bar"))); + assert_eq!(iter.next(), Some(param("baz"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_into_iterator() { + let kargs = Cmdline::from("foo=bar baz=qux wiz"); + let params: Vec<_> = (&kargs).into_iter().collect(); + + assert_eq!(params.len(), 3); + assert_eq!(params[0], param("foo=bar")); + assert_eq!(params[1], param("baz=qux")); + assert_eq!(params[2], param("wiz")); + } + + #[test] + fn test_cmdline_eq() { + // Ordering, quoting, and the whole dash-underscore + // equivalence thing shouldn't affect whether these are + // semantically equal + assert_eq!( + Cmdline::from("foo bar-with-delim=\"with spaces\""), + Cmdline::from("\"bar_with_delim=with spaces\" foo") + ); + + // Uneven lengths are not equal even if the parameters are. Or + // to put it another way, duplicate parameters break equality. + // Check with both orderings. + assert_ne!(Cmdline::from("foo"), Cmdline::from("foo foo")); + assert_ne!(Cmdline::from("foo foo"), Cmdline::from("foo")); + + // Equal lengths but differing duplicates are also not equal + assert_ne!(Cmdline::from("a a b"), Cmdline::from("a b b")); + } +} diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml new file mode 100644 index 000000000..cdc278d49 --- /dev/null +++ b/crates/lib/Cargo.toml @@ -0,0 +1,89 @@ +[package] +description = "bootc implementation" +edition = "2021" +license = "MIT OR Apache-2.0" +name = "bootc-lib" +repository = "https://github.com/bootc-dev/bootc" +# The intention is we'll follow semver here, even though this +# project isn't actually published as a crate. +version = "1.10.0" +# In general we try to keep this pinned to what's in the latest RHEL9. +rust-version = "1.84.0" + +include = ["/src", "LICENSE-APACHE", "LICENSE-MIT"] + +[dependencies] +# Internal crates +bootc-blockdev = { package = "bootc-internal-blockdev", path = "../blockdev", version = "0.0.0" } +bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" } +bootc-mount = { path = "../mount" } +bootc-sysusers = { path = "../sysusers" } +bootc-tmpfiles = { path = "../tmpfiles" } +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } +ostree-ext = { path = "../ostree-ext", features = ["bootc"] } +etc-merge = { path = "../etc-merge" } +bootc-initramfs-setup = { path = "../initramfs" } + +# Workspace dependencies +anstream = { workspace = true } +anyhow = { workspace = true } +camino = { workspace = true, features = ["serde1"] } +canon-json = { workspace = true } +cap-std-ext = { workspace = true, features = ["fs_utf8"] } +cfg-if = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive","cargo"] } +clap_mangen = { workspace = true, optional = true } +composefs = { workspace = true } +composefs-boot = { workspace = true } +composefs-oci = { workspace = true } +fn-error-context = { workspace = true } +hex = { workspace = true } +indicatif = { workspace = true } +indoc = { workspace = true } +libc = { workspace = true } +openssl = { workspace = true } +regex = { workspace = true } +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["io-std", "time", "process", "rt", "net"] } +tokio-util = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +xshell = { workspace = true, optional = true } + +# Crate-specific dependencies +anstyle = "1.0.6" +comfy-table = "7.1.1" +liboverdrop = "0.1.0" +libsystemd = "0.7" +linkme = "0.3" +nom = "8.0.0" +schemars = { version = "1.0.4", features = ["chrono04"] } +serde_ignored = "0.1.10" +serde_yaml = "0.9.34" +tini = "1.3.0" +uuid = { version = "1.8.0", features = ["v4"] } +uapi-version = "0.4.0" + +[dev-dependencies] +similar-asserts = { workspace = true } +static_assertions = { workspace = true } + +[features] +default = ["install-to-disk"] +# This feature enables `bootc install to-disk`, which is considered just a "demo" +# or reference installer; we expect most nontrivial use cases to be using +# `bootc install to-filesystem`. +install-to-disk = [] +# This featuares enables `bootc internals publish-rhsm-facts` to integrate with +# Red Hat Subscription Manager +rhsm = [] +# Implementation detail of man page generation. +docgen = ["clap_mangen"] + +[lints] +workspace = true diff --git a/crates/lib/LICENSE-APACHE b/crates/lib/LICENSE-APACHE new file mode 120000 index 000000000..965b606f3 --- /dev/null +++ b/crates/lib/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/crates/lib/LICENSE-MIT b/crates/lib/LICENSE-MIT new file mode 120000 index 000000000..76219eb72 --- /dev/null +++ b/crates/lib/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/crates/lib/README.md b/crates/lib/README.md new file mode 120000 index 000000000..32d46ee88 --- /dev/null +++ b/crates/lib/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs new file mode 100644 index 000000000..5cc475b75 --- /dev/null +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -0,0 +1,1077 @@ +use std::ffi::OsStr; +use std::fs::create_dir_all; +use std::io::Write; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use bootc_blockdev::find_parent_devices; +use bootc_kernel_cmdline::utf8::Cmdline; +use bootc_mount::inspect_filesystem_of_dir; +use bootc_mount::tempmount::TempMount; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::{ + cap_std::{ambient_authority, fs::Dir}, + dirext::CapStdExtDirExt, +}; +use clap::ValueEnum; +use composefs::fs::read_file; +use composefs::tree::RegularFile; +use composefs_boot::BootOps; +use composefs_boot::{ + bootloader::{PEType, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT}, + uki::UkiError, +}; +use fn_error_context::context; +use ostree_ext::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; +use ostree_ext::composefs_boot::{ + bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, + os_release::OsReleaseInfo, uki, +}; +use ostree_ext::composefs_oci::image::create_filesystem as create_composefs_filesystem; +use rustix::{mount::MountFlags, path::Arg}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::composefs_consts::{TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; +use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; +use crate::parsers::grub_menuconfig::MenuEntry; +use crate::task::Task; +use crate::{ + bootc_composefs::repo::open_composefs_repo, + store::{ComposefsFilesystem, Storage}, +}; +use crate::{ + bootc_composefs::state::{get_booted_bls, write_composefs_state}, + bootloader::esp_in, +}; +use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState}; +use crate::{ + composefs_consts::{ + BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, + STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, USER_CFG, USER_CFG_STAGED, + }, + install::RW_KARG, + spec::{Bootloader, Host}, +}; + +use crate::install::{RootSetup, State}; + +/// Contains the EFP's filesystem UUID. Used by grub +pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; +/// The EFI Linux directory +pub(crate) const EFI_LINUX: &str = "EFI/Linux"; + +/// Timeout for systemd-boot bootloader menu +const SYSTEMD_TIMEOUT: &str = "timeout 5"; +const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf"; + +const INITRD: &str = "initrd"; +const VMLINUZ: &str = "vmlinuz"; + +/// We want to be able to control the ordering of UKIs so we put them in a directory that's not the +/// directory specified by the BLS spec. We do this because we want systemd-boot to only look at +/// our config files and not show the actual UKIs in the bootloader menu +/// This is relative to the ESP +pub(crate) const SYSTEMD_UKI_DIR: &str = "EFI/Linux/bootc"; + +pub(crate) enum BootSetupType<'a> { + /// For initial setup, i.e. install to-disk + Setup( + ( + &'a RootSetup, + &'a State, + &'a PostFetchState, + &'a ComposefsFilesystem, + ), + ), + /// For `bootc upgrade` + Upgrade((&'a Storage, &'a ComposefsFilesystem, &'a Host)), +} + +#[derive( + ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema, +)] +pub enum BootType { + #[default] + Bls, + Uki, +} + +impl ::std::fmt::Display for BootType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BootType::Bls => "bls", + BootType::Uki => "uki", + }; + + write!(f, "{}", s) + } +} + +impl TryFrom<&str> for BootType { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + match value { + "bls" => Ok(Self::Bls), + "uki" => Ok(Self::Uki), + unrecognized => Err(anyhow::anyhow!( + "Unrecognized boot option: '{unrecognized}'" + )), + } + } +} + +impl From<&ComposefsBootEntry> for BootType { + fn from(entry: &ComposefsBootEntry) -> Self { + match entry { + ComposefsBootEntry::Type1(..) => Self::Bls, + ComposefsBootEntry::Type2(..) => Self::Uki, + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + } + } +} + +/// Returns the beginning of the grub2/user.cfg file +/// where we source a file containing the ESPs filesystem UUID +pub(crate) fn get_efi_uuid_source() -> String { + format!( + r#" +if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then + source ${{config_directory}}/{EFI_UUID_FILE} +fi +"# + ) +} + +/// Returns `true` if detect the target rootfs carries a UKI. +pub(crate) fn container_root_has_uki(root: &Dir) -> Result { + let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else { + return Ok(false); + }; + let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else { + return Ok(false); + }; + for entry in efi_linux.entries()? { + let entry = entry?; + let name = entry.file_name(); + let name = Path::new(&name); + let extension = name.extension().and_then(|v| v.to_str()); + if extension == Some("efi") { + return Ok(true); + } + } + Ok(false) +} + +pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { + let device_info = bootc_blockdev::partitions_of(Utf8Path::new(device))?; + let esp = crate::bootloader::esp_in(&device_info)?; + + Ok((esp.node.clone(), esp.uuid.clone())) +} + +/// Mount the ESP from the provided device +pub fn mount_esp(device: &str) -> Result { + let flags = MountFlags::NOEXEC | MountFlags::NOSUID; + TempMount::mount_dev(device, "vfat", flags, Some(c"fmask=0177,dmask=0077")) +} + +pub fn get_sysroot_parent_dev(physical_root: &Dir) -> Result { + let fsinfo = inspect_filesystem_of_dir(physical_root)?; + let parent_devices = find_parent_devices(&fsinfo.source)?; + + let Some(parent) = parent_devices.into_iter().next() else { + anyhow::bail!("Could not find parent device of system root"); + }; + + Ok(parent) +} + +pub fn type1_entry_conf_file_name(sort_key: impl std::fmt::Display) -> String { + format!("bootc-composefs-{sort_key}.conf") +} + +/// Compute SHA256Sum of VMlinuz + Initrd +/// +/// # Arguments +/// * entry - BootEntry containing VMlinuz and Initrd +/// * repo - The composefs repository +#[context("Computing boot digest")] +fn compute_boot_digest( + entry: &UsrLibModulesVmlinuz, + repo: &crate::store::ComposefsRepository, +) -> Result { + let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + let initramfs = read_file(initramfs, &repo).context("Reading intird")?; + + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + + hasher.update(&vmlinuz).context("hashing vmlinuz")?; + hasher.update(&initramfs).context("hashing initrd")?; + + let digest: &[u8] = &hasher.finish().context("Finishing digest")?; + + Ok(hex::encode(digest)) +} + +/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum +/// +/// # Returns +/// Returns the verity of all deployments that have a boot digest same as the one passed in +#[context("Checking boot entry duplicates")] +pub(crate) fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result>> { + let deployments = Dir::open_ambient_dir(STATE_DIR_ABS, ambient_authority()); + + let deployments = match deployments { + Ok(d) => d, + // The first ever deployment + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => anyhow::bail!(e), + }; + + let mut symlink_to: Option> = None; + + for depl in deployments.entries()? { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.as_str()?; + + let config = depl + .open_dir() + .with_context(|| format!("Opening {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .context("Reading origin file")?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { + Some(hash) => { + if hash == digest { + match symlink_to { + Some(ref mut prev) => prev.push(depl_file_name.to_string()), + None => symlink_to = Some(vec![depl_file_name.to_string()]), + } + } + } + + // No SHASum recorded in origin file + // `symlink_to` is already none, but being explicit here + None => symlink_to = None, + }; + } + + Ok(symlink_to) +} + +#[context("Writing BLS entries to disk")] +fn write_bls_boot_entries_to_disk( + boot_dir: &Utf8PathBuf, + deployment_id: &Sha512HashValue, + entry: &UsrLibModulesVmlinuz, + repo: &crate::store::ComposefsRepository, +) -> Result<()> { + let id_hex = deployment_id.to_hex(); + + // Write the initrd and vmlinuz at /boot// + let path = boot_dir.join(&id_hex); + create_dir_all(&path)?; + + let entries_dir = Dir::open_ambient_dir(&path, ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + + entries_dir + .atomic_write( + VMLINUZ, + read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?, + ) + .context("Writing vmlinuz to path")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + entries_dir + .atomic_write( + INITRD, + read_file(initramfs, &repo).context("Reading initrd")?, + ) + .context("Writing initrd to path")?; + + // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd + let owned_fd = entries_dir + .reopen_as_ownedfd() + .context("Reopen as owned fd")?; + + rustix::fs::fsync(owned_fd).context("fsync")?; + + Ok(()) +} + +/// Parses /usr/lib/os-release and returns title and version fields +/// # Returns +/// - (title, version) +fn osrel_title_and_version( + fs: &crate::store::ComposefsFilesystem, + repo: &crate::store::ComposefsRepository, +) -> Result, Option)>> { + // Every update should have its own /usr/lib/os-release + let (dir, fname) = fs + .root + .split(OsStr::new("/usr/lib/os-release")) + .context("Getting /usr/lib/os-release")?; + + let os_release = dir + .get_file_opt(fname) + .context("Getting /usr/lib/os-release")?; + + let Some(os_rel_file) = os_release else { + return Ok(None); + }; + + let file_contents = match read_file(os_rel_file, repo) { + Ok(c) => c, + Err(e) => { + tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); + return Ok(None); + } + }; + + let file_contents = match std::str::from_utf8(&file_contents) { + Ok(c) => c, + Err(e) => { + tracing::warn!("/usr/lib/os-release did not have valid UTF-8: {e}"); + return Ok(None); + } + }; + + let parsed_contents = OsReleaseInfo::parse(file_contents); + + let title = parsed_contents.get_pretty_name(); + let version = parsed_contents.get_version(); + + Ok(Some((title, version))) +} + +struct BLSEntryPath { + /// Where to write vmlinuz/initrd + entries_path: Utf8PathBuf, + /// The absolute path, with reference to the partition's root, where the vmlinuz/initrd are written to + abs_entries_path: Utf8PathBuf, + /// Where to write the .conf files + config_path: Utf8PathBuf, +} + +/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk +/// +/// # Returns +/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any +#[context("Setting up BLS boot")] +pub(crate) fn setup_composefs_bls_boot( + setup_type: BootSetupType, + repo: crate::store::ComposefsRepository, + id: &Sha512HashValue, + entry: &ComposefsBootEntry, +) -> Result { + let id_hex = id.to_hex(); + + let (root_path, esp_device, cmdline_refs, fs, bootloader) = match setup_type { + BootSetupType::Setup((root_setup, state, postfetch, fs)) => { + // root_setup.kargs has [root=UUID=, "rw"] + let mut cmdline_options = Cmdline::new(); + + cmdline_options.extend(&root_setup.kargs); + + let composefs_cmdline = if state.composefs_options.insecure { + format!("{COMPOSEFS_CMDLINE}=?{id_hex}") + } else { + format!("{COMPOSEFS_CMDLINE}={id_hex}") + }; + + cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); + + // Locate ESP partition device + let esp_part = esp_in(&root_setup.device_info)?; + + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + cmdline_options, + fs, + postfetch.detected_bootloader.clone(), + ) + } + + BootSetupType::Upgrade((storage, fs, host)) => { + let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; + let bootloader = host.require_composefs_booted()?.bootloader.clone(); + + ( + Utf8PathBuf::from("/sysroot"), + get_esp_partition(&sysroot_parent)?.0, + Cmdline::from(format!("{RW_KARG} {COMPOSEFS_CMDLINE}={id_hex}")), + fs, + bootloader, + ) + } + }; + + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + let (entry_paths, _tmpdir_guard) = match bootloader { + Bootloader::Grub => { + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .context("Opening root path")?; + + // Grub wants the paths to be absolute against the mounted drive that the kernel + + // initrd live in + // + // If "boot" is a partition, we want the paths to be absolute to "/" + let entries_path = match root.is_mountpoint("boot")? { + Some(true) => "/", + // We can be fairly sure that the kernels we target support `statx` + Some(false) | None => "/boot", + }; + + ( + BLSEntryPath { + entries_path: root_path.join("boot"), + config_path: root_path.join("boot"), + abs_entries_path: entries_path.into(), + }, + None, + ) + } + + Bootloader::Systemd => { + let efi_mount = mount_esp(&esp_device).context("Mounting ESP")?; + + let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?); + let efi_linux_dir = mounted_efi.join(EFI_LINUX); + + ( + BLSEntryPath { + entries_path: efi_linux_dir, + config_path: mounted_efi.clone(), + abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX), + }, + Some(efi_mount), + ) + } + }; + + let (bls_config, boot_digest) = match &entry { + ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"), + ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"), + + ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { + let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) + .context("Computing boot digest")?; + + let default_sort_key = "1"; + let default_title_version = (id.to_hex(), default_sort_key.to_string()); + + let osrel_res = osrel_title_and_version(fs, &repo)?; + + let (title, version) = match osrel_res { + Some((t, v)) => ( + t.unwrap_or(default_title_version.0), + v.unwrap_or(default_title_version.1), + ), + + None => default_title_version, + }; + + let mut bls_config = BLSConfig::default(); + + bls_config + .with_title(title) + .with_sort_key(default_sort_key.into()) + .with_version(version) + .with_cfg(BLSConfigType::NonEFI { + linux: entry_paths.abs_entries_path.join(&id_hex).join(VMLINUZ), + initrd: vec![entry_paths.abs_entries_path.join(&id_hex).join(INITRD)], + options: Some(cmdline_refs), + }); + + match find_vmlinuz_initrd_duplicates(&boot_digest)? { + Some(symlink_to) => { + let symlink_to = &symlink_to[0]; + + match bls_config.cfg_type { + BLSConfigType::NonEFI { + ref mut linux, + ref mut initrd, + .. + } => { + *linux = entry_paths.abs_entries_path.join(&symlink_to).join(VMLINUZ); + + *initrd = + vec![entry_paths.abs_entries_path.join(&symlink_to).join(INITRD)]; + } + + _ => unreachable!(), + }; + } + + None => { + write_bls_boot_entries_to_disk( + &entry_paths.entries_path, + id, + usr_lib_modules_vmlinuz, + &repo, + )?; + } + }; + + (bls_config, boot_digest) + } + }; + + let loader_path = entry_paths.config_path.join("loader"); + + let (config_path, booted_bls) = if is_upgrade { + let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())?; + + let mut booted_bls = get_booted_bls(&boot_dir)?; + booted_bls.sort_key = Some("0".into()); // entries are sorted by their filename in reverse order + + // This will be atomically renamed to 'loader/entries' on shutdown/reboot + ( + loader_path.join(STAGED_BOOT_LOADER_ENTRIES), + Some(booted_bls), + ) + } else { + (loader_path.join(BOOT_LOADER_ENTRIES), None) + }; + + create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?; + + let loader_entries_dir = Dir::open_ambient_dir(&config_path, ambient_authority()) + .with_context(|| format!("Opening {config_path:?}"))?; + + loader_entries_dir.atomic_write( + // SAFETY: We set sort_key above + type1_entry_conf_file_name(bls_config.sort_key.as_ref().unwrap()), + bls_config.to_string().as_bytes(), + )?; + + if let Some(booted_bls) = booted_bls { + loader_entries_dir.atomic_write( + // SAFETY: We set sort_key above + type1_entry_conf_file_name(booted_bls.sort_key.as_ref().unwrap()), + booted_bls.to_string().as_bytes(), + )?; + } + + let owned_loader_entries_fd = loader_entries_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + + rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; + + Ok(boot_digest) +} + +struct UKILabels { + boot_label: String, + version: Option, +} + +/// Writes a PortableExecutable to ESP along with any PE specific or Global addons +#[context("Writing {file_path} to ESP")] +fn write_pe_to_esp( + repo: &crate::store::ComposefsRepository, + file: &RegularFile, + file_path: &Utf8Path, + pe_type: PEType, + uki_id: &Sha512HashValue, + is_insecure_from_opts: bool, + mounted_efi: impl AsRef, + bootloader: &Bootloader, +) -> Result> { + let efi_bin = read_file(file, &repo).context("Reading .efi binary")?; + + let mut boot_label: Option = None; + + // UKI Extension might not even have a cmdline + // TODO: UKI Addon might also have a composefs= cmdline? + if matches!(pe_type, PEType::Uki) { + let cmdline = uki::get_cmdline(&efi_bin).context("Getting UKI cmdline")?; + + let (composefs_cmdline, insecure) = + get_cmdline_composefs::(cmdline).context("Parsing composefs=")?; + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + match is_insecure_from_opts { + true if !insecure => { + tracing::warn!("--insecure passed as option but UKI cmdline does not support it"); + } + + false if insecure => { + tracing::warn!("UKI cmdline has composefs set as insecure"); + } + + _ => { /* no-op */ } + } + + if composefs_cmdline != *uki_id { + anyhow::bail!( + "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})" + ); + } + + let osrel = uki::get_text_section(&efi_bin, ".osrel") + .ok_or(UkiError::PortableExecutableError)??; + + let parsed_osrel = OsReleaseInfo::parse(osrel); + + boot_label = Some(UKILabels { + boot_label: uki::get_boot_label(&efi_bin).context("Getting UKI boot label")?, + version: parsed_osrel.get_version(), + }); + } + + // Write the UKI to ESP + let efi_linux_path = mounted_efi.as_ref().join(match bootloader { + Bootloader::Grub => EFI_LINUX, + Bootloader::Systemd => SYSTEMD_UKI_DIR, + }); + + create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; + + let final_pe_path = match file_path.parent() { + Some(parent) => { + let renamed_path = match parent.as_str().ends_with(EFI_ADDON_DIR_EXT) { + true => { + let dir_name = format!("{}{}", uki_id.to_hex(), EFI_ADDON_DIR_EXT); + + parent + .parent() + .map(|p| p.join(&dir_name)) + .unwrap_or(dir_name.into()) + } + + false => parent.to_path_buf(), + }; + + let full_path = efi_linux_path.join(renamed_path); + create_dir_all(&full_path)?; + + full_path + } + + None => efi_linux_path, + }; + + let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) + .with_context(|| format!("Opening {final_pe_path:?}"))?; + + let pe_name = match pe_type { + PEType::Uki => &format!("{}{}", uki_id.to_hex(), EFI_EXT), + PEType::UkiAddon => file_path + .components() + .last() + .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))? + .as_str(), + }; + + pe_dir + .atomic_write(pe_name, efi_bin) + .context("Writing UKI")?; + + rustix::fs::fsync( + pe_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; + + Ok(boot_label) +} + +#[context("Writing Grub menuentry")] +fn write_grub_uki_menuentry( + root_path: Utf8PathBuf, + setup_type: &BootSetupType, + boot_label: String, + id: &Sha512HashValue, + esp_device: &String, +) -> Result<()> { + let boot_dir = root_path.join("boot"); + create_dir_all(&boot_dir).context("Failed to create boot dir")?; + + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + let efi_uuid_source = get_efi_uuid_source(); + + let user_cfg_name = if is_upgrade { + USER_CFG_STAGED + } else { + USER_CFG + }; + + let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority()) + .context("opening boot/grub2")?; + + // Iterate over all available deployments, and generate a menuentry for each + if is_upgrade { + let mut str_buf = String::new(); + let boot_dir = + Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?; + let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?; + + grub_dir + .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> { + f.write_all(efi_uuid_source.as_bytes())?; + f.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + // Write out only the currently booted entry, which should be the very first one + // Even if we have booted into the second menuentry "boot entry", the default will be the + // first one + f.write_all(entries[0].to_string().as_bytes())?; + + Ok(()) + }) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + return Ok(()); + } + + // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there + // This will be sourced by grub2/user.cfg to be used for `--fs-uuid` + let esp_uuid = Task::new("blkid for ESP UUID", "blkid") + .args(["-s", "UUID", "-o", "value", &esp_device]) + .read()?; + + grub_dir.atomic_write( + EFI_UUID_FILE, + format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(), + )?; + + // Write to grub2/user.cfg + grub_dir + .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> { + f.write_all(efi_uuid_source.as_bytes())?; + f.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + Ok(()) + }) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + Ok(()) +} + +#[context("Writing systemd UKI config")] +fn write_systemd_uki_config( + esp_dir: &Dir, + setup_type: &BootSetupType, + boot_label: UKILabels, + id: &Sha512HashValue, +) -> Result<()> { + let default_sort_key = "0"; + + let mut bls_conf = BLSConfig::default(); + bls_conf + .with_title(boot_label.boot_label) + .with_cfg(BLSConfigType::EFI { + efi: format!("/{SYSTEMD_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(), + }) + .with_sort_key(default_sort_key.into()) + .with_version(boot_label.version.unwrap_or(default_sort_key.into())); + + let (entries_dir, booted_bls) = match setup_type { + BootSetupType::Setup(..) => { + esp_dir + .create_dir_all(TYPE1_ENT_PATH) + .with_context(|| format!("Creating {TYPE1_ENT_PATH}"))?; + + (esp_dir.open_dir(TYPE1_ENT_PATH)?, None) + } + + BootSetupType::Upgrade(_) => { + esp_dir + .create_dir_all(TYPE1_ENT_PATH_STAGED) + .with_context(|| format!("Creating {TYPE1_ENT_PATH_STAGED}"))?; + + let mut booted_bls = get_booted_bls(&esp_dir)?; + booted_bls.sort_key = Some("1".into()); + + (esp_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls)) + } + }; + + entries_dir + .atomic_write( + type1_entry_conf_file_name(default_sort_key), + bls_conf.to_string().as_bytes(), + ) + .context("Writing conf file")?; + + if let Some(booted_bls) = booted_bls { + entries_dir.atomic_write( + // SAFETY: We set sort_key above + type1_entry_conf_file_name(booted_bls.sort_key.as_ref().unwrap()), + booted_bls.to_string().as_bytes(), + )?; + } + + // Write the timeout for bootloader menu if not exists + if !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) { + esp_dir + .atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT) + .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?; + } + + let esp_dir = esp_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + rustix::fs::fsync(esp_dir).context("fsync")?; + + Ok(()) +} + +#[context("Setting up UKI boot")] +pub(crate) fn setup_composefs_uki_boot( + setup_type: BootSetupType, + repo: crate::store::ComposefsRepository, + id: &Sha512HashValue, + entries: Vec>, +) -> Result<()> { + let (root_path, esp_device, bootloader, is_insecure_from_opts, uki_addons) = match setup_type { + BootSetupType::Setup((root_setup, state, postfetch, ..)) => { + state.require_no_kargs_for_uki()?; + + let esp_part = esp_in(&root_setup.device_info)?; + + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + postfetch.detected_bootloader.clone(), + state.composefs_options.insecure, + state.composefs_options.uki_addon.as_ref(), + ) + } + + BootSetupType::Upgrade((storage, _, host)) => { + let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path + let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; + let bootloader = host.require_composefs_booted()?.bootloader.clone(); + + ( + sysroot, + get_esp_partition(&sysroot_parent)?.0, + bootloader, + false, + None, + ) + } + }; + + let esp_mount = mount_esp(&esp_device).context("Mounting ESP")?; + + let mut uki_label: Option = None; + + for entry in entries { + match entry { + ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"), + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => { + tracing::debug!("Skipping vmlinuz in /usr/lib/modules") + } + + ComposefsBootEntry::Type2(entry) => { + // If --uki-addon is not passed, we don't install any addon + if matches!(entry.pe_type, PEType::UkiAddon) { + let Some(addons) = uki_addons else { + continue; + }; + + let addon_name = entry + .file_path + .components() + .last() + .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?; + + let addon_name = addon_name.as_str()?; + + let addon_name = + addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| { + anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_EXT}") + })?; + + if !addons.iter().any(|passed_addon| passed_addon == addon_name) { + continue; + } + } + + let utf8_file_path = Utf8Path::from_path(&entry.file_path) + .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; + + let ret = write_pe_to_esp( + &repo, + &entry.file, + utf8_file_path, + entry.pe_type, + &id, + is_insecure_from_opts, + esp_mount.dir.path(), + &bootloader, + )?; + + if let Some(label) = ret { + uki_label = Some(label); + } + } + }; + } + + let uki_label = uki_label + .ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; + + match bootloader { + Bootloader::Grub => write_grub_uki_menuentry( + root_path, + &setup_type, + uki_label.boot_label, + id, + &esp_device, + )?, + + Bootloader::Systemd => write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_label, id)?, + }; + + Ok(()) +} + +#[context("Setting up composefs boot")] +pub(crate) fn setup_composefs_boot( + root_setup: &RootSetup, + state: &State, + image_id: &str, +) -> Result<()> { + let repo = open_composefs_repo(&root_setup.physical_root)?; + let mut fs = create_composefs_filesystem(&repo, image_id, None)?; + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + let mounted_fs = Dir::reopen_dir( + &repo + .mount(&id.to_hex()) + .context("Failed to mount composefs image")?, + )?; + + let postfetch = PostFetchState::new(state, &mounted_fs)?; + + let boot_uuid = root_setup + .get_boot_uuid()? + .or(root_setup.rootfs_uuid.as_deref()) + .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + + if cfg!(target_arch = "s390x") { + // TODO: Integrate s390x support into install_via_bootupd + crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?; + } else if postfetch.detected_bootloader == Bootloader::Grub { + crate::bootloader::install_via_bootupd( + &root_setup.device_info, + &root_setup.physical_root_path, + &state.config_opts, + None, + )?; + } else { + crate::bootloader::install_systemd_boot( + &root_setup.device_info, + &root_setup.physical_root_path, + &state.config_opts, + None, + )?; + } + + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + let mut boot_digest: Option = None; + + match boot_type { + BootType::Bls => { + let digest = setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + repo, + &id, + entry, + )?; + + boot_digest = Some(digest); + } + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + repo, + &id, + entries, + )?, + }; + + write_composefs_state( + &root_setup.physical_root_path, + id, + &crate::spec::ImageReference::from(state.target_imgref.clone()), + false, + boot_type, + boot_digest, + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std; + + #[test] + fn test_root_has_uki() -> Result<()> { + // Test case 1: No boot directory + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 2: boot directory exists but no EFI/Linux + tempdir.create_dir(crate::install::BOOT)?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 3: boot/EFI/Linux exists but no .efi files + tempdir.create_dir_all("boot/EFI/Linux")?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 4: boot/EFI/Linux exists with non-.efi file + tempdir.atomic_write("boot/EFI/Linux/readme.txt", b"some file")?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 5: boot/EFI/Linux exists with .efi file + tempdir.atomic_write("boot/EFI/Linux/bootx64.efi", b"fake efi binary")?; + assert_eq!(container_root_has_uki(&tempdir)?, true); + + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs new file mode 100644 index 000000000..b16930e83 --- /dev/null +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -0,0 +1,370 @@ +use std::{collections::HashSet, io::Write, path::Path}; + +use anyhow::{Context, Result}; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs::fsverity::Sha512HashValue; +use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; + +use crate::{ + bootc_composefs::{ + boot::{ + find_vmlinuz_initrd_duplicates, get_efi_uuid_source, get_esp_partition, + get_sysroot_parent_dev, mount_esp, BootType, SYSTEMD_UKI_DIR, + }, + gc::composefs_gc, + repo::open_composefs_repo, + rollback::{composefs_rollback, rename_exchange_user_cfg}, + status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, + }, + composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, + TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, + }, + parsers::bls_config::{parse_bls_config, BLSConfigType}, + spec::{BootEntry, Bootloader, DeploymentEntry}, + status::Slot, + store::{BootedComposefs, Storage}, +}; + +#[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] +fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { + let entries_dir_path = if deleting_staged { + TYPE1_ENT_PATH_STAGED + } else { + TYPE1_ENT_PATH + }; + + let entries_dir = boot_dir + .open_dir(entries_dir_path) + .context("Opening entries dir")?; + + // We reuse kernel + initrd if they're the same for two deployments + // We don't want to delete the (being deleted) deployment's kernel + initrd + // if it's in use by any other deployment + let should_del_kernel = match depl.deployment.boot_digest.as_ref() { + Some(digest) => find_vmlinuz_initrd_duplicates(digest)? + .is_some_and(|vec| vec.iter().any(|digest| *digest != depl.deployment.verity)), + None => false, + }; + + for entry in entries_dir.entries_utf8()? { + let entry = entry?; + let file_name = entry.file_name()?; + + if !file_name.ends_with(".conf") { + // We don't put any non .conf file in the entries dir + // This is here just for sanity + tracing::debug!("Found non .conf file '{file_name}' in entires dir"); + continue; + } + + let cfg = entries_dir + .read_to_string(&file_name) + .with_context(|| format!("Reading {file_name}"))?; + + let bls_config = parse_bls_config(&cfg)?; + + match &bls_config.cfg_type { + BLSConfigType::EFI { efi } => { + if !efi.as_str().contains(&depl.deployment.verity) { + continue; + } + + // Boot dir in case of EFI will be the ESP + tracing::debug!("Deleting EFI .conf file: {}", file_name); + entry.remove_file().context("Removing .conf file")?; + delete_uki(&depl.deployment.verity, boot_dir)?; + + break; + } + + BLSConfigType::NonEFI { options, .. } => { + let options = options + .as_ref() + .ok_or(anyhow::anyhow!("options not found in BLS config file"))?; + + if !options.contains(&depl.deployment.verity) { + continue; + } + + tracing::debug!("Deleting non-EFI .conf file: {}", file_name); + entry.remove_file().context("Removing .conf file")?; + + if should_del_kernel { + delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; + } + + break; + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } + } + + if deleting_staged { + tracing::debug!( + "Deleting staged entries directory: {}", + TYPE1_ENT_PATH_STAGED + ); + boot_dir + .remove_dir_all(TYPE1_ENT_PATH_STAGED) + .context("Removing staged entries dir")?; + } + + Ok(()) +} + +#[fn_error_context::context("Deleting kernel and initrd")] +fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<()> { + let BLSConfigType::NonEFI { linux, initrd, .. } = bls_config else { + anyhow::bail!("Found EFI config") + }; + + // "linux" and "initrd" are relative to the boot_dir in our config files + tracing::debug!("Deleting kernel: {:?}", linux); + boot_dir + .remove_file(linux) + .with_context(|| format!("Removing {linux:?}"))?; + + for ird in initrd { + tracing::debug!("Deleting initrd: {:?}", ird); + boot_dir + .remove_file(ird) + .with_context(|| format!("Removing {ird:?}"))?; + } + + // Remove the directory if it's empty + // + // This shouldn't ever error as we'll never have these in root + let dir = linux + .parent() + .ok_or_else(|| anyhow::anyhow!("Bad path for vmlinuz {linux}"))?; + + let kernel_parent_dir = boot_dir.open_dir(&dir)?; + + if kernel_parent_dir.entries().iter().len() == 0 { + // We don't have anything other than kernel and initrd in this directory for now + // So this directory should *always* be empty, for now at least + tracing::debug!("Deleting empty kernel directory: {:?}", dir); + kernel_parent_dir.remove_open_dir()?; + }; + + Ok(()) +} + +/// Deletes the UKI `uki_id` and any addons specific to it +#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] +fn delete_uki(uki_id: &str, esp_mnt: &Dir) -> Result<()> { + // TODO: We don't delete global addons here + let ukis = esp_mnt.open_dir(SYSTEMD_UKI_DIR)?; + + for entry in ukis.entries_utf8()? { + let entry = entry?; + let entry_name = entry.file_name()?; + + // The actual UKI PE binary + if entry_name == format!("{}{}", uki_id, EFI_EXT) { + tracing::debug!("Deleting UKI: {}", entry_name); + entry.remove_file().context("Deleting UKI")?; + } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { + // Addons dir + tracing::debug!("Deleting UKI addons directory: {}", entry_name); + ukis.remove_dir_all(entry_name) + .context("Deleting UKI addons dir")?; + } + } + + Ok(()) +} + +#[fn_error_context::context("Removing Grub Menuentry")] +fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { + let grub_dir = boot_dir.open_dir("grub2").context("Opening grub2")?; + + if deleting_staged { + tracing::debug!("Deleting staged grub menuentry file: {}", USER_CFG_STAGED); + return grub_dir + .remove_file(USER_CFG_STAGED) + .context("Deleting staged Menuentry"); + } + + let mut string = String::new(); + let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut string)?; + + grub_dir + .atomic_replace_with(USER_CFG_STAGED, move |f| -> std::io::Result<_> { + f.write_all(get_efi_uuid_source().as_bytes())?; + + for entry in menuentries { + if entry.body.chainloader.contains(id) { + continue; + } + + f.write_all(entry.to_string().as_bytes())?; + } + + Ok(()) + }) + .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd().context("Reopening")?).context("fsync")?; + + rename_exchange_user_cfg(&grub_dir) +} + +#[fn_error_context::context("Deleting boot entries for deployment {}", deployment.deployment.verity)] +fn delete_depl_boot_entries( + deployment: &DeploymentEntry, + physical_root: &Dir, + deleting_staged: bool, +) -> Result<()> { + match deployment.deployment.bootloader { + Bootloader::Grub => { + let boot_dir = physical_root.open_dir("boot").context("Opening boot dir")?; + + match deployment.deployment.boot_type { + BootType::Bls => delete_type1_entry(deployment, &boot_dir, deleting_staged), + + BootType::Uki => { + let device = get_sysroot_parent_dev(physical_root)?; + let (esp_part, ..) = get_esp_partition(&device)?; + let esp_mount = mount_esp(&esp_part)?; + + remove_grub_menucfg_entry( + &deployment.deployment.verity, + &boot_dir, + deleting_staged, + )?; + + delete_uki(&deployment.deployment.verity, &esp_mount.fd) + } + } + } + + Bootloader::Systemd => { + let device = get_sysroot_parent_dev(physical_root)?; + let (esp_part, ..) = get_esp_partition(&device)?; + + let esp_mount = mount_esp(&esp_part)?; + + // For Systemd UKI as well, we use .conf files + delete_type1_entry(deployment, &esp_mount.fd, deleting_staged) + } + } +} + +#[fn_error_context::context("Getting image objects")] +pub(crate) fn get_image_objects(sysroot: &Dir) -> Result> { + let repo = open_composefs_repo(&sysroot)?; + + let images_dir = sysroot + .open_dir("composefs/images") + .context("Opening images dir")?; + + let image_entries = images_dir + .entries_utf8() + .context("Reading entries in images dir")?; + + let mut object_refs = HashSet::new(); + + for image in image_entries { + let image = image?; + + let img_name = image.file_name().context("Getting image name")?; + + let objects = repo + .objects_for_image(&img_name) + .with_context(|| format!("Getting objects for image {img_name}"))?; + + object_refs.extend(objects); + } + + Ok(object_refs) +} + +#[fn_error_context::context("Deleting image for deployment {}", deployment_id)] +pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str) -> Result<()> { + let img_path = Path::new("composefs").join("images").join(deployment_id); + + tracing::debug!("Deleting EROFS image: {:?}", img_path); + sysroot + .remove_file(&img_path) + .context("Deleting EROFS image") +} + +#[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] +pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> Result<()> { + let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); + + tracing::debug!("Deleting state directory: {:?}", state_dir); + sysroot + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}")) +} + +#[fn_error_context::context("Deleting staged deployment")] +pub(crate) fn delete_staged(staged: &Option) -> Result<()> { + if staged.is_none() { + tracing::debug!("No staged deployment"); + return Ok(()); + }; + + let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); + tracing::debug!("Deleting staged deployment file: {file:?}"); + std::fs::remove_file(file).context("Removing staged file")?; + + Ok(()) +} + +#[fn_error_context::context("Deleting composefs deployment {}", deployment_id)] +pub(crate) async fn delete_composefs_deployment( + deployment_id: &str, + storage: &Storage, + booted_cfs: &BootedComposefs, +) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + + let booted = host.require_composefs_booted()?; + + if deployment_id == &booted.verity { + anyhow::bail!("Cannot delete currently booted deployment"); + } + + let all_depls = host.all_composefs_deployments()?; + + let depl_to_del = all_depls + .iter() + .find(|d| d.deployment.verity == deployment_id); + + let Some(depl_to_del) = depl_to_del else { + anyhow::bail!("Deployment {deployment_id} not found"); + }; + + let deleting_staged = host + .status + .staged + .as_ref() + .and_then(|s| s.composefs.as_ref()) + .map_or(false, |cfs| cfs.verity == deployment_id); + + // Unqueue rollback. This makes it easier to delete boot entries later on + if matches!(depl_to_del.ty, Some(Slot::Rollback)) && host.status.rollback_queued { + composefs_rollback(storage, booted_cfs).await?; + } + + let kind = if depl_to_del.pinned { + "pinned " + } else if deleting_staged { + "staged " + } else { + "" + }; + + tracing::info!("Deleting {kind}deployment '{deployment_id}'"); + + delete_depl_boot_entries(&depl_to_del, &storage.physical_root, deleting_staged)?; + + composefs_gc(storage, booted_cfs).await?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs new file mode 100644 index 000000000..d397c9f5c --- /dev/null +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -0,0 +1,165 @@ +use std::path::Path; + +use crate::bootc_composefs::boot::{ + get_esp_partition, get_sysroot_parent_dev, mount_esp, BootType, +}; +use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; +use crate::bootc_composefs::status::get_composefs_status; +use crate::composefs_consts::STATE_DIR_ABS; +use crate::spec::Bootloader; +use crate::store::{BootedComposefs, Storage}; +use anyhow::{Context, Result}; +use bootc_initramfs_setup::mount_composefs_image; +use bootc_mount::tempmount::TempMount; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use etc_merge::{compute_diff, merge, print_diff, traverse_etc}; +use rustix::fs::{fsync, renameat}; +use rustix::path::Arg; + +use fn_error_context::context; + +pub(crate) async fn get_etc_diff(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + let booted_composefs = host.require_composefs_booted()?; + + // Mount the booted EROFS image to get pristine etc + let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; + let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + + let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; + + let pristine_etc = + Dir::open_ambient_dir(erofs_tmp_mnt.dir.path().join("etc"), ambient_authority())?; + let current_etc = Dir::open_ambient_dir("/etc", ambient_authority())?; + + let (pristine_files, current_files, _) = traverse_etc(&pristine_etc, ¤t_etc, None)?; + let diff = compute_diff(&pristine_files, ¤t_files)?; + + print_diff(&diff, &mut std::io::stdout()); + + Ok(()) +} + +pub(crate) async fn composefs_backend_finalize( + storage: &Storage, + booted_cfs: &BootedComposefs, +) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + + let booted_composefs = host.require_composefs_booted()?; + + let Some(staged_depl) = host.status.staged.as_ref() else { + tracing::debug!("No staged deployment found"); + return Ok(()); + }; + + let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!( + "Staged deployment is not a composefs deployment" + ))?; + + // Mount the booted EROFS image to get pristine etc + let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; + let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + + let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; + + // Perform the /etc merge + let pristine_etc = + Dir::open_ambient_dir(erofs_tmp_mnt.dir.path().join("etc"), ambient_authority())?; + let current_etc = Dir::open_ambient_dir("/etc", ambient_authority())?; + + let new_etc_path = Path::new(STATE_DIR_ABS) + .join(&staged_composefs.verity) + .join("etc"); + + let new_etc = Dir::open_ambient_dir(new_etc_path, ambient_authority())?; + + let (pristine_files, current_files, new_files) = + traverse_etc(&pristine_etc, ¤t_etc, Some(&new_etc))?; + + let new_files = new_files.ok_or(anyhow::anyhow!("Failed to get dirtree for new etc"))?; + + let diff = compute_diff(&pristine_files, ¤t_files)?; + merge(¤t_etc, ¤t_files, &new_etc, &new_files, diff)?; + + // Unmount EROFS + drop(erofs_tmp_mnt); + + let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; + // NOTE: Assumption here that ESP will always be present + let (esp_part, ..) = get_esp_partition(&sysroot_parent)?; + + let esp_mount = mount_esp(&esp_part)?; + let boot_dir = storage + .physical_root + .open_dir("boot") + .context("Opening boot")?; + + // NOTE: Assuming here we won't have two bootloaders at the same time + match booted_composefs.bootloader { + Bootloader::Grub => match staged_composefs.boot_type { + BootType::Bls => { + let entries_dir = boot_dir.open_dir("loader")?; + rename_exchange_bls_entries(&entries_dir)?; + } + BootType::Uki => finalize_staged_grub_uki(&esp_mount.fd, &boot_dir)?, + }, + + Bootloader::Systemd => match staged_composefs.boot_type { + BootType::Bls => { + let entries_dir = esp_mount.fd.open_dir("loader")?; + rename_exchange_bls_entries(&entries_dir)?; + } + BootType::Uki => { + rename_staged_uki_entries(&esp_mount.fd)?; + + let entries_dir = esp_mount.fd.open_dir("loader")?; + rename_exchange_bls_entries(&entries_dir)?; + } + }, + }; + + Ok(()) +} + +#[context("Grub: Finalizing staged UKI")] +fn finalize_staged_grub_uki(esp_mount: &Dir, boot_fd: &Dir) -> Result<()> { + rename_staged_uki_entries(esp_mount)?; + + let entries_dir = boot_fd.open_dir("grub2")?; + rename_exchange_user_cfg(&entries_dir)?; + + let entries_dir = entries_dir.reopen_as_ownedfd()?; + fsync(entries_dir).context("fsync")?; + + Ok(()) +} + +#[context("Renaming staged UKI entries")] +fn rename_staged_uki_entries(esp_mount: &Dir) -> Result<()> { + for entry in esp_mount.entries()? { + let entry = entry?; + + let filename = entry.file_name(); + let filename = filename.as_str()?; + + if !filename.ends_with(".staged") { + continue; + } + + renameat( + &esp_mount, + filename, + &esp_mount, + // SAFETY: We won't reach here if not for the above condition + filename.strip_suffix(".staged").unwrap(), + ) + .context("Renaming {filename}")?; + } + + let esp_mount = esp_mount.reopen_as_ownedfd()?; + fsync(esp_mount).context("fsync")?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs new file mode 100644 index 000000000..8195cade2 --- /dev/null +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -0,0 +1,222 @@ +//! This module handles the case when deleting a deployment fails midway +//! +//! There could be the following cases (See ./delete.rs:delete_composefs_deployment): +//! - We delete the bootloader entry but fail to delete image +//! - We delete bootloader + image but fail to delete the state/unrefenced objects etc + +use anyhow::{Context, Result}; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; + +use crate::{ + bootc_composefs::{ + boot::{get_esp_partition, get_sysroot_parent_dev, mount_esp}, + delete::{delete_image, delete_staged, delete_state_dir, get_image_objects}, + status::{ + get_bootloader, get_composefs_status, get_sorted_grub_uki_boot_entries, + get_sorted_type1_boot_entries, + }, + }, + composefs_consts::{STATE_DIR_RELATIVE, USER_CFG}, + spec::Bootloader, + store::{BootedComposefs, Storage}, +}; + +#[fn_error_context::context("Listing EROFS images")] +fn list_erofs_images(sysroot: &Dir) -> Result> { + let images_dir = sysroot + .open_dir("composefs/images") + .context("Opening images dir")?; + + let mut images = vec![]; + + for entry in images_dir.entries_utf8()? { + let entry = entry?; + let name = entry.file_name()?; + images.push(name); + } + + Ok(images) +} + +/// Get all Type1/Type2 bootloader entries +/// +/// # Returns +/// The fsverity of EROFS images corresponding to boot entries +#[fn_error_context::context("Listing bootloader entries")] +fn list_bootloader_entries(physical_root: &Dir) -> Result> { + let bootloader = get_bootloader()?; + + let entries = match bootloader { + Bootloader::Grub => { + let boot_dir = physical_root.open_dir("boot").context("Opening boot dir")?; + + // Grub entries are always in boot + let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + + if grub_dir.exists(USER_CFG) { + // Grub UKI + let mut s = String::new(); + let boot_entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut s)?; + + boot_entries + .into_iter() + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } else { + // Type1 Entry + let boot_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + + boot_entries + .into_iter() + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } + } + + Bootloader::Systemd => { + let device = get_sysroot_parent_dev(physical_root)?; + let (esp_part, ..) = get_esp_partition(&device)?; + let esp_mount = mount_esp(&esp_part)?; + + let boot_entries = get_sorted_type1_boot_entries(&esp_mount.fd, true)?; + + boot_entries + .into_iter() + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } + }; + + Ok(entries) +} + +#[fn_error_context::context("Listing state directories")] +fn list_state_dirs(sysroot: &Dir) -> Result> { + let state = sysroot + .open_dir(STATE_DIR_RELATIVE) + .context("Opening state dir")?; + + let mut dirs = vec![]; + + for dir in state.entries_utf8()? { + let dir = dir?; + + if dir.file_type()?.is_file() { + continue; + } + + dirs.push(dir.file_name()?); + } + + Ok(dirs) +} + +/// Deletes objects in sysroot/composefs/objects that are not being referenced by any of the +/// present EROFS images +/// +/// We do not delete streams though +#[fn_error_context::context("Garbage collecting objects")] +// TODO(Johan-Liebert1): This will be moved to composefs-rs +pub(crate) fn gc_objects(sysroot: &Dir) -> Result<()> { + tracing::debug!("Running garbage collection on unreferenced objects"); + + // Get all the objects referenced by all available images + let obj_refs = get_image_objects(sysroot)?; + + // List all objects in the objects directory + let objects_dir = sysroot + .open_dir("composefs/objects") + .context("Opening objects dir")?; + + for dir_name in 0x0..=0xff { + let dir = objects_dir + .open_dir_optional(dir_name.to_string()) + .with_context(|| format!("Opening {dir_name}"))?; + + let Some(dir) = dir else { + continue; + }; + + for entry in dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + let id = Sha512HashValue::from_object_dir_and_basename(dir_name, filename.as_bytes())?; + + // If this object is not referenced by any image, delete it + if !obj_refs.contains(&id) { + tracing::trace!("Deleting unreferenced object: {filename}"); + + entry + .remove_file() + .with_context(|| format!("Removing object {filename}"))?; + } + } + } + + Ok(()) +} + +/// 1. List all bootloader entries +/// 2. List all EROFS images +/// 3. List all state directories +/// 4. List staged depl if any +/// +/// If bootloader entry B1 doesn't exist, but EROFS image B1 does exist, then delete the image and +/// perform GC +/// +/// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and +/// perform GC +#[fn_error_context::context("Running composefs garbage collection")] +pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + let booted_cfs_status = host.require_composefs_booted()?; + + let sysroot = &storage.physical_root; + + let bootloader_entries = list_bootloader_entries(&storage.physical_root)?; + let images = list_erofs_images(&sysroot)?; + + // Collect the deployments that have an image but no bootloader entry + let img_bootloader_diff = images + .iter() + .filter(|i| !bootloader_entries.contains(i)) + .collect::>(); + + let staged = &host.status.staged; + + if img_bootloader_diff.contains(&&booted_cfs_status.verity) { + anyhow::bail!( + "Inconsistent state. Booted entry '{}' found for cleanup", + booted_cfs_status.verity + ) + } + + for verity in &img_bootloader_diff { + tracing::debug!("Cleaning up orphaned image: {verity}"); + + delete_staged(staged)?; + delete_image(&sysroot, verity)?; + delete_state_dir(&sysroot, verity)?; + } + + let state_dirs = list_state_dirs(&sysroot)?; + + // Collect all the deployments that have no image but have a state dir + // This for the case where the gc was interrupted after deleting the image + let state_img_diff = state_dirs + .iter() + .filter(|s| !images.contains(s)) + .collect::>(); + + for verity in &state_img_diff { + delete_staged(staged)?; + delete_state_dir(&sysroot, verity)?; + } + + // Run garbage collection on objects after deleting images + gc_objects(&sysroot)?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs new file mode 100644 index 000000000..a9ced452d --- /dev/null +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod boot; +pub(crate) mod delete; +pub(crate) mod finalize; +pub(crate) mod gc; +pub(crate) mod repo; +pub(crate) mod rollback; +pub(crate) mod service; +pub(crate) mod state; +pub(crate) mod status; +pub(crate) mod switch; +pub(crate) mod update; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs new file mode 100644 index 000000000..c3f478169 --- /dev/null +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -0,0 +1,152 @@ +use fn_error_context::context; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use ostree_ext::composefs::{ + fsverity::{FsVerityHashValue, Sha512HashValue}, + util::Sha256Digest, +}; +use ostree_ext::composefs_boot::{bootloader::BootEntry as ComposefsBootEntry, BootOps}; +use ostree_ext::composefs_oci::{ + image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, +}; + +use ostree_ext::container::ImageReference as OstreeExtImgRef; + +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; + +use crate::install::{RootSetup, State}; + +pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result { + crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs") + .context("Failed to open composefs repository") +} + +pub(crate) async fn initialize_composefs_repository( + state: &State, + root_setup: &RootSetup, +) -> Result<(Sha256Digest, impl FsVerityHashValue)> { + let rootfs_dir = &root_setup.physical_root; + + rootfs_dir + .create_dir_all("composefs") + .context("Creating dir composefs")?; + + let repo = open_composefs_repo(rootfs_dir)?; + + let OstreeExtImgRef { + name: image_name, + transport, + } = &state.source.imageref; + + // transport's display is already of type ":" + composefs_oci_pull( + &Arc::new(repo), + &format!("{transport}{image_name}"), + None, + None, + ) + .await +} + +/// skopeo (in composefs-rs) doesn't understand "registry:" +/// This function will convert it to "docker://" and return the image ref +/// +/// Ex +/// docker://quay.io/some-image +/// containers-storage:some-image +/// docker-daemon:some-image-id +pub(crate) fn get_imgref(transport: &str, image: &str) -> String { + let img = image.strip_prefix(":").unwrap_or(&image); + let transport = transport.strip_suffix(":").unwrap_or(&transport); + + if transport == "registry" { + format!("docker://{img}") + } else if transport == "docker-daemon" { + format!("docker-daemon:{img}") + } else { + format!("{transport}:{img}") + } +} + +/// Pulls the `image` from `transport` into a composefs repository at /sysroot +/// Checks for boot entries in the image and returns them +#[context("Pulling composefs repository")] +pub(crate) async fn pull_composefs_repo( + transport: &String, + image: &String, +) -> Result<( + crate::store::ComposefsRepository, + Vec>, + Sha512HashValue, + crate::store::ComposefsFilesystem, +)> { + let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; + + let repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; + + let final_imgref = get_imgref(transport, image); + + tracing::debug!("Image to pull {final_imgref}"); + + let (id, verity) = composefs_oci_pull(&Arc::new(repo), &final_imgref, None, None) + .await + .context("Pulling composefs repo")?; + + tracing::info!("ID: {}, Verity: {}", hex::encode(id), verity.to_hex()); + + let repo = open_composefs_repo(&rootfs_dir)?; + let mut fs: crate::store::ComposefsFilesystem = + create_composefs_filesystem(&repo, &hex::encode(id), None) + .context("Failed to create composefs filesystem")?; + + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + Ok((repo, entries, id, fs)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE_NAME: &str = "quay.io/example/image:latest"; + + #[test] + fn test_get_imgref_registry_transport() { + assert_eq!( + get_imgref("registry:", IMAGE_NAME), + format!("docker://{IMAGE_NAME}") + ); + } + + #[test] + fn test_get_imgref_containers_storage() { + assert_eq!( + get_imgref("containers-storage", IMAGE_NAME), + format!("containers-storage:{IMAGE_NAME}") + ); + + assert_eq!( + get_imgref("containers-storage:", IMAGE_NAME), + format!("containers-storage:{IMAGE_NAME}") + ); + } + + #[test] + fn test_get_imgref_edge_cases() { + assert_eq!( + get_imgref("registry", IMAGE_NAME), + format!("docker://{IMAGE_NAME}") + ); + } + + #[test] + fn test_get_imgref_docker_daemon_transport() { + assert_eq!( + get_imgref("docker-daemon", IMAGE_NAME), + format!("docker-daemon:{IMAGE_NAME}") + ); + } +} diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs new file mode 100644 index 000000000..6338bf9b5 --- /dev/null +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -0,0 +1,234 @@ +use std::io::Write; + +use anyhow::{anyhow, Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; + +use crate::bootc_composefs::boot::{ + get_esp_partition, get_sysroot_parent_dev, mount_esp, type1_entry_conf_file_name, BootType, +}; +use crate::bootc_composefs::status::{get_composefs_status, get_sorted_type1_boot_entries}; +use crate::composefs_consts::TYPE1_ENT_PATH_STAGED; +use crate::spec::Bootloader; +use crate::store::{BootedComposefs, Storage}; +use crate::{ + bootc_composefs::{boot::get_efi_uuid_source, status::get_sorted_grub_uki_boot_entries}, + composefs_consts::{ + BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_STAGED, + }, + spec::BootOrder, +}; + +/// Atomically rename exchange grub user.cfg with the staged version +/// Performed as the last step in rollback/update/switch operation +#[context("Atomically exchanging user.cfg")] +pub(crate) fn rename_exchange_user_cfg(entries_dir: &Dir) -> Result<()> { + tracing::debug!("Atomically exchanging {USER_CFG_STAGED} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_STAGED, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_STAGED}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_STAGED, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + let entries_dir = entries_dir + .reopen_as_ownedfd() + .context("Reopening entries dir as owned fd")?; + + fsync(entries_dir).context("fsync entries dir")?; + + Ok(()) +} + +/// Atomically rename exchange "entries" <-> "entries.staged" +/// Performed as the last step in rollback/update/switch operation +/// +/// `entries_dir` is the directory that contains the BLS entries directories +/// Ex: entries_dir = ESP/loader or boot/loader +#[context("Atomically exchanging BLS entries")] +pub(crate) fn rename_exchange_bls_entries(entries_dir: &Dir) -> Result<()> { + tracing::debug!("Atomically exchanging {STAGED_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}"); + renameat_with( + &entries_dir, + STAGED_BOOT_LOADER_ENTRIES, + &entries_dir, + BOOT_LOADER_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {STAGED_BOOT_LOADER_ENTRIES}"); + entries_dir + .remove_dir_all(STAGED_BOOT_LOADER_ENTRIES) + .context("Removing staged dir")?; + + tracing::debug!("Syncing to disk"); + let entries_dir = entries_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + + fsync(entries_dir).context("fsync")?; + + Ok(()) +} + +#[context("Rolling back Grub UKI")] +fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { + let mut str = String::new(); + let mut menuentries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str) + .context("Getting UKI boot entries")?; + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); + + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); + + let entries_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + + entries_dir + .atomic_replace_with(USER_CFG_STAGED, |f| -> std::io::Result<_> { + f.write_all(get_efi_uuid_source().as_bytes())?; + + for entry in menuentries { + f.write_all(entry.to_string().as_bytes())?; + } + + Ok(()) + }) + .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; + + rename_exchange_user_cfg(&entries_dir) +} + +/// Performs rollback for +/// - Grub Type1 boot entries +/// - Systemd Typ1 boot entries +/// - Systemd UKI (Type2) boot entries [since we use BLS entries for systemd boot] +/// +/// The bootloader parameter is only for logging purposes +#[context("Rolling back {bootloader} entries")] +fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result<()> { + // Sort in descending order as that's the order they're shown on the boot screen + // After this: + // all_configs[0] -> booted depl + // all_configs[1] -> rollback depl + let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?; + + // Update the indicies so that they're swapped + for (idx, cfg) in all_configs.iter_mut().enumerate() { + cfg.sort_key = Some(idx.to_string()); + } + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(all_configs.len() == 2); + + // Write these + boot_dir + .create_dir_all(TYPE1_ENT_PATH_STAGED) + .context("Creating staged dir")?; + + let rollback_entries_dir = boot_dir + .open_dir(TYPE1_ENT_PATH_STAGED) + .context("Opening staged entries dir")?; + + // Write the BLS configs in there + for cfg in all_configs { + // SAFETY: We set sort_key above + let file_name = type1_entry_conf_file_name(cfg.sort_key.as_ref().unwrap()); + + rollback_entries_dir + .atomic_write(&file_name, cfg.to_string()) + .with_context(|| format!("Writing to {file_name}"))?; + } + + let rollback_entries_dir = rollback_entries_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + + // Should we sync after every write? + fsync(rollback_entries_dir).context("fsync")?; + + // Atomically exchange "entries" <-> "entries.rollback" + let dir = boot_dir.open_dir("loader").context("Opening loader dir")?; + + rename_exchange_bls_entries(&dir) +} + +#[context("Rolling back composefs")] +pub(crate) async fn composefs_rollback( + storage: &Storage, + booted_cfs: &BootedComposefs, +) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.boot_order = new_spec.boot_order.swap(); + new_spec + }; + + // Just to be sure + host.spec.verify_transition(&new_spec)?; + + let reverting = new_spec.boot_order == BootOrder::Default; + if reverting { + println!("notice: Reverting queued rollback state"); + } + + let rollback_status = host + .status + .rollback + .ok_or_else(|| anyhow!("No rollback available"))?; + + // TODO: Handle staged deployment + // Ostree will drop any staged deployment on rollback but will keep it if it is the first item + // in the new deployment list + let Some(rollback_entry) = &rollback_status.composefs else { + anyhow::bail!("Rollback deployment not a composefs deployment") + }; + + match &rollback_entry.bootloader { + Bootloader::Grub => { + let boot_dir = storage + .physical_root + .open_dir("boot") + .context("Opening boot dir")?; + + match rollback_entry.boot_type { + BootType::Bls => { + rollback_composefs_entries(&boot_dir, rollback_entry.bootloader.clone())?; + } + + BootType::Uki => { + rollback_grub_uki_entries(&boot_dir)?; + } + } + } + + Bootloader::Systemd => { + let parent = get_sysroot_parent_dev(&storage.physical_root)?; + let (esp_part, ..) = get_esp_partition(&parent)?; + let esp_mount = mount_esp(&esp_part)?; + + // We use BLS entries for systemd UKI as well + rollback_composefs_entries(&esp_mount.fd, rollback_entry.bootloader.clone())?; + } + } + + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/service.rs b/crates/lib/src/bootc_composefs/service.rs new file mode 100644 index 000000000..fdf4136a0 --- /dev/null +++ b/crates/lib/src/bootc_composefs/service.rs @@ -0,0 +1,22 @@ +use anyhow::{Context, Result}; +use fn_error_context::context; +use std::process::Command; + +use crate::composefs_consts::BOOTC_FINALIZE_STAGED_SERVICE; + +/// Starts the finaize staged service which will "unstage" the deployment +/// This is called before an upgrade or switch operation, as these create a staged +/// deployment +#[context("Starting finalize staged service")] +pub(crate) fn start_finalize_stated_svc() -> Result<()> { + let cmd_status = Command::new("systemctl") + .args(["start", "--quiet", BOOTC_FINALIZE_STAGED_SERVICE]) + .status() + .context("Starting finalize service")?; + + if !cmd_status.success() { + anyhow::bail!("systemctl exited with status {cmd_status}") + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs new file mode 100644 index 000000000..723d6ed19 --- /dev/null +++ b/crates/lib/src/bootc_composefs/state.rs @@ -0,0 +1,208 @@ +use std::os::unix::fs::symlink; +use std::{fs::create_dir_all, process::Command}; + +use anyhow::{Context, Result}; +use bootc_initramfs_setup::overlay_transient; +use bootc_kernel_cmdline::utf8::Cmdline; +use bootc_mount::tempmount::TempMount; +use bootc_utils::CommandRunExt; +use camino::Utf8PathBuf; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use fn_error_context::context; + +use ostree_ext::container::deploy::ORIGIN_CONTAINER; +use rustix::{ + fs::{open, Mode, OFlags}, + path::Arg, +}; + +use crate::bootc_composefs::boot::BootType; +use crate::bootc_composefs::repo::get_imgref; +use crate::bootc_composefs::status::get_sorted_type1_boot_entries; +use crate::parsers::bls_config::BLSConfigType; +use crate::{ + composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, SHARED_VAR_PATH, + STATE_DIR_RELATIVE, + }, + parsers::bls_config::BLSConfig, + spec::ImageReference, + utils::path_relative_to, +}; + +pub(crate) fn get_booted_bls(boot_dir: &Dir) -> Result { + let cmdline = Cmdline::from_proc()?; + let booted = cmdline + .find(COMPOSEFS_CMDLINE) + .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; + + let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + + for entry in sorted_entries { + match &entry.cfg_type { + BLSConfigType::EFI { efi } => { + let composefs_param_value = booted.value().ok_or_else(|| { + anyhow::anyhow!("Failed to get composefs kernel cmdline value") + })?; + + if efi.as_str().contains(composefs_param_value) { + return Ok(entry); + } + } + + BLSConfigType::NonEFI { options, .. } => { + let Some(opts) = options else { + anyhow::bail!("options not found in bls config") + }; + + let opts = Cmdline::from(opts); + + if opts.iter().any(|v| v == booted) { + return Ok(entry); + } + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config type"), + }; + } + + Err(anyhow::anyhow!("Booted BLS not found")) +} + +/// Mounts an EROFS image and copies the pristine /etc to the deployment's /etc +#[context("Copying etc")] +pub(crate) fn copy_etc_to_state( + sysroot_path: &Utf8PathBuf, + erofs_id: &String, + state_path: &Utf8PathBuf, +) -> Result<()> { + let sysroot_fd = open( + sysroot_path.as_std_path(), + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .context("Opening sysroot")?; + + let composefs_fd = bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, false)?; + + let tempdir = TempMount::mount_fd(composefs_fd)?; + + // TODO: Replace this with a function to cap_std_ext + let cp_ret = Command::new("cp") + .args([ + "-a", + "--remove-destination", + &format!("{}/etc/.", tempdir.dir.path().as_str()?), + &format!("{state_path}/etc/."), + ]) + .run_capture_stderr(); + + cp_ret +} + +/// Creates and populates /sysroot/state/deploy/image_id +#[context("Writing composefs state")] +pub(crate) fn write_composefs_state( + root_path: &Utf8PathBuf, + deployment_id: Sha512HashValue, + imgref: &ImageReference, + staged: bool, + boot_type: BootType, + boot_digest: Option, +) -> Result<()> { + let state_path = root_path + .join(STATE_DIR_RELATIVE) + .join(deployment_id.to_hex()); + + create_dir_all(state_path.join("etc"))?; + + copy_etc_to_state(&root_path, &deployment_id.to_hex(), &state_path)?; + + let actual_var_path = root_path.join(SHARED_VAR_PATH); + create_dir_all(&actual_var_path)?; + + symlink( + path_relative_to(state_path.as_std_path(), actual_var_path.as_std_path()) + .context("Getting var symlink path")?, + state_path.join("var"), + ) + .context("Failed to create symlink for /var")?; + + let ImageReference { + image: image_name, + transport, + .. + } = &imgref; + + let imgref = get_imgref(&transport, &image_name); + + let mut config = tini::Ini::new().section("origin").item( + ORIGIN_CONTAINER, + // TODO (Johan-Liebert1): The image won't always be unverified + format!("ostree-unverified-image:{imgref}"), + ); + + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_TYPE, boot_type); + + if let Some(boot_digest) = boot_digest { + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); + } + + let state_dir = + Dir::open_ambient_dir(&state_path, ambient_authority()).context("Opening state dir")?; + + state_dir + .atomic_write( + format!("{}.origin", deployment_id.to_hex()), + config.to_string().as_bytes(), + ) + .context("Failed to write to .origin file")?; + + if staged { + std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) + .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + staged_depl_dir + .atomic_write( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + deployment_id.to_hex().as_bytes(), + ) + .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; + } + + Ok(()) +} + +pub(crate) fn composefs_usr_overlay() -> Result<()> { + let usr = Dir::open_ambient_dir("/usr", ambient_authority()).context("Opening /usr")?; + let is_usr_mounted = usr + .is_mountpoint(".") + .context("Failed to get mount details for /usr")?; + + let is_usr_mounted = + is_usr_mounted.ok_or_else(|| anyhow::anyhow!("Falied to get mountinfo"))?; + + if is_usr_mounted { + println!("A writeable overlayfs is already mounted on /usr"); + return Ok(()); + } + + overlay_transient(usr)?; + + println!("A writeable overlayfs is now mounted on /usr"); + println!("All changes there will be discarded on reboot."); + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs new file mode 100644 index 000000000..29ae212ba --- /dev/null +++ b/crates/lib/src/bootc_composefs/status.rs @@ -0,0 +1,570 @@ +use std::{io::Read, sync::OnceLock}; + +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::utf8::Cmdline; +use fn_error_context::context; + +use crate::{ + bootc_composefs::boot::{get_esp_partition, get_sysroot_parent_dev, mount_esp, BootType}, + composefs_consts::{COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, USER_CFG}, + install::EFI_LOADER_INFO, + parsers::{ + bls_config::{parse_bls_config, BLSConfig, BLSConfigType}, + grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}, + }, + spec::{BootEntry, BootOrder, Host, HostSpec, ImageReference, ImageStatus}, + utils::{read_uefi_var, EfiError}, +}; + +use std::str::FromStr; + +use bootc_utils::try_deserialize_timestamp; +use cap_std_ext::cap_std::fs::Dir; +use ostree_container::OstreeImageReference; +use ostree_ext::container::deploy::ORIGIN_CONTAINER; +use ostree_ext::container::{self as ostree_container}; +use ostree_ext::containers_image_proxy; +use ostree_ext::oci_spec; + +use ostree_ext::oci_spec::image::ImageManifest; +use tokio::io::AsyncReadExt; + +use crate::composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, + ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, +}; +use crate::spec::Bootloader; + +/// A parsed composefs command line +#[derive(Clone)] +pub(crate) struct ComposefsCmdline { + #[allow(dead_code)] + pub insecure: bool, + pub digest: Box, +} + +impl ComposefsCmdline { + pub(crate) fn new(s: &str) -> Self { + let (insecure, digest_str) = s + .strip_prefix('?') + .map(|v| (true, v)) + .unwrap_or_else(|| (false, s)); + ComposefsCmdline { + insecure, + digest: digest_str.into(), + } + } +} + +impl std::fmt::Display for ComposefsCmdline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let insecure = if self.insecure { "?" } else { "" }; + write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest) + } +} + +/// Detect if we have composefs= in /proc/cmdline +pub(crate) fn composefs_booted() -> Result> { + static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); + if let Some(v) = CACHED_DIGEST_VALUE.get() { + return Ok(v.as_ref()); + } + let cmdline = Cmdline::from_proc()?; + let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else { + return Ok(None); + }; + let Some(v) = kv.value() else { return Ok(None) }; + let v = ComposefsCmdline::new(v); + let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(v)); + Ok(r.as_ref()) +} + +// Need str to store lifetime +pub(crate) fn get_sorted_grub_uki_boot_entries<'a>( + boot_dir: &Dir, + str: &'a mut String, +) -> Result>> { + let mut file = boot_dir + .open(format!("grub2/{USER_CFG}")) + .with_context(|| format!("Opening {USER_CFG}"))?; + file.read_to_string(str)?; + parse_grub_menuentry_file(str) +} + +#[context("Getting sorted Type1 boot entries")] +pub(crate) fn get_sorted_type1_boot_entries( + boot_dir: &Dir, + ascending: bool, +) -> Result> { + let mut all_configs = vec![]; + + for entry in boot_dir.read_dir(TYPE1_ENT_PATH)? { + let entry = entry?; + + let file_name = entry.file_name(); + + let file_name = file_name + .to_str() + .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?; + + if !file_name.ends_with(".conf") { + continue; + } + + let mut file = entry + .open() + .with_context(|| format!("Failed to open {:?}", file_name))?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| format!("Failed to read {:?}", file_name))?; + + let config = parse_bls_config(&contents).context("Parsing bls config")?; + + all_configs.push(config); + } + + all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + + Ok(all_configs) +} + +/// imgref = transport:image_name +#[context("Getting container info")] +pub(crate) async fn get_container_manifest_and_config( + imgref: &String, +) -> Result<(ImageManifest, oci_spec::image::ImageConfiguration)> { + let config = containers_image_proxy::ImageProxyConfig::default(); + let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; + + let img = proxy.open_image(&imgref).await.context("Opening image")?; + + let (_, manifest) = proxy.fetch_manifest(&img).await?; + let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?; + + let mut buf = Vec::with_capacity(manifest.config().size() as usize); + buf.resize(manifest.config().size() as usize, 0); + reader.read_exact(&mut buf).await?; + driver.await?; + + let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?; + + Ok((manifest, config)) +} + +#[context("Getting bootloader")] +pub(crate) fn get_bootloader() -> Result { + match read_uefi_var(EFI_LOADER_INFO) { + Ok(loader) => { + if loader.to_lowercase().contains("systemd-boot") { + return Ok(Bootloader::Systemd); + } + + return Ok(Bootloader::Grub); + } + + Err(efi_error) => match efi_error { + EfiError::SystemNotUEFI => return Ok(Bootloader::Grub), + EfiError::MissingVar => return Ok(Bootloader::Grub), + + e => return Err(anyhow::anyhow!("Failed to read EfiLoaderInfo: {e:?}")), + }, + } +} + +#[context("Getting composefs deployment metadata")] +async fn boot_entry_from_composefs_deployment( + origin: tini::Ini, + verity: String, +) -> Result { + let image = match origin.get::("origin", ORIGIN_CONTAINER) { + Some(img_name_from_config) => { + let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; + let imgref = ostree_img_ref.imgref.to_string(); + let img_ref = ImageReference::from(ostree_img_ref); + + // The image might've been removed, so don't error if we can't get the image manifest + let (image_digest, version, architecture, created_at) = + match get_container_manifest_and_config(&imgref).await { + Ok((manifest, config)) => { + let digest = manifest.config().digest().to_string(); + let arch = config.architecture().to_string(); + let created = config.created().clone(); + let version = manifest + .annotations() + .as_ref() + .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned()); + + (digest, version, arch, created) + } + + Err(e) => { + tracing::debug!("Failed to open image {img_ref}, because {e:?}"); + ("".into(), None, "".into(), None) + } + }; + + let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x)); + + let image_status = ImageStatus { + image: img_ref, + version, + timestamp, + image_digest, + architecture, + }; + + Some(image_status) + } + + // Wasn't booted using a container image. Do nothing + None => None, + }; + + let boot_type = match origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) { + Some(s) => BootType::try_from(s.as_str())?, + None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), + }; + + let boot_digest = origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST); + + let e = BootEntry { + image, + cached_update: None, + incompatible: false, + pinned: false, + store: None, + ostree: None, + composefs: Some(crate::spec::BootEntryComposefs { + verity, + boot_type, + bootloader: get_bootloader()?, + boot_digest, + }), + soft_reboot_capable: false, + }; + + Ok(e) +} + +/// Get composefs status using provided storage and booted composefs data +/// instead of scraping global state. +#[context("Getting composefs deployment status")] +pub(crate) async fn get_composefs_status( + storage: &crate::store::Storage, + booted_cfs: &crate::store::BootedComposefs, +) -> Result { + composefs_deployment_status_from(&storage.physical_root, booted_cfs.cmdline).await +} + +#[context("Getting composefs deployment status")] +pub(crate) async fn composefs_deployment_status_from( + sysroot: &Dir, + cmdline: &ComposefsCmdline, +) -> Result { + let composefs_digest = &cmdline.digest; + + let deployments = sysroot + .read_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; + + let host_spec = HostSpec { + image: None, + boot_order: BootOrder::Default, + }; + + let mut host = Host::new(host_spec); + + let staged_deployment_id = match std::fs::File::open(format!( + "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" + )) { + Ok(mut f) => { + let mut s = String::new(); + f.read_to_string(&mut s)?; + + Ok(Some(s)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + }?; + + // NOTE: This cannot work if we support both BLS and UKI at the same time + let mut boot_type: Option = None; + + for depl in deployments { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.to_string_lossy(); + + // read the origin file + let config = depl + .open_dir() + .with_context(|| format!("Failed to open {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .with_context(|| format!("Reading file {depl_file_name}.origin"))?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + let boot_entry = + boot_entry_from_composefs_deployment(ini, depl_file_name.to_string()).await?; + + // SAFETY: boot_entry.composefs will always be present + let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; + + match boot_type { + Some(current_type) => { + if current_type != boot_type_from_origin { + anyhow::bail!("Conflicting boot types") + } + } + + None => { + boot_type = Some(boot_type_from_origin); + } + }; + + if depl.file_name() == composefs_digest.as_ref() { + host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); + host.status.booted = Some(boot_entry); + continue; + } + + if let Some(staged_deployment_id) = &staged_deployment_id { + if depl_file_name == staged_deployment_id.trim() { + host.status.staged = Some(boot_entry); + continue; + } + } + + host.status.rollback = Some(boot_entry); + } + + // Shouldn't really happen, but for sanity nonetheless + let Some(boot_type) = boot_type else { + anyhow::bail!("Could not determine boot type"); + }; + + let booted = host.require_composefs_booted()?; + + let (boot_dir, _temp_guard) = match booted.bootloader { + Bootloader::Grub => (sysroot.open_dir("boot").context("Opening boot dir")?, None), + + // TODO: This is redundant as we should already have ESP mounted at `/efi/` accoding to + // spec; currently we do not + // + // See: https://uapi-group.org/specifications/specs/boot_loader_specification/#mount-points + Bootloader::Systemd => { + let parent = get_sysroot_parent_dev(sysroot)?; + let (esp_part, ..) = get_esp_partition(&parent)?; + + let esp_mount = mount_esp(&esp_part)?; + + let dir = esp_mount.fd.try_clone().context("Cloning fd")?; + let guard = Some(esp_mount); + + (dir, guard) + } + }; + + let is_rollback_queued = match booted.bootloader { + Bootloader::Grub => match boot_type { + BootType::Bls => { + let bls_config = get_sorted_type1_boot_entries(&boot_dir, false)?; + let bls_config = bls_config + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))?; + + match &bls_config.cfg_type { + BLSConfigType::NonEFI { options, .. } => !options + .as_ref() + .ok_or(anyhow::anyhow!("options key not found in bls config"))? + .contains(composefs_digest.as_ref()), + + BLSConfigType::EFI { .. } => { + anyhow::bail!("Found 'efi' field in Type1 boot entry") + } + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } + } + + BootType::Uki => { + let mut s = String::new(); + + !get_sorted_grub_uki_boot_entries(&boot_dir, &mut s)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .body + .chainloader + .contains(composefs_digest.as_ref()) + } + }, + + // We will have BLS stuff and the UKI stuff in the same DIR + Bootloader::Systemd => { + let bls_config = get_sorted_type1_boot_entries(&boot_dir, false)?; + let bls_config = bls_config + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))?; + + match &bls_config.cfg_type { + // For UKI boot + BLSConfigType::EFI { efi } => efi.as_str().contains(composefs_digest.as_ref()), + + // For boot entry Type1 + BLSConfigType::NonEFI { options, .. } => !options + .as_ref() + .ok_or(anyhow::anyhow!("options key not found in bls config"))? + .contains(composefs_digest.as_ref()), + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } + } + }; + + host.status.rollback_queued = is_rollback_queued; + + if host.status.rollback_queued { + host.spec.boot_order = BootOrder::Rollback + }; + + Ok(host) +} + +#[cfg(test)] +mod tests { + use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; + + use crate::parsers::{bls_config::BLSConfigType, grub_menuconfig::MenuentryBody}; + + use super::*; + + #[test] + fn test_composefs_parsing() { + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + let v = ComposefsCmdline::new(DIGEST); + assert!(!v.insecure); + assert_eq!(v.digest.as_ref(), DIGEST); + let v = ComposefsCmdline::new(&format!("?{}", DIGEST)); + assert!(v.insecure); + assert_eq!(v.digest.as_ref(), DIGEST); + } + + #[test] + fn test_sorted_bls_boot_entries() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let entry1 = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version fedora-42.0 + sort-key 1 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + "#; + + let entry2 = r#" + title Fedora 41.20250214.2.0 (CoreOS) + version fedora-42.0 + sort-key 2 + linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10 + initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01 + "#; + + tempdir.create_dir_all("loader/entries")?; + tempdir.atomic_write( + "loader/entries/random_file.txt", + "Random file that we won't parse", + )?; + tempdir.atomic_write("loader/entries/entry1.conf", entry1)?; + tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; + + let result = get_sorted_type1_boot_entries(&tempdir, true).unwrap(); + + let mut config1 = BLSConfig::default(); + config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into()); + config1.sort_key = Some("1".into()); + config1.cfg_type = BLSConfigType::NonEFI { + linux: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(), + initrd: vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()], + options: Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()), + }; + + let mut config2 = BLSConfig::default(); + config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into()); + config2.sort_key = Some("2".into()); + config2.cfg_type = BLSConfigType::NonEFI { + linux: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(), + initrd: vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()], + options: Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into()) + }; + + assert_eq!(result[0].sort_key.as_ref().unwrap(), "1"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "2"); + + let result = get_sorted_type1_boot_entries(&tempdir, false).unwrap(); + assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); + + Ok(()) + } + + #[test] + fn test_sorted_uki_boot_entries() -> Result<()> { + let user_cfg = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi + } + + menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + "#; + + let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + bootdir.create_dir_all(format!("grub2"))?; + bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?; + + let mut s = String::new(); + let result = get_sorted_grub_uki_boot_entries(&bootdir, &mut s)?; + + let expected = vec![ + MenuEntry { + title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + MenuEntry { + title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + ]; + + assert_eq!(result, expected); + + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs new file mode 100644 index 000000000..5cb1e2c1f --- /dev/null +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -0,0 +1,85 @@ +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use fn_error_context::context; + +use crate::{ + bootc_composefs::{ + boot::{setup_composefs_bls_boot, setup_composefs_uki_boot, BootSetupType, BootType}, + repo::pull_composefs_repo, + service::start_finalize_stated_svc, + state::write_composefs_state, + status::get_composefs_status, + }, + cli::{imgref_for_switch, SwitchOpts}, + store::{BootedComposefs, Storage}, +}; + +#[context("Composefs Switching")] +pub(crate) async fn switch_composefs( + opts: SwitchOpts, + storage: &Storage, + booted_cfs: &BootedComposefs, +) -> Result<()> { + let target = imgref_for_switch(&opts)?; + // TODO: Handle in-place + + let host = get_composefs_status(storage, booted_cfs) + .await + .context("Getting composefs deployment status")?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.image = Some(target.clone()); + new_spec + }; + + if new_spec == host.spec { + println!("Image specification is unchanged."); + return Ok(()); + } + + let Some(target_imgref) = new_spec.image else { + anyhow::bail!("Target image is undefined") + }; + + start_finalize_stated_svc()?; + + let (repo, entries, id, fs) = + pull_composefs_repo(&target_imgref.transport, &target_imgref.image).await?; + + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade((storage, &fs, &host)), + repo, + &id, + entry, + )?) + } + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Upgrade((storage, &fs, &host)), + repo, + &id, + entries, + )?, + }; + + // TODO: Remove this hardcoded path when write_composefs_state accepts a Dir + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + &target_imgref, + true, + boot_type, + boot_digest, + )?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs new file mode 100644 index 000000000..eebfd3faa --- /dev/null +++ b/crates/lib/src/bootc_composefs/update.rs @@ -0,0 +1,192 @@ +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use composefs::util::{parse_sha256, Sha256Digest}; +use fn_error_context::context; +use ostree_ext::oci_spec::image::{ImageConfiguration, ImageManifest}; + +use crate::{ + bootc_composefs::{ + boot::{setup_composefs_bls_boot, setup_composefs_uki_boot, BootSetupType, BootType}, + repo::{get_imgref, pull_composefs_repo}, + service::start_finalize_stated_svc, + state::write_composefs_state, + status::{get_composefs_status, get_container_manifest_and_config}, + }, + cli::UpgradeOpts, + spec::ImageReference, + store::{BootedComposefs, ComposefsRepository, Storage}, +}; + +#[context("Getting SHA256 Digest for {id}")] +pub fn str_to_sha256digest(id: &str) -> Result { + let id = id.strip_prefix("sha256:").unwrap_or(id); + Ok(parse_sha256(&id)?) +} + +/// Checks if a container image has been pulled to the local composefs repository. +/// +/// This function verifies whether the specified container image exists in the local +/// composefs repository by checking if the image's configuration digest stream is +/// available. It retrieves the image manifest and configuration from the container +/// registry and uses the configuration digest to perform the local availability check. +/// +/// # Arguments +/// +/// * `repo` - The composefs repository +/// * `imgref` - Reference to the container image to check +/// +/// # Returns +/// +/// Returns a tuple containing: +/// * `true` if the image is pulled/available locally, `false` otherwise +/// * The container image manifest +/// * The container image configuration +#[context("Checking if image {} is pulled", imgref.image)] +async fn is_image_pulled( + repo: &ComposefsRepository, + imgref: &ImageReference, +) -> Result<(bool, ImageManifest, ImageConfiguration)> { + let imgref_repr = get_imgref(&imgref.transport, &imgref.image); + let (manifest, config) = get_container_manifest_and_config(&imgref_repr).await?; + + let img_digest = manifest.config().digest().digest(); + let img_sha256 = str_to_sha256digest(&img_digest)?; + + // check_stream is expensive to run, but probably a good idea + let container_pulled = repo.check_stream(&img_sha256).context("Checking stream")?; + + Ok((container_pulled.is_some(), manifest, config)) +} + +#[context("Upgrading composefs")] +pub(crate) async fn upgrade_composefs( + opts: UpgradeOpts, + storage: &Storage, + composefs: &BootedComposefs, +) -> Result<()> { + let host = get_composefs_status(storage, composefs) + .await + .context("Getting composefs deployment status")?; + + let mut imgref = host + .spec + .image + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + + let repo = &*composefs.repo; + + let (img_pulled, mut manifest, mut config) = is_image_pulled(&repo, imgref).await?; + let booted_img_digest = manifest.config().digest().digest(); + + // We already have this container config. No update available + if img_pulled { + println!("No changes in: {imgref:#}"); + // TODO(Johan-Liebert1): What if we have the config but we failed the previous update in the middle? + return Ok(()); + } + + // Check if we already have this update staged + let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref()); + + if let Some(staged_image) = staged_image { + // We have a staged image and it has the same digest as the currently booted image's latest + // digest + if staged_image.image_digest == booted_img_digest { + if opts.apply { + return crate::reboot::reboot(); + } + + println!("Update already staged. To apply update run `bootc update --apply`"); + + return Ok(()); + } + + // We have a staged image but it's not the update image. + // Maybe it's something we got by `bootc switch` + // Switch takes precedence over update, so we change the imgref + imgref = &staged_image.image; + + let (img_pulled, staged_manifest, staged_cfg) = is_image_pulled(&repo, imgref).await?; + manifest = staged_manifest; + config = staged_cfg; + + // We already have this container config. No update available + if img_pulled { + println!("No changes in staged image: {imgref:#}"); + return Ok(()); + } + } + + if opts.check { + // TODO(Johan-Liebert1): If we have the previous, i.e. the current manifest with us then we can replace the + // following with [`ostree_container::ManifestDiff::new`] which will be much cleaner + for (idx, diff_id) in config.rootfs().diff_ids().iter().enumerate() { + let diff_id = str_to_sha256digest(diff_id)?; + + // we could use `check_stream` here but that will most probably take forever as it + // usually takes ~3s to verify one single layer + let have_layer = repo.has_stream(&diff_id)?; + + if have_layer.is_none() { + if idx >= manifest.layers().len() { + anyhow::bail!("Length mismatch between rootfs diff layers and manifest layers"); + } + + let layer = &manifest.layers()[idx]; + + println!( + "Added layer: {}\tSize: {}", + layer.digest(), + layer.size().to_string() + ); + } + } + + return Ok(()); + } + + start_finalize_stated_svc()?; + + let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; + + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade((storage, &fs, &host)), + repo, + &id, + entry, + )?) + } + + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Upgrade((storage, &fs, &host)), + repo, + &id, + entries, + )?, + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + imgref, + true, + boot_type, + boot_digest, + )?; + + if opts.apply { + return crate::reboot::reboot(); + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_kargs.rs b/crates/lib/src/bootc_kargs.rs new file mode 100644 index 000000000..af709f1af --- /dev/null +++ b/crates/lib/src/bootc_kargs.rs @@ -0,0 +1,402 @@ +//! This module handles the bootc-owned kernel argument lists in `/usr/lib/bootc/kargs.d`. +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned}; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::cap_std::fs_utf8::Dir as DirUtf8; +use cap_std_ext::dirext::CapStdExtDirExt; +use cap_std_ext::dirext::CapStdExtDirExtUtf8; +use ostree::gio; +use ostree_ext::ostree; +use ostree_ext::ostree::Deployment; +use ostree_ext::prelude::Cast; +use ostree_ext::prelude::FileEnumeratorExt; +use ostree_ext::prelude::FileExt; +use serde::Deserialize; + +use crate::deploy::ImageState; +use crate::store::Storage; + +/// The relative path to the kernel arguments which may be embedded in an image. +const KARGS_PATH: &str = "usr/lib/bootc/kargs.d"; + +/// The default root filesystem mount specification. +pub(crate) const ROOT_KEY: &str = "root"; +/// This is used by dracut. +pub(crate) const INITRD_ARG_PREFIX: &str = "rd."; +/// The kernel argument for configuring the rootfs flags. +pub(crate) const ROOTFLAGS_KEY: &str = "rootflags"; + +/// The kargs.d configuration file. +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct Config { + /// Ordered list of kernel arguments. + kargs: Vec, + /// Optional list of architectures (using the Rust naming conventions); + /// if present and the current architecture doesn't match, the file is skipped. + match_architectures: Option>, +} + +impl Config { + /// Return true if the filename is one we should parse. + fn filename_matches(name: &str) -> bool { + matches!(Utf8Path::new(name).extension(), Some("toml")) + } +} + +/// Load and parse all bootc kargs.d files in the specified root, returning +/// a combined list. +pub(crate) fn get_kargs_in_root(d: &Dir, sys_arch: &str) -> Result { + // If the directory doesn't exist, that's OK. + let Some(d) = d.open_dir_optional(KARGS_PATH)?.map(DirUtf8::from_cap_std) else { + return Ok(Default::default()); + }; + let mut ret = Cmdline::new(); + let entries = d.filenames_filtered_sorted(|_, name| Config::filename_matches(name))?; + for name in entries { + let buf = d.read_to_string(&name)?; + if let Some(kargs) = + parse_kargs_toml(&buf, sys_arch).with_context(|| format!("Parsing {name}"))? + { + ret.extend(&kargs) + } + } + Ok(ret) +} + +pub(crate) fn root_args_from_cmdline(cmdline: &Cmdline) -> CmdlineOwned { + let mut result = Cmdline::new(); + for param in cmdline { + let key = param.key(); + if key == ROOT_KEY.into() + || key == ROOTFLAGS_KEY.into() + || key.starts_with(INITRD_ARG_PREFIX) + { + result.add(¶m); + } + } + result +} + +/// Load kargs.d files from the target ostree commit root +pub(crate) fn get_kargs_from_ostree_root( + repo: &ostree::Repo, + root: &ostree::RepoFile, + sys_arch: &str, +) -> Result { + let kargsd = root.resolve_relative_path(KARGS_PATH); + let kargsd = kargsd.downcast_ref::().expect("downcast"); + if !kargsd.query_exists(gio::Cancellable::NONE) { + return Ok(Default::default()); + } + get_kargs_from_ostree(repo, kargsd, sys_arch) +} + +/// Load kargs.d files from the target dir +fn get_kargs_from_ostree( + repo: &ostree::Repo, + fetched_tree: &ostree::RepoFile, + sys_arch: &str, +) -> Result { + let cancellable = gio::Cancellable::NONE; + let queryattrs = "standard::name,standard::type"; + let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let fetched_iter = fetched_tree.enumerate_children(queryattrs, queryflags, cancellable)?; + let mut ret = Cmdline::new(); + while let Some(fetched_info) = fetched_iter.next_file(cancellable)? { + // only read and parse the file if it is a toml file + let name = fetched_info.name(); + let Some(name) = name.to_str() else { + continue; + }; + if !Config::filename_matches(name) { + continue; + } + + let fetched_child = fetched_iter.child(&fetched_info); + let fetched_child = fetched_child + .downcast::() + .expect("downcast"); + fetched_child.ensure_resolved()?; + let fetched_contents_checksum = fetched_child.checksum(); + let f = ostree::Repo::load_file(repo, fetched_contents_checksum.as_str(), cancellable)?; + let file_content = f.0; + let mut reader = + ostree_ext::prelude::InputStreamExtManual::into_read(file_content.unwrap()); + let s = std::io::read_to_string(&mut reader)?; + if let Some(parsed_kargs) = + parse_kargs_toml(&s, sys_arch).with_context(|| format!("Parsing {name}"))? + { + ret.extend(&parsed_kargs); + } + } + Ok(ret) +} + +/// Compute the kernel arguments for the new deployment. This starts from the booted +/// karg, but applies the diff between the bootc karg files in /usr/lib/bootc/kargs.d +/// between the booted deployment and the new one. +pub(crate) fn get_kargs( + sysroot: &Storage, + merge_deployment: &Deployment, + fetched: &ImageState, +) -> Result { + let cancellable = gio::Cancellable::NONE; + let ostree = sysroot.get_ostree()?; + let repo = &ostree.repo(); + let sys_arch = std::env::consts::ARCH; + + // Get the kargs used for the merge in the bootloader config + let mut kargs = ostree::Deployment::bootconfig(merge_deployment) + .and_then(|bootconfig| { + ostree::BootconfigParser::get(&bootconfig, "options") + .map(|options| Cmdline::from(options.to_string())) + }) + .unwrap_or_default(); + + // Get the kargs in kargs.d of the merge + let merge_root = &crate::utils::deployment_fd(ostree, merge_deployment)?; + let existing_kargs = get_kargs_in_root(merge_root, sys_arch)?; + + // Get the kargs in kargs.d of the pending image + let (fetched_tree, _) = repo.read_commit(fetched.ostree_commit.as_str(), cancellable)?; + let fetched_tree = fetched_tree.resolve_relative_path(KARGS_PATH); + let fetched_tree = fetched_tree + .downcast::() + .expect("downcast"); + // A special case: if there's no kargs.d directory in the pending (fetched) image, + // then we can just use the combined current kargs + kargs from booted + if !fetched_tree.query_exists(cancellable) { + kargs.extend(&existing_kargs); + return Ok(kargs); + } + + // Fetch the kernel arguments from the new root + let remote_kargs = get_kargs_from_ostree(repo, &fetched_tree, sys_arch)?; + + // Calculate the diff between the existing and remote kargs + let added_kargs: Vec<_> = remote_kargs + .iter() + .filter(|item| !existing_kargs.iter().any(|existing| *item == existing)) + .collect(); + let removed_kargs: Vec<_> = existing_kargs + .iter() + .filter(|item| !remote_kargs.iter().any(|remote| *item == remote)) + .collect(); + + tracing::debug!( + "kargs: added={:?} removed={:?}", + &added_kargs, + removed_kargs + ); + + // Apply the diff to the system kargs + for arg in &removed_kargs { + kargs.remove_exact(arg); + } + for arg in &added_kargs { + kargs.add(arg); + } + + Ok(kargs) +} + +/// This parses a bootc kargs.d toml file, returning the resulting +/// vector of kernel arguments. Architecture matching is performed using +/// `sys_arch`. +fn parse_kargs_toml(contents: &str, sys_arch: &str) -> Result> { + let de: Config = toml::from_str(contents)?; + // if arch specified, apply kargs only if the arch matches + // if arch not specified, apply kargs unconditionally + let matched = de + .match_architectures + .map(|arches| arches.iter().any(|s| s == sys_arch)) + .unwrap_or(true); + let r = if matched { + Some(Cmdline::from(de.kargs.join(" "))) + } else { + None + }; + Ok(r) +} + +#[cfg(test)] +mod tests { + use cap_std_ext::cap_std; + use fn_error_context::context; + use rustix::fd::{AsFd, AsRawFd}; + + use super::*; + + fn assert_cmdline_eq(cmdline: &Cmdline, expected_params: &[&str]) { + let actual_params: Vec<_> = cmdline.iter_str().collect(); + assert_eq!(actual_params, expected_params); + } + + #[test] + /// Verify that kargs are only applied to supported architectures + fn test_arch() { + // no arch specified, kargs ensure that kargs are applied unconditionally + let sys_arch = "x86_64"; + let file_content = r##"kargs = ["console=tty0", "nosmt"]"##.to_string(); + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap(); + assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]); + + let sys_arch = "aarch64"; + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap(); + assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]); + + // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch + let sys_arch = "aarch64"; + let file_content = r##"kargs = ["console=tty0", "nosmt"] +match-architectures = ["x86_64"] +"## + .to_string(); + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap(); + assert!(parsed_kargs.is_none()); + let file_content = r##"kargs = ["console=tty0", "nosmt"] +match-architectures = ["aarch64"] +"## + .to_string(); + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap(); + assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]); + + // multiple arch specified, ensure that kargs are applied to both archs + let sys_arch = "x86_64"; + let file_content = r##"kargs = ["console=tty0", "nosmt"] +match-architectures = ["x86_64", "aarch64"] +"## + .to_string(); + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap(); + assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]); + + let sys_arch = "aarch64"; + let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap(); + assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]); + } + + #[test] + /// Verify some error cases + fn test_invalid() { + let test_invalid_extra = r#"kargs = ["console=tty0", "nosmt"]\nfoo=bar"#; + assert!(parse_kargs_toml(test_invalid_extra, "x86_64").is_err()); + + let test_missing = r#"foo=bar"#; + assert!(parse_kargs_toml(test_missing, "x86_64").is_err()); + } + + #[context("writing test kargs")] + fn write_test_kargs(td: &Dir) -> Result<()> { + td.write( + "usr/lib/bootc/kargs.d/01-foo.toml", + r##"kargs = ["console=tty0", "nosmt"]"##, + )?; + td.write( + "usr/lib/bootc/kargs.d/02-bar.toml", + r##"kargs = ["console=ttyS1"]"##, + )?; + + Ok(()) + } + + #[test] + fn test_get_kargs_in_root() -> Result<()> { + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + // No directory + assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0); + // Empty directory + td.create_dir_all("usr/lib/bootc/kargs.d")?; + assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0); + // Non-toml file + td.write("usr/lib/bootc/kargs.d/somegarbage", "garbage")?; + assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0); + + write_test_kargs(&td)?; + + let args = get_kargs_in_root(&td, "x86_64").unwrap(); + assert_cmdline_eq(&args, &["console=tty0", "nosmt", "console=ttyS1"]); + + Ok(()) + } + + #[context("ostree commit")] + fn ostree_commit( + repo: &ostree::Repo, + d: &Dir, + path: &Utf8Path, + ostree_ref: &str, + ) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let txn = repo.auto_transaction(cancellable)?; + + let mt = ostree::MutableTree::new(); + let commitmod_flags = ostree::RepoCommitModifierFlags::SKIP_XATTRS; + let commitmod = ostree::RepoCommitModifier::new(commitmod_flags, None); + repo.write_dfd_to_mtree( + d.as_fd().as_raw_fd(), + path.as_str(), + &mt, + Some(&commitmod), + cancellable, + ) + .context("Writing merged filesystem to mtree")?; + + let merged_root = repo + .write_mtree(&mt, cancellable) + .context("Writing mtree")?; + let merged_root = merged_root.downcast::().unwrap(); + let merged_commit = repo + .write_commit(None, None, None, None, &merged_root, cancellable) + .context("Writing commit")?; + repo.transaction_set_ref(None, &ostree_ref, Some(merged_commit.as_str())); + txn.commit(cancellable)?; + Ok(()) + } + + #[test] + fn test_get_kargs_in_ostree() -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + td.create_dir("repo")?; + let repo = &ostree::Repo::create_at( + td.as_fd().as_raw_fd(), + "repo", + ostree::RepoMode::Bare, + None, + gio::Cancellable::NONE, + )?; + + td.create_dir("rootfs")?; + let test_rootfs = &td.open_dir("rootfs")?; + + ostree_commit(repo, &test_rootfs, ".".into(), "testref")?; + // Helper closure to read the kargs + let get_kargs = |sys_arch: &str| -> Result { + let rootfs = repo.read_commit("testref", cancellable)?.0; + let rootfs = rootfs.downcast_ref::().unwrap(); + let fetched_tree = rootfs.resolve_relative_path("/usr/lib/bootc/kargs.d"); + let fetched_tree = fetched_tree + .downcast::() + .expect("downcast"); + if !fetched_tree.query_exists(cancellable) { + return Ok(Default::default()); + } + get_kargs_from_ostree(repo, &fetched_tree, sys_arch) + }; + + // rootfs is empty + assert_eq!(get_kargs("x86_64").unwrap().iter().count(), 0); + + test_rootfs.create_dir_all("usr/lib/bootc/kargs.d")?; + write_test_kargs(&test_rootfs).unwrap(); + ostree_commit(repo, &test_rootfs, ".".into(), "testref")?; + + let args = get_kargs("x86_64").unwrap(); + assert_cmdline_eq(&args, &["console=tty0", "nosmt", "console=ttyS1"]); + + Ok(()) + } +} diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs new file mode 100644 index 000000000..6126cef9e --- /dev/null +++ b/crates/lib/src/bootloader.rs @@ -0,0 +1,169 @@ +use std::process::Command; + +use anyhow::{anyhow, bail, Context, Result}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use fn_error_context::context; + +use bootc_blockdev::{Partition, PartitionTable}; +use bootc_mount as mount; + +use crate::bootc_composefs::boot::mount_esp; +use crate::{discoverable_partition_specification, utils}; + +/// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) +pub(crate) const EFI_DIR: &str = "efi"; +/// The EFI system partition GUID +/// Path to the bootupd update payload +#[allow(dead_code)] +const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates"; + +#[allow(dead_code)] +pub(crate) fn esp_in(device: &PartitionTable) -> Result<&Partition> { + device + .find_partition_of_type(discoverable_partition_specification::ESP) + .ok_or(anyhow::anyhow!("ESP not found in partition table")) +} + +/// Determine if the invoking environment contains bootupd, and if there are bootupd-based +/// updates in the target root. +#[context("Querying for bootupd")] +pub(crate) fn supports_bootupd(root: &Dir) -> Result { + if !utils::have_executable("bootupctl")? { + tracing::trace!("No bootupctl binary found"); + return Ok(false); + }; + let r = root.try_exists(BOOTUPD_UPDATES)?; + tracing::trace!("bootupd updates: {r}"); + Ok(r) +} + +#[context("Installing bootloader")] +pub(crate) fn install_via_bootupd( + device: &PartitionTable, + rootfs: &Utf8Path, + configopts: &crate::install::InstallConfigOpts, + deployment_path: Option<&str>, +) -> Result<()> { + let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv"); + // bootc defaults to only targeting the platform boot method. + let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); + + let abs_deployment_path = deployment_path.map(|v| rootfs.join(v)); + let src_root_arg = if let Some(p) = abs_deployment_path.as_deref() { + vec!["--src-root", p.as_str()] + } else { + vec![] + }; + let devpath = device.path(); + println!("Installing bootloader via bootupd"); + Command::new("bootupctl") + .args(["backend", "install", "--write-uuid"]) + .args(verbose) + .args(bootupd_opts.iter().copied().flatten()) + .args(src_root_arg) + .args(["--device", devpath.as_str(), rootfs.as_str()]) + .log_debug() + .run_inherited_with_cmd_context() +} + +#[context("Installing bootloader")] +pub(crate) fn install_systemd_boot( + device: &PartitionTable, + _rootfs: &Utf8Path, + _configopts: &crate::install::InstallConfigOpts, + _deployment_path: Option<&str>, +) -> Result<()> { + let esp_part = device + .find_partition_of_type(discoverable_partition_specification::ESP) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + + let esp_mount = mount_esp(&esp_part.node).context("Mounting ESP")?; + let esp_path = Utf8Path::from_path(esp_mount.dir.path()) + .ok_or_else(|| anyhow::anyhow!("Failed to convert ESP mount path to UTF-8"))?; + + println!("Installing bootloader via systemd-boot"); + Command::new("bootctl") + .args(["install", "--esp-path", esp_path.as_str()]) + .log_debug() + .run_inherited_with_cmd_context() +} + +#[context("Installing bootloader using zipl")] +pub(crate) fn install_via_zipl(device: &PartitionTable, boot_uuid: &str) -> Result<()> { + // Identify the target boot partition from UUID + let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?; + let boot_dir = Utf8Path::new(&fs.target); + let maj_min = fs.maj_min; + + // Ensure that the found partition is a part of the target device + let device_path = device.path(); + + let partitions = bootc_blockdev::list_dev(device_path)? + .children + .with_context(|| format!("no partition found on {device_path}"))?; + let boot_part = partitions + .iter() + .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str())) + .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?; + let boot_part_offset = boot_part.start.unwrap_or(0); + + // Find exactly one BLS configuration under /boot/loader/entries + // TODO: utilize the BLS parser in ostree + let bls_dir = boot_dir.join("boot/loader/entries"); + let bls_entry = bls_dir + .read_dir_utf8()? + .try_fold(None, |acc, e| -> Result<_> { + let e = e?; + let name = Utf8Path::new(e.file_name()); + if let Some("conf") = name.extension() { + if acc.is_some() { + bail!("more than one BLS configurations under {bls_dir}"); + } + Ok(Some(e.path().to_owned())) + } else { + Ok(None) + } + })? + .with_context(|| format!("no BLS configuration under {bls_dir}"))?; + + let bls_path = bls_dir.join(bls_entry); + let bls_conf = + std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?; + + let mut kernel = None; + let mut initrd = None; + let mut options = None; + + for line in bls_conf.lines() { + match line.split_once(char::is_whitespace) { + Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')), + Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')), + Some(("options", val)) => options = Some(val.trim()), + _ => (), + } + } + + let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?; + let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?; + let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?; + + let image = boot_dir.join(kernel).canonicalize_utf8()?; + let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?; + + // Execute the zipl command to install bootloader + println!("Running zipl on {device_path}"); + Command::new("zipl") + .args(["--target", boot_dir.as_str()]) + .args(["--image", image.as_str()]) + .args(["--ramdisk", ramdisk.as_str()]) + .args(["--parameters", options]) + .args(["--targetbase", device_path.as_str()]) + .args(["--targettype", "SCSI"]) + .args(["--targetblocksize", "512"]) + .args(["--targetoffset", &boot_part_offset.to_string()]) + .args(["--add-files", "--verbose"]) + .log_debug() + .run_inherited_with_cmd_context() +} diff --git a/crates/lib/src/boundimage.rs b/crates/lib/src/boundimage.rs new file mode 100644 index 000000000..a515740bf --- /dev/null +++ b/crates/lib/src/boundimage.rs @@ -0,0 +1,408 @@ +//! # Implementation of "logically bound" container images +//! +//! This module implements the design in +//! for "logically bound" container images. These container images are +//! pre-pulled (and in the future, pinned) before a new image root +//! is considered ready. + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::containers_image_proxy; +use ostree_ext::ostree::Deployment; + +use crate::podstorage::{CStorage, PullMode}; +use crate::store::Storage; + +/// The path in a root for bound images; this directory should only contain +/// symbolic links to `.container` or `.image` files. +const BOUND_IMAGE_DIR: &str = "usr/lib/bootc/bound-images.d"; + +/// A subset of data parsed from a `.image` or `.container` file with +/// the minimal information necessary to fetch the image. +/// +/// In the future this may be extended to include e.g. certificates or +/// other pull options. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct BoundImage { + pub(crate) image: String, + pub(crate) auth_file: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ResolvedBoundImage { + pub(crate) image: String, + pub(crate) digest: String, +} + +/// Given a deployment, pull all container images it references. +pub(crate) async fn pull_bound_images(sysroot: &Storage, deployment: &Deployment) -> Result<()> { + // Log the bound images operation to systemd journal + const BOUND_IMAGES_JOURNAL_ID: &str = "1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5"; + tracing::info!( + message_id = BOUND_IMAGES_JOURNAL_ID, + bootc.deployment.osname = deployment.osname().as_str(), + bootc.deployment.checksum = deployment.csum().as_str(), + "Starting pull of bound images for deployment" + ); + + let ostree = sysroot.get_ostree()?; + let bound_images = query_bound_images_for_deployment(ostree, deployment)?; + tracing::info!( + message_id = BOUND_IMAGES_JOURNAL_ID, + bootc.bound_images_count = bound_images.len(), + "Found {} bound images to pull", + bound_images.len() + ); + pull_images(sysroot, bound_images).await +} + +#[context("Querying bound images")] +pub(crate) fn query_bound_images_for_deployment( + sysroot: &ostree_ext::ostree::Sysroot, + deployment: &Deployment, +) -> Result> { + let deployment_root = &crate::utils::deployment_fd(sysroot, deployment)?; + query_bound_images(deployment_root) +} + +#[context("Querying bound images")] +pub(crate) fn query_bound_images(root: &Dir) -> Result> { + let spec_dir = BOUND_IMAGE_DIR; + let Some(bound_images_dir) = root.open_dir_optional(spec_dir)? else { + tracing::debug!("Missing {spec_dir}"); + return Ok(Default::default()); + }; + // And open a view of the dir that uses RESOLVE_IN_ROOT so we + // handle absolute symlinks. + let absroot = &root.open_dir_rooted_ext(".")?; + + let mut bound_images = Vec::new(); + + for entry in bound_images_dir + .entries() + .context("Unable to read entries")? + { + //validate entry is a symlink with correct extension + let entry = entry?; + let file_name = entry.file_name(); + let file_name = if let Some(n) = file_name.to_str() { + n + } else { + anyhow::bail!("Invalid non-UTF8 filename: {file_name:?} in {}", spec_dir); + }; + + if !entry.file_type()?.is_symlink() { + anyhow::bail!("Not a symlink: {file_name}"); + } + + //parse the file contents + let path = Utf8Path::new(spec_dir).join(file_name); + let file_contents = absroot.read_to_string(&path)?; + + let file_ini = tini::Ini::from_string(&file_contents).context("Parse to ini")?; + let file_extension = Utf8Path::new(file_name).extension(); + let bound_image = match file_extension { + Some("image") => parse_image_file(&file_ini).with_context(|| format!("Parsing {path}")), + Some("container") => { + parse_container_file(&file_ini).with_context(|| format!("Parsing {path}")) + } + _ => anyhow::bail!("Invalid file extension: {file_name}"), + }?; + + bound_images.push(bound_image); + } + + Ok(bound_images) +} + +impl ResolvedBoundImage { + #[context("resolving bound image {}", src.image)] + pub(crate) async fn from_image(src: &BoundImage) -> Result { + let proxy = containers_image_proxy::ImageProxy::new().await?; + let img = proxy + .open_image(&format!("containers-storage:{}", src.image)) + .await?; + let digest = proxy.fetch_manifest(&img).await?.0; + Ok(Self { + image: src.image.clone(), + digest, + }) + } +} + +fn parse_image_file(file_contents: &tini::Ini) -> Result { + let image: String = file_contents + .get("Image", "Image") + .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?; + + //TODO: auth_files have some semi-complicated edge cases that we need to handle, + // so for now let's bail out if we see one since the existence of an authfile + // will most likely result in a failure to pull the image + let auth_file: Option = file_contents.get("Image", "AuthFile"); + if auth_file.is_some() { + anyhow::bail!("AuthFile is not supported by bound bootc images"); + } + + let bound_image = BoundImage::new(image.to_string(), None)?; + Ok(bound_image) +} + +fn parse_container_file(file_contents: &tini::Ini) -> Result { + let image: String = file_contents + .get("Container", "Image") + .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?; + + let bound_image = BoundImage::new(image.to_string(), None)?; + Ok(bound_image) +} + +#[context("Pulling bound images")] +pub(crate) async fn pull_images( + sysroot: &Storage, + bound_images: Vec, +) -> Result<()> { + // Always initialize the img store to ensure labels are set when upgrading + let imgstore = sysroot.get_ensure_imgstore()?; + if bound_images.is_empty() { + return Ok(()); + } + pull_images_impl(imgstore, bound_images).await +} + +#[context("Pulling bound images")] +pub(crate) async fn pull_images_impl( + imgstore: &CStorage, + bound_images: Vec, +) -> Result<()> { + let n = bound_images.len(); + tracing::debug!("Pulling bound images: {n}"); + // TODO: do this in parallel + for bound_image in bound_images { + let image = &bound_image.image; + if imgstore.exists(image).await? { + tracing::debug!("Bound image already present: {image}"); + continue; + } + let desc = format!("Fetching bound image: {image}"); + crate::utils::async_task_with_spinner(&desc, async move { + imgstore + .pull(&bound_image.image, PullMode::IfNotExists) + .await + }) + .await?; + } + + println!("Bound images stored: {n}"); + + Ok(()) +} + +impl BoundImage { + fn new(image: String, auth_file: Option) -> Result { + let image = parse_spec_value(&image).context("Invalid image value")?; + + let auth_file = if let Some(auth_file) = &auth_file { + Some(parse_spec_value(auth_file).context("Invalid auth_file value")?) + } else { + None + }; + + Ok(BoundImage { image, auth_file }) + } +} + +/// Given a string, parse it in a way similar to how systemd would do it. +/// The primary thing here is that we reject any "specifiers" such as `%a` +/// etc. We do allow a quoted `%%` to appear in the string, which will +/// result in a single unquoted `%`. +fn parse_spec_value(value: &str) -> Result { + let mut it = value.chars(); + let mut ret = String::new(); + while let Some(c) = it.next() { + if c != '%' { + ret.push(c); + continue; + } + let c = it.next().ok_or_else(|| anyhow::anyhow!("Unterminated %"))?; + match c { + '%' => { + ret.push('%'); + } + _ => { + anyhow::bail!("Systemd specifiers are not supported by bound bootc images: {value}") + } + } + } + Ok(ret) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std; + + #[test] + fn test_parse_spec_dir() -> Result<()> { + const CONTAINER_IMAGE_DIR: &str = "usr/share/containers/systemd"; + + // Empty dir should return an empty vector + let td = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let images = query_bound_images(td).unwrap(); + assert_eq!(images.len(), 0); + + td.create_dir_all(BOUND_IMAGE_DIR).unwrap(); + td.create_dir_all(CONTAINER_IMAGE_DIR).unwrap(); + let images = query_bound_images(td).unwrap(); + assert_eq!(images.len(), 0); + + // Should return BoundImages + td.write( + format!("{CONTAINER_IMAGE_DIR}/foo.image"), + indoc::indoc! { r#" + [Image] + Image=quay.io/foo/foo:latest + "# }, + ) + .unwrap(); + td.symlink_contents( + format!("/{CONTAINER_IMAGE_DIR}/foo.image"), + format!("{BOUND_IMAGE_DIR}/foo.image"), + ) + .unwrap(); + + td.write( + format!("{CONTAINER_IMAGE_DIR}/bar.image"), + indoc::indoc! { r#" + [Image] + Image=quay.io/bar/bar:latest + "# }, + ) + .unwrap(); + td.symlink_contents( + format!("/{CONTAINER_IMAGE_DIR}/bar.image"), + format!("{BOUND_IMAGE_DIR}/bar.image"), + ) + .unwrap(); + + let mut images = query_bound_images(td).unwrap(); + images.sort_by(|a, b| a.image.as_str().cmp(&b.image.as_str())); + assert_eq!(images.len(), 2); + assert_eq!(images[0].image, "quay.io/bar/bar:latest"); + assert_eq!(images[1].image, "quay.io/foo/foo:latest"); + + // Invalid symlink should return an error + td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image")) + .unwrap(); + assert!(query_bound_images(td).is_err()); + + // Invalid image contents should return an error + td.write("error.image", "[Image]\n").unwrap(); + td.symlink_contents("/error.image", format!("{BOUND_IMAGE_DIR}/error.image")) + .unwrap(); + assert!(query_bound_images(td).is_err()); + + Ok(()) + } + + #[test] + fn test_parse_spec_value() -> Result<()> { + //should parse string with no % characters + let value = String::from("quay.io/foo/foo:latest"); + assert_eq!(parse_spec_value(&value).unwrap(), value); + + //should parse string with % followed by another % + let value = String::from("quay.io/foo/%%foo:latest"); + assert_eq!(parse_spec_value(&value).unwrap(), "quay.io/foo/%foo:latest"); + + //should parse string with multiple separate %% + let value = String::from("quay.io/foo/%%foo:%%latest"); + assert_eq!( + parse_spec_value(&value).unwrap(), + "quay.io/foo/%foo:%latest" + ); + + //should parse the string with %% at the start or end + let value = String::from("%%quay.io/foo/foo:latest%%"); + assert_eq!( + parse_spec_value(&value).unwrap(), + "%quay.io/foo/foo:latest%" + ); + + //should not return an error with multiple %% in a row + let value = String::from("quay.io/foo/%%%%foo:latest"); + assert_eq!( + parse_spec_value(&value).unwrap(), + "quay.io/foo/%%foo:latest" + ); + + //should return error when % is NOT followed by another % + let value = String::from("quay.io/foo/%foo:latest"); + assert!(parse_spec_value(&value).is_err()); + + //should return an error when %% is followed by a specifier + let value = String::from("quay.io/foo/%%%foo:latest"); + assert!(parse_spec_value(&value).is_err()); + + //should return an error when there are two specifiers + let value = String::from("quay.io/foo/%f%ooo:latest"); + assert!(parse_spec_value(&value).is_err()); + + //should return an error with a specifier at the start + let value = String::from("%fquay.io/foo/foo:latest"); + assert!(parse_spec_value(&value).is_err()); + + //should return an error with a specifier at the end + let value = String::from("quay.io/foo/foo:latest%f"); + assert!(parse_spec_value(&value).is_err()); + + //should return an error with a single % at the end + let value = String::from("quay.io/foo/foo:latest%"); + assert!(parse_spec_value(&value).is_err()); + + Ok(()) + } + + #[test] + fn test_parse_image_file() -> Result<()> { + //should return BoundImage when no auth_file is present + let file_contents = + tini::Ini::from_string("[Image]\nImage=quay.io/foo/foo:latest").unwrap(); + let bound_image = parse_image_file(&file_contents).unwrap(); + assert_eq!(bound_image.image, "quay.io/foo/foo:latest"); + assert_eq!(bound_image.auth_file, None); + + //should error when auth_file is present + let file_contents = tini::Ini::from_string(indoc::indoc! { " + [Image] + Image=quay.io/foo/foo:latest + AuthFile=/etc/containers/auth.json + " }) + .unwrap(); + assert!(parse_image_file(&file_contents).is_err()); + + //should return error when missing image field + let file_contents = tini::Ini::from_string("[Image]\n").unwrap(); + assert!(parse_image_file(&file_contents).is_err()); + + Ok(()) + } + + #[test] + fn test_parse_container_file() -> Result<()> { + //should return BoundImage + let file_contents = + tini::Ini::from_string("[Container]\nImage=quay.io/foo/foo:latest").unwrap(); + let bound_image = parse_container_file(&file_contents).unwrap(); + assert_eq!(bound_image.image, "quay.io/foo/foo:latest"); + assert_eq!(bound_image.auth_file, None); + + //should return error when missing image field + let file_contents = tini::Ini::from_string("[Container]\n").unwrap(); + assert!(parse_container_file(&file_contents).is_err()); + + Ok(()) + } +} diff --git a/crates/lib/src/cfsctl.rs b/crates/lib/src/cfsctl.rs new file mode 100644 index 000000000..63a005d58 --- /dev/null +++ b/crates/lib/src/cfsctl.rs @@ -0,0 +1,372 @@ +use std::{ + ffi::OsString, + fs::{create_dir_all, File}, + io::BufWriter, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use clap::{Parser, Subcommand}; + +use rustix::fs::CWD; + +use composefs_boot::{write_boot, BootOps}; + +use composefs::{ + dumpfile, + fsverity::{FsVerityHashValue, Sha512HashValue}, + repository::Repository, +}; + +/// cfsctl +#[derive(Debug, Parser)] +#[clap(name = "cfsctl", version)] +pub struct App { + #[clap(long, group = "repopath")] + repo: Option, + #[clap(long, group = "repopath")] + user: bool, + #[clap(long, group = "repopath")] + system: bool, + + /// Sets the repository to insecure before running any operation and + /// prepend '?' to the composefs kernel command line when writing + /// boot entry. + #[clap(long)] + insecure: bool, + + #[clap(subcommand)] + cmd: Command, +} + +#[derive(Debug, Subcommand)] +enum OciCommand { + /// Stores a tar file as a splitstream in the repository. + ImportLayer { + sha256: String, + name: Option, + }, + /// Lists the contents of a tar stream + LsLayer { + /// the name of the stream + name: String, + }, + Dump { + config_name: String, + config_verity: Option, + }, + Pull { + image: String, + name: Option, + }, + ComputeId { + config_name: String, + config_verity: Option, + #[clap(long)] + bootable: bool, + }, + CreateImage { + config_name: String, + config_verity: Option, + #[clap(long)] + bootable: bool, + #[clap(long)] + image_name: Option, + }, + Seal { + config_name: String, + config_verity: Option, + }, + Mount { + name: String, + mountpoint: String, + }, + PrepareBoot { + config_name: String, + config_verity: Option, + #[clap(long, default_value = "/boot")] + bootdir: PathBuf, + #[clap(long)] + entry_id: Option, + #[clap(long)] + cmdline: Vec, + }, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Take a transaction lock on the repository. + /// This prevents garbage collection from occurring. + Transaction, + /// Reconstitutes a split stream and writes it to stdout + Cat { + /// the name of the stream to cat, either a sha256 digest or prefixed with 'ref/' + name: String, + }, + /// Perform garbage collection + GC, + /// Imports a composefs image (unsafe!) + ImportImage { + reference: String, + }, + /// Commands for dealing with OCI layers + Oci { + #[clap(subcommand)] + cmd: OciCommand, + }, + /// Mounts a composefs, possibly enforcing fsverity of the image + Mount { + /// the name of the image to mount, either a sha256 digest or prefixed with 'ref/' + name: String, + /// the mountpoint + mountpoint: String, + }, + CreateImage { + path: PathBuf, + #[clap(long)] + bootable: bool, + #[clap(long)] + stat_root: bool, + image_name: Option, + }, + ComputeId { + path: PathBuf, + /// Write the dumpfile to the provided target + #[clap(long)] + write_dumpfile_to: Option, + #[clap(long)] + bootable: bool, + #[clap(long)] + stat_root: bool, + }, + CreateDumpfile { + path: PathBuf, + #[clap(long)] + bootable: bool, + #[clap(long)] + stat_root: bool, + }, + ImageObjects { + name: String, + }, +} + +fn verity_opt(opt: &Option) -> Result> { + Ok(opt.as_ref().map(FsVerityHashValue::from_hex).transpose()?) +} + +pub(crate) async fn run_from_iter(args: I) -> Result<()> +where + I: IntoIterator, + I::Item: Into + Clone, +{ + let args = App::parse_from( + std::iter::once(OsString::from("cfs")).chain(args.into_iter().map(Into::into)), + ); + + let repo = if let Some(path) = &args.repo { + let mut r = Repository::open_path(CWD, path)?; + r.set_insecure(args.insecure); + Arc::new(r) + } else if args.user { + let mut r = Repository::open_user()?; + r.set_insecure(args.insecure); + Arc::new(r) + } else { + if args.insecure { + anyhow::bail!("Cannot override insecure state for system repo"); + } + let system_store = crate::cli::get_storage().await?; + system_store.get_ensure_composefs()? + }; + let repo = &repo; + + match args.cmd { + Command::Transaction => { + // just wait for ^C + loop { + std::thread::park(); + } + } + Command::Cat { name } => { + repo.merge_splitstream(&name, None, &mut std::io::stdout())?; + } + Command::ImportImage { reference } => { + let image_id = repo.import_image(&reference, &mut std::io::stdin())?; + println!("{}", image_id.to_id()); + } + Command::Oci { cmd: oci_cmd } => match oci_cmd { + OciCommand::ImportLayer { name, sha256 } => { + let object_id = composefs_oci::import_layer( + &repo, + &composefs::util::parse_sha256(sha256)?, + name.as_deref(), + &mut std::io::stdin(), + )?; + println!("{}", object_id.to_id()); + } + OciCommand::LsLayer { name } => { + composefs_oci::ls_layer(&repo, &name)?; + } + OciCommand::Dump { + ref config_name, + ref config_verity, + } => { + let verity = verity_opt(config_verity)?; + let mut fs = + composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?; + fs.print_dumpfile()?; + } + OciCommand::ComputeId { + ref config_name, + ref config_verity, + bootable, + } => { + let verity = verity_opt(config_verity)?; + let mut fs = + composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?; + if bootable { + fs.transform_for_boot(&repo)?; + } + let id = fs.compute_image_id(); + println!("{}", id.to_hex()); + } + OciCommand::CreateImage { + ref config_name, + ref config_verity, + bootable, + ref image_name, + } => { + let verity = verity_opt(config_verity)?; + let mut fs = + composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?; + if bootable { + fs.transform_for_boot(&repo)?; + } + let image_id = fs.commit_image(&repo, image_name.as_deref())?; + println!("{}", image_id.to_id()); + } + OciCommand::Pull { ref image, name } => { + let (sha256, verity) = + composefs_oci::pull(&repo, image, name.as_deref(), None).await?; + + println!("sha256 {}", hex::encode(sha256)); + println!("verity {}", verity.to_hex()); + } + OciCommand::Seal { + ref config_name, + ref config_verity, + } => { + let verity = verity_opt(config_verity)?; + let (sha256, verity) = composefs_oci::seal(&repo, config_name, verity.as_ref())?; + println!("sha256 {}", hex::encode(sha256)); + println!("verity {}", verity.to_id()); + } + OciCommand::Mount { + ref name, + ref mountpoint, + } => { + composefs_oci::mount(&repo, name, mountpoint, None)?; + } + OciCommand::PrepareBoot { + ref config_name, + ref config_verity, + ref bootdir, + ref entry_id, + ref cmdline, + } => { + let verity = verity_opt(config_verity)?; + let mut fs = + composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?; + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect(); + write_boot::write_boot_simple( + &repo, + entry, + &id, + args.insecure, + bootdir, + None, + entry_id.as_deref(), + &cmdline_refs, + )?; + + let state = args + .repo + .as_ref() + .map(|p: &PathBuf| p.parent().unwrap_or(p)) + .unwrap_or(Path::new("/sysroot")) + .join("state/deploy") + .join(id.to_hex()); + + create_dir_all(state.join("var"))?; + create_dir_all(state.join("etc/upper"))?; + create_dir_all(state.join("etc/work"))?; + } + }, + Command::ComputeId { + ref path, + write_dumpfile_to, + bootable, + stat_root, + } => { + let mut fs = composefs::fs::read_filesystem(CWD, path, Some(&repo), stat_root)?; + if bootable { + fs.transform_for_boot(&repo)?; + } + let id = fs.compute_image_id(); + println!("{}", id.to_hex()); + if let Some(path) = write_dumpfile_to.as_deref() { + let mut w = File::create(path) + .with_context(|| format!("Opening {path}")) + .map(BufWriter::new)?; + dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?; + } + } + Command::CreateImage { + ref path, + bootable, + stat_root, + ref image_name, + } => { + let mut fs = composefs::fs::read_filesystem(CWD, path, Some(&repo), stat_root)?; + if bootable { + fs.transform_for_boot(&repo)?; + } + let id = fs.commit_image(&repo, image_name.as_deref())?; + println!("{}", id.to_id()); + } + Command::CreateDumpfile { + ref path, + bootable, + stat_root, + } => { + let mut fs = composefs::fs::read_filesystem(CWD, path, Some(&repo), stat_root)?; + if bootable { + fs.transform_for_boot(&repo)?; + } + fs.print_dumpfile()?; + } + Command::Mount { name, mountpoint } => { + repo.mount_at(&name, &mountpoint)?; + } + Command::ImageObjects { name } => { + let objects = repo.objects_for_image(&name)?; + for object in objects { + println!("{}", object.to_id()); + } + } + Command::GC => { + repo.gc()?; + } + } + Ok(()) +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs new file mode 100644 index 000000000..23bae71d9 --- /dev/null +++ b/crates/lib/src/cli.rs @@ -0,0 +1,1828 @@ +//! # Bootable container image CLI +//! +//! Command line tool to manage bootable ostree-based containers. + +use std::ffi::{CString, OsStr, OsString}; +use std::fs::File; +use std::io::{BufWriter, Seek}; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::sync::Arc; + +use anyhow::{anyhow, ensure, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::Dir; +use clap::Parser; +use clap::ValueEnum; +use composefs::dumpfile; +use composefs_boot::BootOps as _; +use etc_merge::{compute_diff, print_diff}; +use fn_error_context::context; +use indoc::indoc; +use ostree::gio; +use ostree_container::store::PrepareResult; +use ostree_ext::composefs::fsverity; +use ostree_ext::composefs::fsverity::FsVerityHashValue; +use ostree_ext::composefs::splitstream::SplitStreamWriter; +use ostree_ext::container as ostree_container; +use ostree_ext::containers_image_proxy::ImageProxyConfig; +use ostree_ext::keyfileext::KeyFileExt; +use ostree_ext::ostree; +use ostree_ext::sysroot::SysrootLock; +use schemars::schema_for; +use serde::{Deserialize, Serialize}; +use tempfile::tempdir_in; + +use crate::bootc_composefs::delete::delete_composefs_deployment; +use crate::bootc_composefs::{ + finalize::{composefs_backend_finalize, get_etc_diff}, + rollback::composefs_rollback, + state::composefs_usr_overlay, + switch::switch_composefs, + update::upgrade_composefs, +}; +use crate::deploy::{MergeState, RequiredHostSpec}; +use crate::lints; +use crate::podstorage::set_additional_image_store; +use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; +use crate::spec::Host; +use crate::spec::ImageReference; +use crate::store::{BootedOstree, ComposefsRepository, Storage}; +use crate::store::{BootedStorage, BootedStorageKind}; +use crate::utils::sigpolicy_from_opt; + +/// Shared progress options +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct ProgressOptions { + /// File descriptor number which must refer to an open pipe. + /// + /// Progress is written as JSON lines to this file descriptor. + #[clap(long, hide = true)] + pub(crate) progress_fd: Option, +} + +impl TryFrom for ProgressWriter { + type Error = anyhow::Error; + + fn try_from(value: ProgressOptions) -> Result { + let r = value + .progress_fd + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + Ok(r) + } +} + +/// Perform an upgrade operation +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct UpgradeOpts { + /// Don't display progress + #[clap(long)] + pub(crate) quiet: bool, + + /// Check if an update is available without applying it. + /// + /// This only downloads updated metadata, not the full image layers. + #[clap(long, conflicts_with = "apply")] + pub(crate) check: bool, + + /// Restart or reboot into the new target image. + /// + /// Currently, this always reboots. Future versions may support userspace-only restart. + #[clap(long, conflicts_with = "check")] + pub(crate) apply: bool, + + /// Configure soft reboot behavior. + /// + /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot. + #[clap(long = "soft-reboot", conflicts_with = "check")] + pub(crate) soft_reboot: Option, + + #[clap(flatten)] + pub(crate) progress: ProgressOptions, +} + +/// Perform an switch operation +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct SwitchOpts { + /// Don't display progress + #[clap(long)] + pub(crate) quiet: bool, + + /// Restart or reboot into the new target image. + /// + /// Currently, this always reboots. Future versions may support userspace-only restart. + #[clap(long)] + pub(crate) apply: bool, + + /// Configure soft reboot behavior. + /// + /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot. + #[clap(long = "soft-reboot")] + pub(crate) soft_reboot: Option, + + /// The transport; e.g. registry, oci, oci-archive, docker-daemon, containers-storage. Defaults to `registry`. + #[clap(long, default_value = "registry")] + pub(crate) transport: String, + + /// This argument is deprecated and does nothing. + #[clap(long, hide = true)] + pub(crate) no_signature_verification: bool, + + /// This is the inverse of the previous `--target-no-signature-verification` (which is now + /// a no-op). + /// + /// Enabling this option enforces that `/etc/containers/policy.json` includes a + /// default policy which requires signatures. + #[clap(long)] + pub(crate) enforce_container_sigpolicy: bool, + + /// Don't create a new deployment, but directly mutate the booted state. + /// This is hidden because it's not something we generally expect to be done, + /// but this can be used in e.g. Anaconda %post to fixup + #[clap(long, hide = true)] + pub(crate) mutate_in_place: bool, + + /// Retain reference to currently booted image + #[clap(long)] + pub(crate) retain: bool, + + /// Target image to use for the next boot. + pub(crate) target: String, + + #[clap(flatten)] + pub(crate) progress: ProgressOptions, +} + +/// Options controlling rollback +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct RollbackOpts { + /// Restart or reboot into the rollback image. + /// + /// Currently, this option always reboots. In the future this command + /// will detect the case where no kernel changes are queued, and perform + /// a userspace-only restart. + #[clap(long)] + pub(crate) apply: bool, + + /// Configure soft reboot behavior. + /// + /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot. + #[clap(long = "soft-reboot")] + pub(crate) soft_reboot: Option, +} + +/// Perform an edit operation +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct EditOpts { + /// Use filename to edit system specification + #[clap(long, short = 'f')] + pub(crate) filename: Option, + + /// Don't display progress + #[clap(long)] + pub(crate) quiet: bool, +} + +#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] +#[clap(rename_all = "lowercase")] +pub(crate) enum OutputFormat { + /// Output in Human Readable format. + HumanReadable, + /// Output in YAML format. + Yaml, + /// Output in JSON format. + Json, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +#[clap(rename_all = "lowercase")] +pub(crate) enum SoftRebootMode { + /// Require a soft reboot; fail if not possible + Required, + /// Automatically use soft reboot if possible, otherwise use regular reboot + Auto, +} + +/// Perform an status operation +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct StatusOpts { + /// Output in JSON format. + /// + /// Superceded by the `format` option. + #[clap(long, hide = true)] + pub(crate) json: bool, + + /// The output format. + #[clap(long)] + pub(crate) format: Option, + + /// The desired format version. There is currently one supported + /// version, which is exposed as both `0` and `1`. Pass this + /// option to explicitly request it; it is possible that another future + /// version 2 or newer will be supported in the future. + #[clap(long)] + pub(crate) format_version: Option, + + /// Only display status for the booted deployment. + #[clap(long)] + pub(crate) booted: bool, + + /// Include additional fields in human readable format. + #[clap(long, short = 'v')] + pub(crate) verbose: bool, +} + +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum InstallOpts { + /// Install to the target block device. + /// + /// This command must be invoked inside of the container, which will be + /// installed. The container must be run in `--privileged` mode, and hence + /// will be able to see all block devices on the system. + /// + /// The default storage layout uses the root filesystem type configured + /// in the container image, alongside any required system partitions such as + /// the EFI system partition. Use `install to-filesystem` for anything more + /// complex such as RAID, LVM, LUKS etc. + #[cfg(feature = "install-to-disk")] + ToDisk(crate::install::InstallToDiskOpts), + /// Install to an externally created filesystem structure. + /// + /// In this variant of installation, the root filesystem alongside any necessary + /// platform partitions (such as the EFI system partition) are prepared and mounted by an + /// external tool or script. The root filesystem is currently expected to be empty + /// by default. + ToFilesystem(crate::install::InstallToFilesystemOpts), + /// Install to the host root filesystem. + /// + /// This is a variant of `install to-filesystem` that is designed to install "alongside" + /// the running host root filesystem. Currently, the host root filesystem's `/boot` partition + /// will be wiped, but the content of the existing root will otherwise be retained, and will + /// need to be cleaned up if desired when rebooted into the new root. + ToExistingRoot(crate::install::InstallToExistingRootOpts), + /// Nondestructively create a fresh installation state inside an existing bootc system. + /// + /// This is a nondestructive variant of `install to-existing-root` that works only inside + /// an existing bootc system. + #[clap(hide = true)] + Reset(crate::install::InstallResetOpts), + /// Execute this as the penultimate step of an installation using `install to-filesystem`. + /// + Finalize { + /// Path to the mounted root filesystem. + root_path: Utf8PathBuf, + }, + /// Intended for use in environments that are performing an ostree-based installation, not bootc. + /// + /// In this scenario the installation may be missing bootc specific features such as + /// kernel arguments, logically bound images and more. This command can be used to attempt + /// to reconcile. At the current time, the only tested environment is Anaconda using `ostreecontainer` + /// and it is recommended to avoid usage outside of that environment. Instead, ensure your + /// code is using `bootc install to-filesystem` from the start. + EnsureCompletion {}, + /// Output JSON to stdout that contains the merged installation configuration + /// as it may be relevant to calling processes using `install to-filesystem` + /// that in particular want to discover the desired root filesystem type from the container image. + /// + /// At the current time, the only output key is `root-fs-type` which is a string-valued + /// filesystem name suitable for passing to `mkfs.$type`. + PrintConfiguration, +} + +/// Subcommands which can be executed as part of a container build. +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum ContainerOpts { + /// Perform relatively inexpensive static analysis checks as part of a container + /// build. + /// + /// This is intended to be invoked via e.g. `RUN bootc container lint` as part + /// of a build process; it will error if any problems are detected. + Lint { + /// Operate on the provided rootfs. + #[clap(long, default_value = "/")] + rootfs: Utf8PathBuf, + + /// Make warnings fatal. + #[clap(long)] + fatal_warnings: bool, + + /// Instead of executing the lints, just print all available lints. + /// At the current time, this will output in YAML format because it's + /// reasonably human friendly. However, there is no commitment to + /// maintaining this exact format; do not parse it via code or scripts. + #[clap(long)] + list: bool, + + /// Skip checking the targeted lints, by name. Use `--list` to discover the set + /// of available lints. + /// + /// Example: --skip nonempty-boot --skip baseimage-root + #[clap(long)] + skip: Vec, + + /// Don't truncate the output. By default, only a limited number of entries are + /// shown for each lint, followed by a count of remaining entries. + #[clap(long)] + no_truncate: bool, + }, + /// Output the bootable composefs digest. + #[clap(hide = true)] + ComputeComposefsDigest { + /// Additionally generate a dumpfile written to the target path + #[clap(long)] + write_dumpfile_to: Option, + + /// Identifier for image; if not provided, the running image will be used. + image: Option, + }, +} + +/// Subcommands which operate on images. +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum ImageCmdOpts { + /// Wrapper for `podman image list` in bootc storage. + List { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Wrapper for `podman image build` in bootc storage. + Build { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Wrapper for `podman image pull` in bootc storage. + Pull { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Wrapper for `podman image push` in bootc storage. + Push { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, +} + +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImageListType { + /// List all images + #[default] + All, + /// List only logically bound images + Logical, + /// List only host images + Host, +} + +impl std::fmt::Display for ImageListType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImageListFormat { + /// Human readable table format + #[default] + Table, + /// JSON format + Json, +} +impl std::fmt::Display for ImageListFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +/// Subcommands which operate on images. +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum ImageOpts { + /// List fetched images stored in the bootc storage. + /// + /// Note that these are distinct from images stored via e.g. `podman`. + List { + /// Type of image to list + #[clap(long = "type")] + #[arg(default_value_t)] + list_type: ImageListType, + #[clap(long = "format")] + #[arg(default_value_t)] + list_format: ImageListFormat, + }, + /// Copy a container image from the bootc storage to `containers-storage:`. + /// + /// The source and target are both optional; if both are left unspecified, + /// via a simple invocation of `bootc image copy-to-storage`, then the default is to + /// push the currently booted image to `containers-storage` (as used by podman, etc.) + /// and tagged with the image name `localhost/bootc`, + /// + /// ## Copying a non-default container image + /// + /// It is also possible to copy an image other than the currently booted one by + /// specifying `--source`. + /// + /// ## Pulling images + /// + /// At the current time there is no explicit support for pulling images other than indirectly + /// via e.g. `bootc switch` or `bootc upgrade`. + CopyToStorage { + #[clap(long)] + /// The source image; if not specified, the booted image will be used. + source: Option, + + #[clap(long)] + /// The destination; if not specified, then the default is to push to `containers-storage:localhost/bootc`; + /// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds. + target: Option, + }, + /// Copy a container image from the default `containers-storage:` to the bootc-owned container storage. + PullFromDefaultStorage { + /// The image to pull + image: String, + }, + /// Wrapper for selected `podman image` subcommands in bootc storage. + #[clap(subcommand)] + Cmd(ImageCmdOpts), +} + +#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)] +pub(crate) enum SchemaType { + Host, + Progress, +} + +/// Options for consistency checking +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum FsverityOpts { + /// Measure the fsverity digest of the target file. + Measure { + /// Path to file + path: Utf8PathBuf, + }, + /// Enable fsverity on the target file. + Enable { + /// Ptah to file + path: Utf8PathBuf, + }, +} + +/// Hidden, internal only options +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum InternalsOpts { + SystemdGenerator { + normal_dir: Utf8PathBuf, + #[allow(dead_code)] + early_dir: Option, + #[allow(dead_code)] + late_dir: Option, + }, + FixupEtcFstab, + /// Should only be used by `make update-generated` + PrintJsonSchema { + #[clap(long)] + of: SchemaType, + }, + #[clap(subcommand)] + Fsverity(FsverityOpts), + /// Perform consistency checking. + Fsck, + /// Perform cleanup actions + Cleanup, + Relabel { + #[clap(long)] + /// Relabel using this path as root + as_path: Option, + + /// Relabel this path + path: Utf8PathBuf, + }, + /// Proxy frontend for the `ostree-ext` CLI. + OstreeExt { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Proxy frontend for the `cfsctl` CLI + Cfs { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Proxy frontend for the legacy `ostree container` CLI. + OstreeContainer { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + /// Ensure that a composefs repository is initialized + TestComposefs, + /// Loopback device cleanup helper (internal use only) + LoopbackCleanupHelper { + /// Device path to clean up + #[clap(long)] + device: String, + }, + /// Test loopback device allocation and cleanup (internal use only) + AllocateCleanupLoopback { + /// File path to create loopback device for + #[clap(long)] + file_path: Utf8PathBuf, + }, + /// Invoked from ostree-ext to complete an installation. + BootcInstallCompletion { + /// Path to the sysroot + sysroot: Utf8PathBuf, + + // The stateroot + stateroot: String, + }, + /// Initiate a reboot the same way we would after --apply; intended + /// primarily for testing. + Reboot, + #[cfg(feature = "rhsm")] + /// Publish subscription-manager facts to /etc/rhsm/facts/bootc.facts + PublishRhsmFacts, + /// Internal command for testing etc-diff/etc-merge + DirDiff { + /// Directory path to the pristine_etc + pristine_etc: Utf8PathBuf, + /// Directory path to the current_etc + current_etc: Utf8PathBuf, + /// Directory path to the new_etc + new_etc: Utf8PathBuf, + /// Whether to perform the three way merge or not + #[clap(long)] + merge: bool, + }, + #[cfg(feature = "docgen")] + /// Dump CLI structure as JSON for documentation generation + DumpCliJson, +} + +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum StateOpts { + /// Remove all ostree deployments from this system + WipeOstree, +} + +impl InternalsOpts { + /// The name of the binary we inject into /usr/lib/systemd/system-generators + const GENERATOR_BIN: &'static str = "bootc-systemd-generator"; +} + +/// Deploy and transactionally in-place with bootable container images. +/// +/// The `bootc` project currently uses ostree-containers as a backend +/// to support a model of bootable container images. Once installed, +/// whether directly via `bootc install` (executed as part of a container) +/// or via another mechanism such as an OS installer tool, further +/// updates can be pulled and `bootc upgrade`. +#[derive(Debug, Parser, PartialEq, Eq)] +#[clap(name = "bootc")] +#[clap(rename_all = "kebab-case")] +#[clap(version,long_version=clap::crate_version!())] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Opt { + /// Download and queue an updated container image to apply. + /// + /// This does not affect the running system; updates operate in an "A/B" style by default. + /// + /// A queued update is visible as `staged` in `bootc status`. + /// + /// Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`. + /// There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting) + /// if the system has changed. + /// + /// However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply` + /// do *not* automatically apply the update in addition. + #[clap(alias = "update")] + Upgrade(UpgradeOpts), + /// Target a new container image reference to boot. + /// + /// This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference + /// instead. + /// + /// ## Usage + /// + /// A common pattern is to have a management agent control operating system updates via container image tags; + /// for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines + /// are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`. + Switch(SwitchOpts), + /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot, + /// and the current will become rollback. If there is a `staged` entry (an unapplied, queued upgrade) + /// then it will be discarded. + /// + /// Note that absent any additional control logic, if there is an active agent doing automated upgrades + /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the + /// change here may be reverted. It's recommended to only use this in concert with an agent that + /// is in active control. + /// + /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in + /// order to detect a rollback invocation. + #[command(after_help = indoc! {r#" + Note on Rollbacks and the `/etc` Directory: + + When you perform a rollback (e.g., with `bootc rollback`), any + changes made to files in the `/etc` directory won't carry over + to the rolled-back deployment. The `/etc` files will revert + to their state from that previous deployment instead. + + This is because `bootc rollback` just reorders the existing + deployments. It doesn't create new deployments. The `/etc` + merges happen when new deployments are created. + "#})] + Rollback(RollbackOpts), + /// Apply full changes to the host specification. + /// + /// This command operates very similarly to `kubectl apply`; if invoked interactively, + /// then the current host specification will be presented in the system default `$EDITOR` + /// for interactive changes. + /// + /// It is also possible to directly provide new contents via `bootc edit --filename`. + /// + /// Only changes to the `spec` section are honored. + Edit(EditOpts), + /// Display status. + /// + /// Shows bootc system state. Outputs YAML by default, human-readable if terminal detected. + Status(StatusOpts), + /// Add a transient writable overlayfs on `/usr`. + /// + /// Allows temporary package installation that will be discarded on reboot. + #[clap(alias = "usroverlay")] + UsrOverlay, + /// Install the running container to a target. + /// + /// Takes a container image and installs it to disk in a bootable format. + #[clap(subcommand)] + Install(InstallOpts), + /// Operations which can be executed as part of a container build. + #[clap(subcommand)] + Container(ContainerOpts), + /// Operations on container images. + /// + /// Stability: This interface may change in the future. + #[clap(subcommand, hide = true)] + Image(ImageOpts), + /// Execute the given command in the host mount namespace + #[clap(hide = true)] + ExecInHostMountNamespace { + #[clap(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Modify the state of the system + #[clap(hide = true)] + #[clap(subcommand)] + State(StateOpts), + #[clap(subcommand)] + #[clap(hide = true)] + Internals(InternalsOpts), + ComposefsFinalizeStaged, + /// Diff current /etc configuration versus default + #[clap(hide = true)] + ConfigDiff, + #[clap(hide = true)] + DeleteDeployment { + depl_id: String, + }, +} + +/// Ensure we've entered a mount namespace, so that we can remount +/// `/sysroot` read-write +/// TODO use https://github.com/ostreedev/ostree/pull/2779 once +/// we can depend on a new enough ostree +#[context("Ensuring mountns")] +pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> { + let uid = rustix::process::getuid(); + if !uid.is_root() { + tracing::debug!("Not root, assuming no need to unshare"); + return Ok(()); + } + let recurse_env = "_ostree_unshared"; + let ns_pid1 = std::fs::read_link("/proc/1/ns/mnt").context("Reading /proc/1/ns/mnt")?; + let ns_self = std::fs::read_link("/proc/self/ns/mnt").context("Reading /proc/self/ns/mnt")?; + // If we already appear to be in a mount namespace, or we're already pid1, we're done + if ns_pid1 != ns_self { + tracing::debug!("Already in a mount namespace"); + return Ok(()); + } + if std::env::var_os(recurse_env).is_some() { + let am_pid1 = rustix::process::getpid().is_init(); + if am_pid1 { + tracing::debug!("We are pid 1"); + return Ok(()); + } else { + anyhow::bail!("Failed to unshare mount namespace"); + } + } + bootc_utils::reexec::reexec_with_guardenv(recurse_env, &["unshare", "-m", "--"]) +} + +/// Load global storage state, expecting that we're booted into a bootc system. +/// This prepares the process for write operations (re-exec, mount namespace, etc). +#[context("Initializing storage")] +pub(crate) async fn get_storage() -> Result { + let env = crate::store::Environment::detect()?; + // Always call prepare_for_write() for write operations - it checks + // for container, root privileges, mount namespace setup, etc. + prepare_for_write()?; + let r = BootedStorage::new(env) + .await? + .ok_or_else(|| anyhow!("System not booted via bootc"))?; + Ok(r) +} + +#[context("Querying root privilege")] +pub(crate) fn require_root(is_container: bool) -> Result<()> { + ensure!( + rustix::process::getuid().is_root(), + if is_container { + "The user inside the container from which you are running this command must be root" + } else { + "This command must be executed as the root user" + } + ); + + ensure!( + rustix::thread::capability_is_in_bounding_set(rustix::thread::CapabilitySet::SYS_ADMIN)?, + if is_container { + "The container must be executed with full privileges (e.g. --privileged flag)" + } else { + "This command requires full root privileges (CAP_SYS_ADMIN)" + } + ); + + tracing::trace!("Verified uid 0 with CAP_SYS_ADMIN"); + + Ok(()) +} + +/// Check if a deployment has soft reboot capability +fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool { + deployment.map(|d| d.soft_reboot_capable).unwrap_or(false) +} + +/// Prepare a soft reboot for the given deployment +#[context("Preparing soft reboot")] +fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> Result<()> { + let cancellable = ostree::gio::Cancellable::NONE; + sysroot + .deployment_set_soft_reboot(deployment, false, cancellable) + .context("Failed to prepare soft-reboot")?; + Ok(()) +} + +/// Handle soft reboot based on the configured mode +#[context("Handling soft reboot")] +fn handle_soft_reboot( + soft_reboot_mode: Option, + entry: Option<&crate::spec::BootEntry>, + deployment_type: &str, + execute_soft_reboot: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + let Some(mode) = soft_reboot_mode else { + return Ok(()); + }; + + let can_soft_reboot = has_soft_reboot_capability(entry); + match mode { + SoftRebootMode::Required => { + if can_soft_reboot { + execute_soft_reboot()?; + } else { + anyhow::bail!( + "Soft reboot was required but {} deployment is not soft-reboot capable", + deployment_type + ); + } + } + SoftRebootMode::Auto => { + if can_soft_reboot { + execute_soft_reboot()?; + } + } + } + Ok(()) +} + +/// Handle soft reboot for staged deployments (used by upgrade and switch) +#[context("Handling staged soft reboot")] +fn handle_staged_soft_reboot( + booted_ostree: &BootedOstree<'_>, + soft_reboot_mode: Option, + host: &crate::spec::Host, +) -> Result<()> { + handle_soft_reboot( + soft_reboot_mode, + host.status.staged.as_ref(), + "staged", + || soft_reboot_staged(booted_ostree.sysroot), + ) +} + +/// Perform a soft reboot for a staged deployment +#[context("Soft reboot staged deployment")] +fn soft_reboot_staged(sysroot: &SysrootLock) -> Result<()> { + println!("Staged deployment is soft-reboot capable, preparing for soft-reboot..."); + + let deployments_list = sysroot.deployments(); + let staged_deployment = deployments_list + .iter() + .find(|d| d.is_staged()) + .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?; + + prepare_soft_reboot(sysroot, staged_deployment)?; + Ok(()) +} + +/// Perform a soft reboot for a rollback deployment +#[context("Soft reboot rollback deployment")] +fn soft_reboot_rollback(booted_ostree: &BootedOstree<'_>) -> Result<()> { + println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot..."); + + let deployments_list = booted_ostree.sysroot.deployments(); + let target_deployment = deployments_list + .first() + .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?; + + prepare_soft_reboot(booted_ostree.sysroot, target_deployment) +} + +/// A few process changes that need to be made for writing. +/// IMPORTANT: This may end up re-executing the current process, +/// so anything that happens before this should be idempotent. +#[context("Preparing for write")] +pub(crate) fn prepare_for_write() -> Result<()> { + use std::sync::atomic::{AtomicBool, Ordering}; + + // This is intending to give "at most once" semantics to this + // function. We should never invoke this from multiple threads + // at the same time, but verifying "on main thread" is messy. + // Yes, using SeqCst is likely overkill, but there is nothing perf + // sensitive about this. + static ENTERED: AtomicBool = AtomicBool::new(false); + if ENTERED.load(Ordering::SeqCst) { + return Ok(()); + } + if ostree_ext::container_utils::running_in_container() { + anyhow::bail!("Detected container; this command requires a booted host system."); + } + crate::cli::require_root(false)?; + ensure_self_unshared_mount_namespace()?; + if crate::lsm::selinux_enabled()? && !crate::lsm::selinux_ensure_install()? { + tracing::debug!("Do not have install_t capabilities"); + } + ENTERED.store(true, Ordering::SeqCst); + Ok(()) +} + +/// Implementation of the `bootc upgrade` CLI command. +#[context("Upgrading")] +async fn upgrade( + opts: UpgradeOpts, + storage: &Storage, + booted_ostree: &BootedOstree<'_>, +) -> Result<()> { + let repo = &booted_ostree.repo(); + + let host = crate::status::get_status(booted_ostree)?.1; + let imgref = host.spec.image.as_ref(); + let prog: ProgressWriter = opts.progress.try_into()?; + + // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree + if imgref.is_none() { + let booted_incompatible = host.status.booted.as_ref().is_some_and(|b| b.incompatible); + + let staged_incompatible = host.status.staged.as_ref().is_some_and(|b| b.incompatible); + + if booted_incompatible || staged_incompatible { + return Err(anyhow::anyhow!( + "Deployment contains local rpm-ostree modifications; cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the modifications." + )); + } + } + + let spec = RequiredHostSpec::from_spec(&host.spec)?; + let booted_image = host + .status + .booted + .as_ref() + .map(|b| b.query_image(repo)) + .transpose()? + .flatten(); + let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + // Find the currently queued digest, if any before we pull + let staged = host.status.staged.as_ref(); + let staged_image = staged.as_ref().and_then(|s| s.image.as_ref()); + let mut changed = false; + if opts.check { + let imgref = imgref.clone().into(); + let mut imp = crate::deploy::new_importer(repo, &imgref).await?; + match imp.prepare().await? { + PrepareResult::AlreadyPresent(_) => { + println!("No changes in: {imgref:#}"); + } + PrepareResult::Ready(r) => { + crate::deploy::check_bootc_label(&r.config); + println!("Update available for: {imgref:#}"); + if let Some(version) = r.version() { + println!(" Version: {version}"); + } + println!(" Digest: {}", r.manifest_digest); + changed = true; + if let Some(previous_image) = booted_image.as_ref() { + let diff = + ostree_container::ManifestDiff::new(&previous_image.manifest, &r.manifest); + diff.print(); + } + } + } + } else { + let fetched = crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?; + let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status")); + let fetched_digest = &fetched.manifest_digest; + tracing::debug!("staged: {staged_digest:?}"); + tracing::debug!("fetched: {fetched_digest}"); + let staged_unchanged = staged_digest + .as_ref() + .map(|d| d == fetched_digest) + .unwrap_or_default(); + let booted_unchanged = booted_image + .as_ref() + .map(|img| &img.manifest_digest == fetched_digest) + .unwrap_or_default(); + if staged_unchanged { + println!("Staged update present, not changed."); + handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?; + if opts.apply { + crate::reboot::reboot()?; + } + } else if booted_unchanged { + println!("No update available.") + } else { + let stateroot = booted_ostree.stateroot(); + let from = MergeState::from_stateroot(storage, &stateroot)?; + crate::deploy::stage(storage, from, &fetched, &spec, prog.clone()).await?; + changed = true; + if let Some(prev) = booted_image.as_ref() { + if let Some(fetched_manifest) = fetched.get_manifest(repo)? { + let diff = + ostree_container::ManifestDiff::new(&prev.manifest, &fetched_manifest); + diff.print(); + } + } + } + } + if changed { + storage.update_mtime()?; + + if opts.soft_reboot.is_some() { + // At this point we have new staged deployment and the host definition has changed. + // We need the updated host status before we check if we can prepare the soft-reboot. + let updated_host = crate::status::get_status(booted_ostree)?.1; + handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?; + } + + if opts.apply { + crate::reboot::reboot()?; + } + } else { + tracing::debug!("No changes"); + } + + Ok(()) +} + +pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { + let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; + let imgref = ostree_container::ImageReference { + transport, + name: opts.target.to_string(), + }; + let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); + let target = ostree_container::OstreeImageReference { sigverify, imgref }; + let target = ImageReference::from(target); + + return Ok(target); +} + +/// Implementation of the `bootc switch` CLI command for ostree backend. +#[context("Switching (ostree)")] +async fn switch_ostree( + opts: SwitchOpts, + storage: &Storage, + booted_ostree: &BootedOstree<'_>, +) -> Result<()> { + let target = imgref_for_switch(&opts)?; + let prog: ProgressWriter = opts.progress.try_into()?; + let cancellable = gio::Cancellable::NONE; + + let repo = &booted_ostree.repo(); + let (_, host) = crate::status::get_status(booted_ostree)?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.image = Some(target.clone()); + new_spec + }; + + if new_spec == host.spec { + println!("Image specification is unchanged."); + return Ok(()); + } + + // Log the switch operation to systemd journal + const SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1"; + let old_image = host + .spec + .image + .as_ref() + .map(|i| i.image.as_str()) + .unwrap_or("none"); + + tracing::info!( + message_id = SWITCH_JOURNAL_ID, + bootc.old_image_reference = old_image, + bootc.new_image_reference = &target.image, + bootc.new_image_transport = &target.transport, + "Switching from image {} to {}", + old_image, + target.image + ); + + let new_spec = RequiredHostSpec::from_spec(&new_spec)?; + + let fetched = crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?; + + if !opts.retain { + // By default, we prune the previous ostree ref so it will go away after later upgrades + if let Some(booted_origin) = booted_ostree.deployment.origin() { + if let Some(ostree_ref) = booted_origin.optional_string("origin", "refspec")? { + let (remote, ostree_ref) = + ostree::parse_refspec(&ostree_ref).context("Failed to parse ostree ref")?; + repo.set_ref_immediate(remote.as_deref(), &ostree_ref, None, cancellable)?; + } + } + } + + let stateroot = booted_ostree.stateroot(); + let from = MergeState::from_stateroot(storage, &stateroot)?; + crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?; + + storage.update_mtime()?; + + if opts.soft_reboot.is_some() { + // At this point we have staged the deployment and the host definition has changed. + // We need the updated host status before we check if we can prepare the soft-reboot. + let updated_host = crate::status::get_status(booted_ostree)?.1; + handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?; + } + + if opts.apply { + crate::reboot::reboot()?; + } + + Ok(()) +} + +/// Implementation of the `bootc switch` CLI command. +#[context("Switching")] +async fn switch(opts: SwitchOpts) -> Result<()> { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(booted_ostree) => { + // If we're doing an in-place mutation, we shortcut most of the rest of the work here + if opts.mutate_in_place { + let target = imgref_for_switch(&opts)?; + let deployid = { + // Clone to pass into helper thread + let target = target.clone(); + let root = + cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + tokio::task::spawn_blocking(move || { + crate::deploy::switch_origin_inplace(&root, &target) + }) + .await?? + }; + println!("Updated {deployid} to pull from {target}"); + return Ok(()); + } + switch_ostree(opts, storage, &booted_ostree).await + } + BootedStorageKind::Composefs(booted_cfs) => { + if opts.mutate_in_place { + anyhow::bail!("--mutate-in-place is not yet supported for composefs backend"); + } + switch_composefs(opts, storage, &booted_cfs).await + } + } +} + +/// Implementation of the `bootc rollback` CLI command for ostree backend. +#[context("Rollback (ostree)")] +async fn rollback_ostree( + opts: &RollbackOpts, + storage: &Storage, + booted_ostree: &BootedOstree<'_>, +) -> Result<()> { + crate::deploy::rollback(storage).await?; + + if opts.soft_reboot.is_some() { + // Get status of rollback deployment to check soft-reboot capability + let host = crate::status::get_status(booted_ostree)?.1; + + handle_soft_reboot( + opts.soft_reboot, + host.status.rollback.as_ref(), + "rollback", + || soft_reboot_rollback(booted_ostree), + )?; + } + + Ok(()) +} + +/// Implementation of the `bootc rollback` CLI command. +#[context("Rollback")] +async fn rollback(opts: &RollbackOpts) -> Result<()> { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(booted_ostree) => { + rollback_ostree(opts, storage, &booted_ostree).await + } + BootedStorageKind::Composefs(booted_cfs) => composefs_rollback(storage, &booted_cfs).await, + } +} + +/// Implementation of the `bootc edit` CLI command for ostree backend. +#[context("Editing spec (ostree)")] +async fn edit_ostree( + opts: EditOpts, + storage: &Storage, + booted_ostree: &BootedOstree<'_>, +) -> Result<()> { + let repo = &booted_ostree.repo(); + let (_, host) = crate::status::get_status(booted_ostree)?; + + let new_host: Host = if let Some(filename) = opts.filename { + let mut r = std::io::BufReader::new(std::fs::File::open(filename)?); + serde_yaml::from_reader(&mut r)? + } else { + let tmpf = tempfile::NamedTempFile::new()?; + serde_yaml::to_writer(std::io::BufWriter::new(tmpf.as_file()), &host)?; + crate::utils::spawn_editor(&tmpf)?; + tmpf.as_file().seek(std::io::SeekFrom::Start(0))?; + serde_yaml::from_reader(&mut tmpf.as_file())? + }; + + if new_host.spec == host.spec { + println!("Edit cancelled, no changes made."); + return Ok(()); + } + host.spec.verify_transition(&new_host.spec)?; + let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?; + + let prog = ProgressWriter::default(); + + // We only support two state transitions right now; switching the image, + // or flipping the bootloader ordering. + if host.spec.boot_order != new_host.spec.boot_order { + return crate::deploy::rollback(storage).await; + } + + let fetched = crate::deploy::pull(repo, new_spec.image, None, opts.quiet, prog.clone()).await?; + + // TODO gc old layers here + + let stateroot = booted_ostree.stateroot(); + let from = MergeState::from_stateroot(storage, &stateroot)?; + crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?; + + storage.update_mtime()?; + + Ok(()) +} + +/// Implementation of the `bootc edit` CLI command. +#[context("Editing spec")] +async fn edit(opts: EditOpts) -> Result<()> { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(booted_ostree) => { + edit_ostree(opts, storage, &booted_ostree).await + } + BootedStorageKind::Composefs(_) => { + anyhow::bail!("Edit is not yet supported for composefs backend") + } + } +} + +/// Implementation of `bootc usroverlay` +async fn usroverlay() -> Result<()> { + // This is just a pass-through today. At some point we may make this a libostree API + // or even oxidize it. + Err(Command::new("ostree") + .args(["admin", "unlock"]) + .exec() + .into()) +} + +/// Perform process global initialization. This should be called as early as possible +/// in the standard `main` function. +pub fn global_init() -> Result<()> { + // In some cases we re-exec with a temporary binary, + // so ensure that the syslog identifier is set. + ostree::glib::set_prgname(bootc_utils::NAME.into()); + if let Err(e) = rustix::thread::set_name(&CString::new(bootc_utils::NAME).unwrap()) { + // This shouldn't ever happen + eprintln!("failed to set name: {e}"); + } + // Silence SELinux log warnings + ostree::SePolicy::set_null_log(); + let am_root = rustix::process::getuid().is_root(); + // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common) + // bombing out if it is unset. + if std::env::var_os("HOME").is_none() && am_root { + // Setting the environment is thread-unsafe, but we ask calling code + // to invoke this as early as possible. (In practice, that's just the cli's `main.rs`) + // xref https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475 + std::env::set_var("HOME", "/root"); + } + Ok(()) +} + +/// Parse the provided arguments and execute. +/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program. +pub async fn run_from_iter(args: I) -> Result<()> +where + I: IntoIterator, + I::Item: Into + Clone, +{ + run_from_opt(Opt::parse_including_static(args)).await +} + +/// Find the base binary name from argv0 (without a full path). The empty string +/// is never returned; instead a fallback string is used. If the input is not valid +/// UTF-8, a default is used. +fn callname_from_argv0(argv0: &OsStr) -> &str { + let default = "bootc"; + std::path::Path::new(argv0) + .file_name() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(default) +} + +impl Opt { + /// In some cases (e.g. systemd generator) we dispatch specifically on argv0. This + /// requires some special handling in clap. + fn parse_including_static(args: I) -> Self + where + I: IntoIterator, + I::Item: Into + Clone, + { + let mut args = args.into_iter(); + let first = if let Some(first) = args.next() { + let first: OsString = first.into(); + let argv0 = callname_from_argv0(&first); + tracing::debug!("argv0={argv0:?}"); + let mapped = match argv0 { + InternalsOpts::GENERATOR_BIN => { + Some(["bootc", "internals", "systemd-generator"].as_slice()) + } + "ostree-container" | "ostree-ima-sign" | "ostree-provisional-repair" => { + Some(["bootc", "internals", "ostree-ext"].as_slice()) + } + _ => None, + }; + if let Some(base_args) = mapped { + let base_args = base_args.iter().map(OsString::from); + return Opt::parse_from(base_args.chain(args.map(|i| i.into()))); + } + Some(first) + } else { + None + }; + Opt::parse_from(first.into_iter().chain(args.map(|i| i.into()))) + } +} + +/// Internal (non-generic/monomorphized) primary CLI entrypoint +async fn run_from_opt(opt: Opt) -> Result<()> { + let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + match opt { + Opt::Upgrade(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(booted_ostree) => { + upgrade(opts, storage, &booted_ostree).await + } + BootedStorageKind::Composefs(booted_cfs) => { + upgrade_composefs(opts, storage, &booted_cfs).await + } + } + } + Opt::Switch(opts) => switch(opts).await, + Opt::Rollback(opts) => { + rollback(&opts).await?; + if opts.apply { + crate::reboot::reboot()?; + } + Ok(()) + } + Opt::Edit(opts) => edit(opts).await, + Opt::UsrOverlay => { + use crate::store::Environment; + let env = Environment::detect()?; + match env { + Environment::OstreeBooted => usroverlay().await, + Environment::ComposefsBooted(_) => composefs_usr_overlay(), + _ => anyhow::bail!("usroverlay only applies on booted hosts"), + } + } + Opt::Container(opts) => match opts { + ContainerOpts::Lint { + rootfs, + fatal_warnings, + list, + skip, + no_truncate, + } => { + if list { + return lints::lint_list(std::io::stdout().lock()); + } + let warnings = if fatal_warnings { + lints::WarningDisposition::FatalWarnings + } else { + lints::WarningDisposition::AllowWarnings + }; + let root_type = if rootfs == "/" { + lints::RootType::Running + } else { + lints::RootType::Alternative + }; + + let root = &Dir::open_ambient_dir(rootfs, cap_std::ambient_authority())?; + let skip = skip.iter().map(|s| s.as_str()); + lints::lint( + root, + warnings, + root_type, + skip, + std::io::stdout().lock(), + no_truncate, + )?; + Ok(()) + } + ContainerOpts::ComputeComposefsDigest { + write_dumpfile_to, + image, + } => { + // Allocate a tempdir + let td = tempdir_in("/var/tmp")?; + let td = td.path(); + let td = &Dir::open_ambient_dir(td, cap_std::ambient_authority())?; + + td.create_dir("repo")?; + let repo = td.open_dir("repo")?; + let mut repo = + ComposefsRepository::open_path(&repo, ".").context("Init cfs repo")?; + // We don't need to hard require verity on the *host* system, we're just computing a checksum here + repo.set_insecure(true); + let repo = &Arc::new(repo); + + let mut proxycfg = ImageProxyConfig::default(); + + let image = if let Some(image) = image { + image + } else { + let host_container_store = Utf8Path::new("/run/host-container-storage"); + // If no image is provided, assume that we're running in a container in privileged mode + // with access to the container storage. + let container_info = crate::containerenv::get_container_execution_info(&root)?; + let iid = container_info.imageid; + tracing::debug!("Computing digest of {iid}"); + + if !host_container_store.try_exists()? { + anyhow::bail!("Must be readonly mount of host container store: {host_container_store}"); + } + // And ensure we're finding the image in the host storage + let mut cmd = Command::new("skopeo"); + set_additional_image_store(&mut cmd, "/run/host-container-storage"); + proxycfg.skopeo_cmd = Some(cmd); + iid + }; + + let imgref = format!("containers-storage:{image}"); + let (imgid, verity) = composefs_oci::pull(repo, &imgref, None, Some(proxycfg)) + .await + .context("Pulling image")?; + let imgid = hex::encode(imgid); + let mut fs = composefs_oci::image::create_filesystem(repo, &imgid, Some(&verity)) + .context("Populating fs")?; + fs.transform_for_boot(&repo).context("Preparing for boot")?; + let id = fs.compute_image_id(); + println!("{}", id.to_hex()); + + if let Some(path) = write_dumpfile_to.as_deref() { + let mut w = File::create(path) + .with_context(|| format!("Opening {path}")) + .map(BufWriter::new)?; + dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?; + } + + Ok(()) + } + }, + Opt::Image(opts) => match opts { + ImageOpts::List { + list_type, + list_format, + } => crate::image::list_entrypoint(list_type, list_format).await, + ImageOpts::CopyToStorage { source, target } => { + crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await + } + ImageOpts::PullFromDefaultStorage { image } => { + let storage = get_storage().await?; + storage + .get_ensure_imgstore()? + .pull_from_host_storage(&image) + .await + } + ImageOpts::Cmd(opt) => { + let storage = get_storage().await?; + let imgstore = storage.get_ensure_imgstore()?; + match opt { + ImageCmdOpts::List { args } => { + crate::image::imgcmd_entrypoint(imgstore, "list", &args).await + } + ImageCmdOpts::Build { args } => { + crate::image::imgcmd_entrypoint(imgstore, "build", &args).await + } + ImageCmdOpts::Pull { args } => { + crate::image::imgcmd_entrypoint(imgstore, "pull", &args).await + } + ImageCmdOpts::Push { args } => { + crate::image::imgcmd_entrypoint(imgstore, "push", &args).await + } + } + } + }, + Opt::Install(opts) => match opts { + #[cfg(feature = "install-to-disk")] + InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await, + InstallOpts::ToFilesystem(opts) => { + crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip) + .await + } + InstallOpts::ToExistingRoot(opts) => { + crate::install::install_to_existing_root(opts).await + } + InstallOpts::Reset(opts) => crate::install::install_reset(opts).await, + InstallOpts::PrintConfiguration => crate::install::print_configuration(), + InstallOpts::EnsureCompletion {} => { + let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + crate::install::completion::run_from_anaconda(rootfs).await + } + InstallOpts::Finalize { root_path } => { + crate::install::install_finalize(&root_path).await + } + }, + Opt::ExecInHostMountNamespace { args } => { + crate::install::exec_in_host_mountns(args.as_slice()) + } + Opt::Status(opts) => super::status::status(opts).await, + Opt::Internals(opts) => match opts { + InternalsOpts::SystemdGenerator { + normal_dir, + early_dir: _, + late_dir: _, + } => { + let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?; + crate::generator::generator(root, unit_dir) + } + InternalsOpts::OstreeExt { args } => { + ostree_ext::cli::run_from_iter(["ostree-ext".into()].into_iter().chain(args)).await + } + InternalsOpts::OstreeContainer { args } => { + ostree_ext::cli::run_from_iter( + ["ostree-ext".into(), "container".into()] + .into_iter() + .chain(args), + ) + .await + } + InternalsOpts::TestComposefs => { + // This is a stub to be replaced + let storage = get_storage().await?; + let cfs = storage.get_ensure_composefs()?; + let testdata = b"some test data"; + let testdata_digest = openssl::sha::sha256(testdata); + let mut w = SplitStreamWriter::new(&cfs, None, Some(testdata_digest)); + w.write_inline(testdata); + let object = cfs.write_stream(w, Some("testobject"))?.to_hex(); + assert_eq!(object, "5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07"); + Ok(()) + } + // We don't depend on fsverity-utils today, so re-expose some helpful CLI tools. + InternalsOpts::Fsverity(args) => match args { + FsverityOpts::Measure { path } => { + let fd = + std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?; + let digest: fsverity::Sha256HashValue = fsverity::measure_verity(&fd)?; + let digest = digest.to_hex(); + println!("{digest}"); + Ok(()) + } + FsverityOpts::Enable { path } => { + let fd = + std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?; + fsverity::enable_verity_raw::(&fd)?; + Ok(()) + } + }, + InternalsOpts::Cfs { args } => crate::cfsctl::run_from_iter(args.iter()).await, + InternalsOpts::Reboot => crate::reboot::reboot(), + InternalsOpts::Fsck => { + let storage = &get_storage().await?; + crate::fsck::fsck(&storage, std::io::stdout().lock()).await?; + Ok(()) + } + InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root), + InternalsOpts::PrintJsonSchema { of } => { + let schema = match of { + SchemaType::Host => schema_for!(crate::spec::Host), + SchemaType::Progress => schema_for!(crate::progress_jsonl::Event), + }; + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer_pretty(&mut stdout, &schema)?; + Ok(()) + } + InternalsOpts::Cleanup => { + let storage = get_storage().await?; + crate::deploy::cleanup(&storage).await + } + InternalsOpts::Relabel { as_path, path } => { + let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let path = path.strip_prefix("/")?; + let sepolicy = + &ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)?; + crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?; + Ok(()) + } + InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => { + let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await + } + InternalsOpts::LoopbackCleanupHelper { device } => { + crate::blockdev::run_loopback_cleanup_helper(&device).await + } + InternalsOpts::AllocateCleanupLoopback { file_path: _ } => { + // Create a temporary file for testing + let temp_file = + tempfile::NamedTempFile::new().context("Failed to create temporary file")?; + let temp_path = temp_file.path(); + + // Create a loopback device + let loopback = crate::blockdev::LoopbackDevice::new(temp_path) + .context("Failed to create loopback device")?; + + println!("Created loopback device: {}", loopback.path()); + + // Close the device to test cleanup + loopback + .close() + .context("Failed to close loopback device")?; + + println!("Successfully closed loopback device"); + Ok(()) + } + #[cfg(feature = "rhsm")] + InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await, + #[cfg(feature = "docgen")] + InternalsOpts::DumpCliJson => { + use clap::CommandFactory; + let cmd = Opt::command(); + let json = crate::cli_json::dump_cli_json(&cmd)?; + println!("{}", json); + Ok(()) + } + InternalsOpts::DirDiff { + pristine_etc, + current_etc, + new_etc, + merge, + } => { + let pristine_etc = + Dir::open_ambient_dir(pristine_etc, cap_std::ambient_authority())?; + let current_etc = Dir::open_ambient_dir(current_etc, cap_std::ambient_authority())?; + let new_etc = Dir::open_ambient_dir(new_etc, cap_std::ambient_authority())?; + + let (p, c, n) = + etc_merge::traverse_etc(&pristine_etc, ¤t_etc, Some(&new_etc))?; + + let diff = compute_diff(&p, &c)?; + print_diff(&diff, &mut std::io::stdout()); + + if merge { + let n = + n.ok_or_else(|| anyhow::anyhow!("Failed to get dirtree for new etc"))?; + etc_merge::merge(¤t_etc, &c, &new_etc, &n, diff)?; + } + + Ok(()) + } + }, + Opt::State(opts) => match opts { + StateOpts::WipeOstree => { + let sysroot = ostree::Sysroot::new_default(); + sysroot.load(gio::Cancellable::NONE)?; + crate::deploy::wipe_ostree(sysroot).await?; + Ok(()) + } + }, + + Opt::ComposefsFinalizeStaged => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("ComposefsFinalizeStaged is only supported for composefs backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + composefs_backend_finalize(storage, &booted_cfs).await + } + } + } + + Opt::ConfigDiff => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("ConfigDiff is only supported for composefs backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + get_etc_diff(storage, &booted_cfs).await + } + } + } + + Opt::DeleteDeployment { depl_id } => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("DeleteDeployment is only supported for composefs backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + delete_composefs_deployment(&depl_id, storage, &booted_cfs).await + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_callname() { + use std::os::unix::ffi::OsStrExt; + + // Cases that change + let mapped_cases = [ + ("", "bootc"), + ("/foo/bar", "bar"), + ("/foo/bar/", "bar"), + ("foo/bar", "bar"), + ("../foo/bar", "bar"), + ("usr/bin/ostree-container", "ostree-container"), + ]; + for (input, output) in mapped_cases { + assert_eq!( + output, + callname_from_argv0(OsStr::new(input)), + "Handling mapped case {input}" + ); + } + + // Invalid UTF-8 + assert_eq!("bootc", callname_from_argv0(OsStr::from_bytes(b"foo\x80"))); + + // Cases that are identical + let ident_cases = ["foo", "bootc"]; + for case in ident_cases { + assert_eq!( + case, + callname_from_argv0(OsStr::new(case)), + "Handling ident case {case}" + ); + } + } + + #[test] + fn test_parse_install_args() { + // Verify we still process the legacy --target-no-signature-verification + let o = Opt::try_parse_from([ + "bootc", + "install", + "to-filesystem", + "--target-no-signature-verification", + "/target", + ]) + .unwrap(); + let o = match o { + Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts, + o => panic!("Expected filesystem opts, not {o:?}"), + }; + assert!(o.target_opts.target_no_signature_verification); + assert_eq!(o.filesystem_opts.root_path.as_str(), "/target"); + // Ensure we default to old bound images behavior + assert_eq!( + o.config_opts.bound_images, + crate::install::BoundImagesOpt::Stored + ); + } + + #[test] + fn test_parse_opts() { + assert!(matches!( + Opt::parse_including_static(["bootc", "status"]), + Opt::Status(StatusOpts { + json: false, + format: None, + format_version: None, + booted: false, + verbose: false + }) + )); + assert!(matches!( + Opt::parse_including_static(["bootc", "status", "--format-version=0"]), + Opt::Status(StatusOpts { + format_version: Some(0), + .. + }) + )); + + // Test verbose long form + assert!(matches!( + Opt::parse_including_static(["bootc", "status", "--verbose"]), + Opt::Status(StatusOpts { verbose: true, .. }) + )); + + // Test verbose short form + assert!(matches!( + Opt::parse_including_static(["bootc", "status", "-v"]), + Opt::Status(StatusOpts { verbose: true, .. }) + )); + } + + #[test] + fn test_parse_generator() { + assert!(matches!( + Opt::parse_including_static([ + "/usr/lib/systemd/system/bootc-systemd-generator", + "/run/systemd/system" + ]), + Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system" + )); + } + + #[test] + fn test_parse_ostree_ext() { + assert!(matches!( + Opt::parse_including_static(["bootc", "internals", "ostree-container"]), + Opt::Internals(InternalsOpts::OstreeContainer { .. }) + )); + + fn peel(o: Opt) -> Vec { + match o { + Opt::Internals(InternalsOpts::OstreeExt { args }) => args, + o => panic!("unexpected {o:?}"), + } + } + let args = peel(Opt::parse_including_static([ + "/usr/libexec/libostree/ext/ostree-ima-sign", + "ima-sign", + "--repo=foo", + "foo", + "bar", + "baz", + ])); + assert_eq!( + args.as_slice(), + ["ima-sign", "--repo=foo", "foo", "bar", "baz"] + ); + + let args = peel(Opt::parse_including_static([ + "/usr/libexec/libostree/ext/ostree-container", + "container", + "image", + "pull", + ])); + assert_eq!(args.as_slice(), ["container", "image", "pull"]); + } +} diff --git a/crates/lib/src/cli_json.rs b/crates/lib/src/cli_json.rs new file mode 100644 index 000000000..0b3472540 --- /dev/null +++ b/crates/lib/src/cli_json.rs @@ -0,0 +1,140 @@ +//! Export CLI structure as JSON for documentation generation + +use clap::Command; +use serde::{Deserialize, Serialize}; + +/// Representation of a CLI option for JSON export +#[derive(Debug, Serialize, Deserialize)] +pub struct CliOption { + pub long: String, + pub short: Option, + pub value_name: Option, + pub default: Option, + pub help: String, + pub possible_values: Vec, + pub required: bool, + pub is_boolean: bool, +} + +/// Representation of a CLI command for JSON export +#[derive(Debug, Serialize, Deserialize)] +pub struct CliCommand { + pub name: String, + pub about: Option, + pub options: Vec, + pub positionals: Vec, + pub subcommands: Vec, +} + +/// Representation of a positional argument +#[derive(Debug, Serialize, Deserialize)] +pub struct CliPositional { + pub name: String, + pub help: Option, + pub required: bool, + pub multiple: bool, +} + +/// Convert a clap Command to our JSON representation +pub fn command_to_json(cmd: &Command) -> CliCommand { + let mut options = Vec::new(); + let mut positionals = Vec::new(); + + // Extract arguments + for arg in cmd.get_arguments() { + let id = arg.get_id().as_str(); + + // Skip built-in help and version + if id == "help" || id == "version" { + continue; + } + + // Skip hidden arguments + if arg.is_hide_set() { + continue; + } + + if arg.is_positional() { + // Handle positional arguments + positionals.push(CliPositional { + name: id.to_string(), + help: arg.get_help().map(|h| h.to_string()), + required: arg.is_required_set(), + multiple: false, // For now, simplify this + }); + } else { + // Handle options/flags + let mut possible_values = Vec::new(); + let pvs = arg.get_possible_values(); + if !pvs.is_empty() { + for pv in pvs { + possible_values.push(pv.get_name().to_string()); + } + } + + let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + + // For boolean flags, don't show a value name + // Boolean flags use SetTrue or SetFalse actions and don't take values + let is_boolean = matches!( + arg.get_action(), + clap::ArgAction::SetTrue | clap::ArgAction::SetFalse + ); + let value_name = if is_boolean { + None + } else { + arg.get_value_names() + .and_then(|names| names.first()) + .map(|s| s.to_string()) + }; + + options.push(CliOption { + long: arg + .get_long() + .map(String::from) + .unwrap_or_else(|| id.to_string()), + short: arg.get_short().map(|c| c.to_string()), + value_name, + default: arg + .get_default_values() + .first() + .and_then(|v| v.to_str()) + .map(String::from), + help, + possible_values, + required: arg.is_required_set(), + is_boolean, + }); + } + } + + // Extract subcommands + let mut subcommands = Vec::new(); + for subcmd in cmd.get_subcommands() { + // Skip help subcommand + if subcmd.get_name() == "help" { + continue; + } + + // Skip hidden subcommands + if subcmd.is_hide_set() { + continue; + } + + subcommands.push(command_to_json(subcmd)); + } + + CliCommand { + name: cmd.get_name().to_string(), + about: cmd.get_about().map(|s| s.to_string()), + options, + positionals, + subcommands, + } +} + +/// Dump the entire CLI structure as JSON +pub fn dump_cli_json(cmd: &Command) -> Result { + let cli_structure = command_to_json(cmd); + serde_json::to_string_pretty(&cli_structure) +} diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs new file mode 100644 index 000000000..c9e6f7512 --- /dev/null +++ b/crates/lib/src/composefs_consts.rs @@ -0,0 +1,40 @@ +#![allow(dead_code)] + +/// composefs= paramter in kernel cmdline +pub const COMPOSEFS_CMDLINE: &str = "composefs"; + +/// Directory to store transient state, such as staged deployemnts etc +pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; +/// File created in /run/composefs to record a staged-deployment +pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; + +/// Absolute path to composefs-backend state directory +pub(crate) const STATE_DIR_ABS: &str = "/sysroot/state/deploy"; +/// Relative path to composefs-backend state directory. Relative to /sysroot +pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; +/// Relative path to the shared 'var' directory. Relative to /sysroot +pub(crate) const SHARED_VAR_PATH: &str = "state/os/default/var"; + +/// Section in .origin file to store boot related metadata +pub(crate) const ORIGIN_KEY_BOOT: &str = "boot"; +/// Whether the deployment was booted with BLS or UKI +pub(crate) const ORIGIN_KEY_BOOT_TYPE: &str = "boot_type"; +/// Key to store the SHA256 sum of vmlinuz + initrd for a deployment +pub(crate) const ORIGIN_KEY_BOOT_DIGEST: &str = "digest"; + +/// Filename for `loader/entries` +pub(crate) const BOOT_LOADER_ENTRIES: &str = "entries"; +/// Filename for staged boot loader entries +pub(crate) const STAGED_BOOT_LOADER_ENTRIES: &str = "entries.staged"; + +/// Filename for grub user config +pub(crate) const USER_CFG: &str = "user.cfg"; +/// Filename for staged grub user config +pub(crate) const USER_CFG_STAGED: &str = "user.cfg.staged"; + +/// Path to the config files directory for Type1 boot entries +/// This is relative to the boot/efi directory +pub(crate) const TYPE1_ENT_PATH: &str = "loader/entries"; +pub(crate) const TYPE1_ENT_PATH_STAGED: &str = "loader/entries.staged"; + +pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.service"; diff --git a/crates/lib/src/containerenv.rs b/crates/lib/src/containerenv.rs new file mode 100644 index 000000000..2ed8a1534 --- /dev/null +++ b/crates/lib/src/containerenv.rs @@ -0,0 +1,58 @@ +//! Helpers for parsing the `/run/.containerenv` file generated by podman. + +use std::io::{BufRead, BufReader}; + +use anyhow::Result; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::prelude::CapStdExtDirExt; +use fn_error_context::context; + +/// Path is relative to container rootfs (assumed to be /) +pub(crate) const PATH: &str = "run/.containerenv"; + +#[derive(Debug, Default)] +pub(crate) struct ContainerExecutionInfo { + pub(crate) engine: String, + pub(crate) name: String, + pub(crate) id: String, + pub(crate) image: String, + pub(crate) imageid: String, + pub(crate) rootless: Option, +} + +pub(crate) fn is_container(rootfs: &Dir) -> bool { + rootfs.exists(PATH) +} + +/// Load and parse the `/run/.containerenv` file. +#[context("Querying container")] +pub(crate) fn get_container_execution_info(rootfs: &Dir) -> Result { + let f = match rootfs.open_optional(PATH)? { + Some(f) => BufReader::new(f), + None => { + anyhow::bail!( + "This command must be executed inside a podman container (missing /{PATH})" + ) + } + }; + let mut r = ContainerExecutionInfo::default(); + for line in f.lines() { + let line = line?; + let line = line.trim(); + let Some((k, v)) = line.split_once('=') else { + continue; + }; + // Assuming there's no quotes here + let v = v.trim_start_matches('"').trim_end_matches('"'); + match k { + "engine" => r.engine = v.to_string(), + "name" => r.name = v.to_string(), + "id" => r.id = v.to_string(), + "image" => r.image = v.to_string(), + "imageid" => r.imageid = v.to_string(), + "rootless" => r.rootless = Some(v.to_string()), + _ => {} + } + } + Ok(r) +} diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs new file mode 100644 index 000000000..031700fc1 --- /dev/null +++ b/crates/lib/src/deploy.rs @@ -0,0 +1,1165 @@ +//! # Write deployments merging image with configmap +//! +//! Create a merged filesystem tree with the image and mounted configmaps. + +use std::collections::HashSet; +use std::io::{BufRead, Write}; + +use anyhow::Ok; +use anyhow::{anyhow, Context, Result}; +use bootc_kernel_cmdline::utf8::CmdlineOwned; +use cap_std::fs::{Dir, MetadataExt}; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree::{gio, glib}; +use ostree_container::OstreeImageReference; +use ostree_ext::container as ostree_container; +use ostree_ext::container::store::{ImageImporter, ImportProgress, PrepareResult, PreparedImport}; +use ostree_ext::oci_spec::image::{Descriptor, Digest}; +use ostree_ext::ostree::Deployment; +use ostree_ext::ostree::{self, Sysroot}; +use ostree_ext::sysroot::SysrootLock; +use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; + +use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; +use crate::spec::ImageReference; +use crate::spec::{BootOrder, HostSpec}; +use crate::status::labels_of_config; +use crate::store::Storage; +use crate::utils::async_task_with_spinner; + +// TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a +const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; + +/// Set on an ostree commit if this is a derived commit +const BOOTC_DERIVED_KEY: &str = "bootc.derived"; + +/// Variant of HostSpec but required to be filled out +pub(crate) struct RequiredHostSpec<'a> { + pub(crate) image: &'a ImageReference, +} + +/// State of a locally fetched image +pub(crate) struct ImageState { + pub(crate) manifest_digest: Digest, + pub(crate) version: Option, + pub(crate) ostree_commit: String, +} + +impl<'a> RequiredHostSpec<'a> { + /// Given a (borrowed) host specification, "unwrap" its internal + /// options, giving a spec that is required to have a base container image. + pub(crate) fn from_spec(spec: &'a HostSpec) -> Result { + let image = spec + .image + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing image in specification"))?; + Ok(Self { image }) + } +} + +impl From for ImageState { + fn from(value: ostree_container::store::LayeredImageState) -> Self { + let version = value.version().map(|v| v.to_owned()); + let ostree_commit = value.get_commit().to_owned(); + Self { + manifest_digest: value.manifest_digest, + version, + ostree_commit, + } + } +} + +impl ImageState { + /// Fetch the manifest corresponding to this image. May not be available in all backends. + pub(crate) fn get_manifest( + &self, + repo: &ostree::Repo, + ) -> Result> { + ostree_container::store::query_image_commit(repo, &self.ostree_commit) + .map(|v| Some(v.manifest)) + } +} + +/// Wrapper for pulling a container image, wiring up status output. +pub(crate) async fn new_importer( + repo: &ostree::Repo, + imgref: &ostree_container::OstreeImageReference, +) -> Result { + let config = Default::default(); + let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?; + imp.require_bootable(); + Ok(imp) +} + +pub(crate) fn check_bootc_label(config: &ostree_ext::oci_spec::image::ImageConfiguration) { + if let Some(label) = + labels_of_config(config).and_then(|labels| labels.get(crate::metadata::BOOTC_COMPAT_LABEL)) + { + match label.as_str() { + crate::metadata::COMPAT_LABEL_V1 => {} + o => crate::journal::journal_print( + libsystemd::logging::Priority::Warning, + &format!( + "notice: Unknown {} value {}", + crate::metadata::BOOTC_COMPAT_LABEL, + o + ), + ), + } + } else { + crate::journal::journal_print( + libsystemd::logging::Priority::Warning, + &format!( + "notice: Image is missing label: {}", + crate::metadata::BOOTC_COMPAT_LABEL + ), + ) + } +} + +fn descriptor_of_progress(p: &ImportProgress) -> &Descriptor { + match p { + ImportProgress::OstreeChunkStarted(l) => l, + ImportProgress::OstreeChunkCompleted(l) => l, + ImportProgress::DerivedLayerStarted(l) => l, + ImportProgress::DerivedLayerCompleted(l) => l, + } +} + +fn prefix_of_progress(p: &ImportProgress) -> &'static str { + match p { + ImportProgress::OstreeChunkStarted(_) | ImportProgress::OstreeChunkCompleted(_) => { + "ostree chunk" + } + ImportProgress::DerivedLayerStarted(_) | ImportProgress::DerivedLayerCompleted(_) => { + "layer" + } + } +} + +/// Configuration for layer progress printing +struct LayerProgressConfig { + layers: tokio::sync::mpsc::Receiver, + layer_bytes: tokio::sync::watch::Receiver>, + digest: Box, + n_layers_to_fetch: usize, + layers_total: usize, + bytes_to_download: u64, + bytes_total: u64, + prog: ProgressWriter, + quiet: bool, +} + +/// Write container fetch progress to standard output. +async fn handle_layer_progress_print(mut config: LayerProgressConfig) -> ProgressWriter { + let start = std::time::Instant::now(); + let mut total_read = 0u64; + let bar = indicatif::MultiProgress::new(); + if config.quiet { + bar.set_draw_target(indicatif::ProgressDrawTarget::hidden()); + } + let layers_bar = bar.add(indicatif::ProgressBar::new( + config.n_layers_to_fetch.try_into().unwrap(), + )); + let byte_bar = bar.add(indicatif::ProgressBar::new(0)); + // let byte_bar = indicatif::ProgressBar::new(0); + // byte_bar.set_draw_target(indicatif::ProgressDrawTarget::hidden()); + layers_bar.set_style( + indicatif::ProgressStyle::default_bar() + .template("{prefix} {bar} {pos}/{len} {wide_msg}") + .unwrap(), + ); + let taskname = "Fetching layers"; + layers_bar.set_prefix(taskname); + layers_bar.set_message(""); + byte_bar.set_prefix("Fetching"); + byte_bar.set_style( + indicatif::ProgressStyle::default_bar() + .template( + " └ {prefix} {bar} {binary_bytes}/{binary_total_bytes} ({binary_bytes_per_sec}) {wide_msg}", + ) + .unwrap() + ); + + let mut subtasks = vec![]; + let mut subtask: SubTaskBytes = Default::default(); + loop { + tokio::select! { + // Always handle layer changes first. + biased; + layer = config.layers.recv() => { + if let Some(l) = layer { + let layer = descriptor_of_progress(&l); + let layer_type = prefix_of_progress(&l); + let short_digest = &layer.digest().digest()[0..21]; + let layer_size = layer.size(); + if l.is_starting() { + // Reset the progress bar + byte_bar.reset_elapsed(); + byte_bar.reset_eta(); + byte_bar.set_length(layer_size); + byte_bar.set_message(format!("{layer_type} {short_digest}")); + + subtask = SubTaskBytes { + subtask: layer_type.into(), + description: format!("{layer_type}: {short_digest}").clone().into(), + id: short_digest.to_string().clone().into(), + bytes_cached: 0, + bytes: 0, + bytes_total: layer_size, + }; + } else { + byte_bar.set_position(layer_size); + layers_bar.inc(1); + total_read = total_read.saturating_add(layer_size); + // Emit an event where bytes == total to signal completion. + subtask.bytes = layer_size; + subtasks.push(subtask.clone()); + config.prog.send(Event::ProgressBytes { + task: "pulling".into(), + description: format!("Pulling Image: {}", config.digest).into(), + id: (*config.digest).into(), + bytes_cached: config.bytes_total - config.bytes_to_download, + bytes: total_read, + bytes_total: config.bytes_to_download, + steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64, + steps: layers_bar.position(), + steps_total: config.n_layers_to_fetch as u64, + subtasks: subtasks.clone(), + }).await; + } + } else { + // If the receiver is disconnected, then we're done + break + }; + }, + r = config.layer_bytes.changed() => { + if r.is_err() { + // If the receiver is disconnected, then we're done + break + } + let bytes = { + let bytes = config.layer_bytes.borrow_and_update(); + bytes.as_ref().cloned() + }; + if let Some(bytes) = bytes { + byte_bar.set_position(bytes.fetched); + subtask.bytes = byte_bar.position(); + config.prog.send_lossy(Event::ProgressBytes { + task: "pulling".into(), + description: format!("Pulling Image: {}", config.digest).into(), + id: (*config.digest).into(), + bytes_cached: config.bytes_total - config.bytes_to_download, + bytes: total_read + byte_bar.position(), + bytes_total: config.bytes_to_download, + steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64, + steps: layers_bar.position(), + steps_total: config.n_layers_to_fetch as u64, + subtasks: subtasks.clone().into_iter().chain([subtask.clone()]).collect(), + }).await; + } + } + } + } + byte_bar.finish_and_clear(); + layers_bar.finish_and_clear(); + if let Err(e) = bar.clear() { + tracing::warn!("clearing bar: {e}"); + } + let end = std::time::Instant::now(); + let elapsed = end.duration_since(start); + let persec = total_read as f64 / elapsed.as_secs_f64(); + let persec = indicatif::HumanBytes(persec as u64); + if let Err(e) = bar.println(&format!( + "Fetched layers: {} in {} ({}/s)", + indicatif::HumanBytes(total_read), + indicatif::HumanDuration(elapsed), + persec, + )) { + tracing::warn!("writing to stdout: {e}"); + } + + // Since the progress notifier closed, we know import has started + // use as a heuristic to begin import progress + // Cannot be lossy or it is dropped + config + .prog + .send(Event::ProgressSteps { + task: "importing".into(), + description: "Importing Image".into(), + id: (*config.digest).into(), + steps_cached: 0, + steps: 0, + steps_total: 1, + subtasks: [SubTaskStep { + subtask: "importing".into(), + description: "Importing Image".into(), + id: "importing".into(), + completed: false, + }] + .into(), + }) + .await; + + // Return the writer + config.prog +} + +/// Gather all bound images in all deployments, then prune the image store, +/// using the gathered images as the roots (that will not be GC'd). +pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> { + let ostree = sysroot.get_ostree()?; + let deployments = ostree.deployments(); + let mut all_bound_images = Vec::new(); + for deployment in deployments { + let bound = crate::boundimage::query_bound_images_for_deployment(ostree, &deployment)?; + all_bound_images.extend(bound.into_iter()); + } + // Convert to a hashset of just the image names + let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str())); + let pruned = sysroot + .get_ensure_imgstore()? + .prune_except_roots(&image_names) + .await?; + tracing::debug!("Pruned images: {}", pruned.len()); + Ok(()) +} + +pub(crate) struct PreparedImportMeta { + pub imp: ImageImporter, + pub prep: Box, + pub digest: Digest, + pub n_layers_to_fetch: usize, + pub layers_total: usize, + pub bytes_to_fetch: u64, + pub bytes_total: u64, +} + +pub(crate) enum PreparedPullResult { + Ready(Box), + AlreadyPresent(Box), +} + +pub(crate) async fn prepare_for_pull( + repo: &ostree::Repo, + imgref: &ImageReference, + target_imgref: Option<&OstreeImageReference>, +) -> Result { + let imgref_canonicalized = imgref.clone().canonicalize()?; + tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}"); + let ostree_imgref = &OstreeImageReference::from(imgref_canonicalized); + let mut imp = new_importer(repo, ostree_imgref).await?; + if let Some(target) = target_imgref { + imp.set_target(target); + } + let prep = match imp.prepare().await? { + PrepareResult::AlreadyPresent(c) => { + println!("No changes in {imgref:#} => {}", c.manifest_digest); + return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into()))); + } + PrepareResult::Ready(p) => p, + }; + check_bootc_label(&prep.config); + if let Some(warning) = prep.deprecated_warning() { + ostree_ext::cli::print_deprecated_warning(warning).await; + } + ostree_ext::cli::print_layer_status(&prep); + let layers_to_fetch = prep.layers_to_fetch().collect::>>()?; + + let prepared_image = PreparedImportMeta { + imp, + n_layers_to_fetch: layers_to_fetch.len(), + layers_total: prep.all_layers().count(), + bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(), + bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(), + digest: prep.manifest_digest.clone(), + prep, + }; + + Ok(PreparedPullResult::Ready(Box::new(prepared_image))) +} + +#[context("Pulling")] +pub(crate) async fn pull_from_prepared( + imgref: &ImageReference, + quiet: bool, + prog: ProgressWriter, + mut prepared_image: PreparedImportMeta, +) -> Result> { + let layer_progress = prepared_image.imp.request_progress(); + let layer_byte_progress = prepared_image.imp.request_layer_progress(); + let digest = prepared_image.digest.clone(); + let digest_imp = prepared_image.digest.clone(); + + let printer = tokio::task::spawn(async move { + handle_layer_progress_print(LayerProgressConfig { + layers: layer_progress, + layer_bytes: layer_byte_progress, + digest: digest.as_ref().into(), + n_layers_to_fetch: prepared_image.n_layers_to_fetch, + layers_total: prepared_image.layers_total, + bytes_to_download: prepared_image.bytes_to_fetch, + bytes_total: prepared_image.bytes_total, + prog, + quiet, + }) + .await + }); + let import = prepared_image.imp.import(prepared_image.prep).await; + let prog = printer.await?; + // Both the progress and the import are done, so import is done as well + prog.send(Event::ProgressSteps { + task: "importing".into(), + description: "Importing Image".into(), + id: digest_imp.clone().as_ref().into(), + steps_cached: 0, + steps: 1, + steps_total: 1, + subtasks: [SubTaskStep { + subtask: "importing".into(), + description: "Importing Image".into(), + id: "importing".into(), + completed: true, + }] + .into(), + }) + .await; + let import = import?; + let imgref_canonicalized = imgref.clone().canonicalize()?; + tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}"); + + // Log successful import completion + const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8"; + + tracing::info!( + message_id = IMPORT_COMPLETE_JOURNAL_ID, + bootc.image.reference = &imgref.image, + bootc.image.transport = &imgref.transport, + bootc.manifest_digest = import.manifest_digest.as_ref(), + bootc.ostree_commit = &import.merge_commit, + "Successfully imported image: {}", + imgref + ); + + if let Some(msg) = + ostree_container::store::image_filtered_content_warning(&import.filtered_files) + .context("Image content warning")? + { + tracing::info!("{}", msg); + } + Ok(Box::new((*import).into())) +} + +/// Wrapper for pulling a container image, wiring up status output. +pub(crate) async fn pull( + repo: &ostree::Repo, + imgref: &ImageReference, + target_imgref: Option<&OstreeImageReference>, + quiet: bool, + prog: ProgressWriter, +) -> Result> { + match prepare_for_pull(repo, imgref, target_imgref).await? { + PreparedPullResult::AlreadyPresent(existing) => { + // Log that the image was already present (Debug level since it's not actionable) + const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9"; + tracing::debug!( + message_id = IMAGE_ALREADY_PRESENT_ID, + bootc.image.reference = &imgref.image, + bootc.image.transport = &imgref.transport, + bootc.status = "already_present", + "Image already present: {}", + imgref + ); + Ok(existing) + } + PreparedPullResult::Ready(prepared_image_meta) => { + // Log that we're pulling a new image + const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0"; + tracing::info!( + message_id = PULLING_NEW_IMAGE_ID, + bootc.image.reference = &imgref.image, + bootc.image.transport = &imgref.transport, + bootc.status = "pulling_new", + "Pulling new image: {}", + imgref + ); + Ok(pull_from_prepared(imgref, quiet, prog, *prepared_image_meta).await?) + } + } +} + +pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> { + tokio::task::spawn_blocking(move || { + sysroot + .write_deployments(&[], gio::Cancellable::NONE) + .context("removing deployments") + }) + .await??; + + Ok(()) +} + +pub(crate) async fn cleanup(sysroot: &Storage) -> Result<()> { + // Log the cleanup operation to systemd journal + const CLEANUP_JOURNAL_ID: &str = "2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6"; + + tracing::info!( + message_id = CLEANUP_JOURNAL_ID, + "Starting cleanup of old images and deployments" + ); + + let bound_prune = prune_container_store(sysroot); + + // We create clones (just atomic reference bumps) here to move to the thread. + let ostree = sysroot.get_ostree_cloned()?; + let repo = ostree.repo(); + let repo_prune = + ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let locked_sysroot = &SysrootLock::from_assumed_locked(&ostree); + let cancellable = Some(cancellable); + let repo = &repo; + let txn = repo.auto_transaction(cancellable)?; + let repo = txn.repo(); + + // Regenerate our base references. First, we delete the ones that exist + for ref_entry in repo + .list_refs_ext( + Some(BASE_IMAGE_PREFIX), + ostree::RepoListRefsExtFlags::NONE, + cancellable, + ) + .context("Listing refs")? + .keys() + { + repo.transaction_set_refspec(ref_entry, None); + } + + // Then, for each deployment which is derived (e.g. has configmaps) we synthesize + // a base ref to ensure that it's not GC'd. + for (i, deployment) in ostree.deployments().into_iter().enumerate() { + let commit = deployment.csum(); + if let Some(base) = get_base_commit(repo, &commit)? { + repo.transaction_set_refspec(&format!("{BASE_IMAGE_PREFIX}/{i}"), Some(&base)); + } + } + + let pruned = + ostree_container::deploy::prune(locked_sysroot).context("Pruning images")?; + if !pruned.is_empty() { + let size = glib::format_size(pruned.objsize); + println!( + "Pruned images: {} (layers: {}, objsize: {})", + pruned.n_images, pruned.n_layers, size + ); + } else { + tracing::debug!("Nothing to prune"); + } + + Ok(()) + }); + + // We run these in parallel mostly because we can. + tokio::try_join!(repo_prune, bound_prune)?; + Ok(()) +} + +/// If commit is a bootc-derived commit (e.g. has configmaps), return its base. +#[context("Finding base commit")] +pub(crate) fn get_base_commit(repo: &ostree::Repo, commit: &str) -> Result> { + let commitv = repo.load_commit(commit)?.0; + let commitmeta = commitv.child_value(0); + let commitmeta = &glib::VariantDict::new(Some(&commitmeta)); + let r = commitmeta.lookup::(BOOTC_DERIVED_KEY)?; + Ok(r) +} + +#[context("Writing deployment")] +async fn deploy( + sysroot: &Storage, + from: MergeState, + image: &ImageState, + origin: &glib::KeyFile, +) -> Result { + // Compute the kernel argument overrides. In practice today this API is always expecting + // a merge deployment. The kargs code also always looks at the booted root (which + // is a distinct minor issue, but not super important as right now the install path + // doesn't use this API). + let (stateroot, override_kargs) = match &from { + MergeState::MergeDeployment(deployment) => { + let kargs = crate::bootc_kargs::get_kargs(sysroot, &deployment, image)?; + (deployment.stateroot().into(), Some(kargs)) + } + MergeState::Reset { stateroot, kargs } => (stateroot.clone(), Some(kargs.clone())), + }; + // Clone all the things to move to worker thread + let ostree = sysroot.get_ostree_cloned()?; + // ostree::Deployment is incorrectly !Send 😢 so convert it to an integer + let merge_deployment = from.as_merge_deployment(); + let merge_deployment = merge_deployment.map(|d| d.index() as usize); + let ostree_commit = image.ostree_commit.to_string(); + // GKeyFile also isn't Send! So we serialize that as a string... + let origin_data = origin.to_data(); + let r = async_task_with_spinner( + "Deploying", + spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> { + let ostree = ostree; + let stateroot = Some(stateroot); + let mut opts = ostree::SysrootDeployTreeOpts::default(); + + // Because the C API expects a Vec<&str>, convert the Cmdline to string slices. + // The references borrow from the Cmdline, which outlives this usage. + let override_kargs_refs = override_kargs + .as_ref() + .map(|kargs| kargs.iter_str().collect::>()); + if let Some(kargs) = override_kargs_refs.as_ref() { + opts.override_kernel_argv = Some(kargs); + } + + let deployments = ostree.deployments(); + let merge_deployment = merge_deployment.map(|m| &deployments[m]); + let origin = glib::KeyFile::new(); + origin.load_from_data(&origin_data, glib::KeyFileFlags::NONE)?; + let d = ostree.stage_tree_with_options( + stateroot.as_deref(), + &ostree_commit, + Some(&origin), + merge_deployment, + &opts, + Some(cancellable), + )?; + Ok(d.index()) + }), + ) + .await?; + // SAFETY: We must have a staged deployment + let ostree = sysroot.get_ostree()?; + let staged = ostree.staged_deployment().unwrap(); + assert_eq!(staged.index(), r); + Ok(staged) +} + +#[context("Generating origin")] +fn origin_from_imageref(imgref: &ImageReference) -> Result { + let origin = glib::KeyFile::new(); + let imgref = OstreeImageReference::from(imgref.clone()); + origin.set_string( + "origin", + ostree_container::deploy::ORIGIN_CONTAINER, + imgref.to_string().as_str(), + ); + Ok(origin) +} + +/// The source of data for staging a new deployment +#[derive(Debug)] +pub(crate) enum MergeState { + /// Use the provided merge deployment + MergeDeployment(Deployment), + /// Don't use a merge deployment, but only this + /// provided initial state. + Reset { + stateroot: String, + kargs: CmdlineOwned, + }, +} +impl MergeState { + /// Initialize using the default merge deployment for the given stateroot. + pub(crate) fn from_stateroot(sysroot: &Storage, stateroot: &str) -> Result { + let ostree = sysroot.get_ostree()?; + let merge_deployment = ostree.merge_deployment(Some(stateroot)).ok_or_else(|| { + anyhow::anyhow!("No merge deployment found for stateroot {stateroot}") + })?; + Ok(Self::MergeDeployment(merge_deployment)) + } + + /// Cast this to a merge deployment case. + pub(crate) fn as_merge_deployment(&self) -> Option<&Deployment> { + match self { + Self::MergeDeployment(d) => Some(d), + Self::Reset { .. } => None, + } + } +} + +/// Stage (queue deployment of) a fetched container image. +#[context("Staging")] +pub(crate) async fn stage( + sysroot: &Storage, + from: MergeState, + image: &ImageState, + spec: &RequiredHostSpec<'_>, + prog: ProgressWriter, +) -> Result<()> { + // Log the staging operation to systemd journal with comprehensive upgrade information + const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c"; + + tracing::info!( + message_id = STAGE_JOURNAL_ID, + bootc.image.reference = &spec.image.image, + bootc.image.transport = &spec.image.transport, + bootc.manifest_digest = image.manifest_digest.as_ref(), + "Staging image for deployment: {} (digest: {})", + spec.image, + image.manifest_digest + ); + + let mut subtask = SubTaskStep { + subtask: "merging".into(), + description: "Merging Image".into(), + id: "fetching".into(), + completed: false, + }; + let mut subtasks = vec![]; + prog.send(Event::ProgressSteps { + task: "staging".into(), + description: "Deploying Image".into(), + id: image.manifest_digest.clone().as_ref().into(), + steps_cached: 0, + steps: 0, + steps_total: 3, + subtasks: subtasks + .clone() + .into_iter() + .chain([subtask.clone()]) + .collect(), + }) + .await; + + subtask.completed = true; + subtasks.push(subtask.clone()); + subtask.subtask = "deploying".into(); + subtask.id = "deploying".into(); + subtask.description = "Deploying Image".into(); + subtask.completed = false; + prog.send(Event::ProgressSteps { + task: "staging".into(), + description: "Deploying Image".into(), + id: image.manifest_digest.clone().as_ref().into(), + steps_cached: 0, + steps: 1, + steps_total: 3, + subtasks: subtasks + .clone() + .into_iter() + .chain([subtask.clone()]) + .collect(), + }) + .await; + let origin = origin_from_imageref(spec.image)?; + let deployment = crate::deploy::deploy(sysroot, from, image, &origin).await?; + + subtask.completed = true; + subtasks.push(subtask.clone()); + subtask.subtask = "bound_images".into(); + subtask.id = "bound_images".into(); + subtask.description = "Pulling Bound Images".into(); + subtask.completed = false; + prog.send(Event::ProgressSteps { + task: "staging".into(), + description: "Deploying Image".into(), + id: image.manifest_digest.clone().as_ref().into(), + steps_cached: 0, + steps: 1, + steps_total: 3, + subtasks: subtasks + .clone() + .into_iter() + .chain([subtask.clone()]) + .collect(), + }) + .await; + crate::boundimage::pull_bound_images(sysroot, &deployment).await?; + + subtask.completed = true; + subtasks.push(subtask.clone()); + subtask.subtask = "cleanup".into(); + subtask.id = "cleanup".into(); + subtask.description = "Removing old images".into(); + subtask.completed = false; + prog.send(Event::ProgressSteps { + task: "staging".into(), + description: "Deploying Image".into(), + id: image.manifest_digest.clone().as_ref().into(), + steps_cached: 0, + steps: 2, + steps_total: 3, + subtasks: subtasks + .clone() + .into_iter() + .chain([subtask.clone()]) + .collect(), + }) + .await; + crate::deploy::cleanup(sysroot).await?; + println!("Queued for next boot: {:#}", spec.image); + if let Some(version) = image.version.as_deref() { + println!(" Version: {version}"); + } + println!(" Digest: {}", image.manifest_digest); + + subtask.completed = true; + subtasks.push(subtask.clone()); + prog.send(Event::ProgressSteps { + task: "staging".into(), + description: "Deploying Image".into(), + id: image.manifest_digest.clone().as_ref().into(), + steps_cached: 0, + steps: 3, + steps_total: 3, + subtasks: subtasks + .clone() + .into_iter() + .chain([subtask.clone()]) + .collect(), + }) + .await; + + // Unconditionally create or update /run/reboot-required to signal a reboot is needed. + // This is monitored by kured (Kubernetes Reboot Daemon). + write_reboot_required(&image.manifest_digest.as_ref())?; + + Ok(()) +} + +/// Update the /run/reboot-required file with the image that will be active after a reboot. +fn write_reboot_required(image: &str) -> Result<()> { + let reboot_message = format!("bootc: Reboot required for image: {}", image); + let run_dir = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?; + run_dir + .atomic_write("reboot-required", reboot_message.as_bytes()) + .context("Creating /run/reboot-required")?; + + Ok(()) +} + +/// Implementation of rollback functionality +pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { + const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; + let ostree = sysroot.get_ostree()?; + let (booted_ostree, deployments, host) = crate::status::get_status_require_booted(ostree)?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.boot_order = new_spec.boot_order.swap(); + new_spec + }; + + let repo = &booted_ostree.repo(); + + // Just to be sure + host.spec.verify_transition(&new_spec)?; + + let reverting = new_spec.boot_order == BootOrder::Default; + if reverting { + println!("notice: Reverting queued rollback state"); + } + let rollback_status = host + .status + .rollback + .ok_or_else(|| anyhow!("No rollback available"))?; + let rollback_image = rollback_status + .query_image(repo)? + .ok_or_else(|| anyhow!("Rollback is not container image based"))?; + + // Get current booted image for comparison + let current_image = host + .status + .booted + .as_ref() + .and_then(|b| b.query_image(repo).ok()?); + + tracing::info!( + message_id = ROLLBACK_JOURNAL_ID, + bootc.manifest_digest = rollback_image.manifest_digest.as_ref(), + bootc.ostree_commit = &rollback_image.merge_commit, + bootc.rollback_type = if reverting { "revert" } else { "rollback" }, + bootc.current_manifest_digest = current_image + .as_ref() + .map(|i| i.manifest_digest.as_ref()) + .unwrap_or("none"), + "Rolling back to image: {}", + rollback_image.manifest_digest + ); + // SAFETY: If there's a rollback status, then there's a deployment + let rollback_deployment = deployments.rollback.expect("rollback deployment"); + let new_deployments = if reverting { + [booted_ostree.deployment, rollback_deployment] + } else { + [rollback_deployment, booted_ostree.deployment] + }; + let new_deployments = new_deployments + .into_iter() + .chain(deployments.other) + .collect::>(); + tracing::debug!("Writing new deployments: {new_deployments:?}"); + booted_ostree + .sysroot + .write_deployments(&new_deployments, gio::Cancellable::NONE)?; + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + + write_reboot_required(rollback_image.manifest_digest.as_ref())?; + + sysroot.update_mtime()?; + + Ok(()) +} + +fn find_newest_deployment_name(deploysdir: &Dir) -> Result { + let mut dirs = Vec::new(); + for ent in deploysdir.entries()? { + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let name = ent.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + dirs.push((name.to_owned(), ent.metadata()?.mtime())); + } + dirs.sort_unstable_by(|a, b| a.1.cmp(&b.1)); + if let Some((name, _ts)) = dirs.pop() { + Ok(name) + } else { + anyhow::bail!("No deployment directory found") + } +} + +// Implementation of `bootc switch --in-place` +pub(crate) fn switch_origin_inplace(root: &Dir, imgref: &ImageReference) -> Result { + // Log the in-place switch operation to systemd journal + const SWITCH_INPLACE_JOURNAL_ID: &str = "3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7"; + + tracing::info!( + message_id = SWITCH_INPLACE_JOURNAL_ID, + bootc.image.reference = &imgref.image, + bootc.image.transport = &imgref.transport, + bootc.switch_type = "in_place", + "Performing in-place switch to image: {}", + imgref + ); + + // First, just create the new origin file + let origin = origin_from_imageref(imgref)?; + let serialized_origin = origin.to_data(); + + // Now, we can't rely on being officially booted (e.g. with the `ostree=` karg) + // in a scenario like running in the anaconda %post. + // Eventually, we should support a setup here where ostree-prepare-root + // can officially be run to "enter" an ostree root in a supportable way. + // Anyways for now, the brutal hack is to just scrape through the deployments + // and find the newest one, which we will mutate. If there's more than one, + // ultimately the calling tooling should be fixed to set things up correctly. + + let mut ostree_deploys = root.open_dir("sysroot/ostree/deploy")?.entries()?; + let deploydir = loop { + if let Some(ent) = ostree_deploys.next() { + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + tracing::debug!("Checking {:?}", ent.file_name()); + let child_dir = ent + .open_dir() + .with_context(|| format!("Opening dir {:?}", ent.file_name()))?; + if let Some(d) = child_dir.open_dir_optional("deploy")? { + break d; + } + } else { + anyhow::bail!("Failed to find a deployment"); + } + }; + let newest_deployment = find_newest_deployment_name(&deploydir)?; + let origin_path = format!("{newest_deployment}.origin"); + if !deploydir.try_exists(&origin_path)? { + tracing::warn!("No extant origin for {newest_deployment}"); + } + deploydir + .atomic_write(&origin_path, serialized_origin.as_bytes()) + .context("Writing origin")?; + Ok(newest_deployment) +} + +/// A workaround for https://github.com/ostreedev/ostree/issues/3193 +/// as generated by anaconda. +#[context("Updating /etc/fstab for anaconda+composefs")] +pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { + let fstab_path = "etc/fstab"; + // Read the old file + let fd = root + .open(fstab_path) + .with_context(|| format!("Opening {fstab_path}")) + .map(std::io::BufReader::new)?; + + // Helper function to possibly change a line from /etc/fstab. + // Returns Ok(true) if we made a change (and we wrote the modified line) + // otherwise returns Ok(false) and the caller should write the original line. + fn edit_fstab_line(line: &str, mut w: impl Write) -> Result { + if line.starts_with('#') { + return Ok(false); + } + let parts = line.split_ascii_whitespace().collect::>(); + + let path_idx = 1; + let options_idx = 3; + let (&path, &options) = match (parts.get(path_idx), parts.get(options_idx)) { + (None, _) => { + tracing::debug!("No path in entry: {line}"); + return Ok(false); + } + (_, None) => { + tracing::debug!("No options in entry: {line}"); + return Ok(false); + } + (Some(p), Some(o)) => (p, o), + }; + // If this is not the root, we're not matching on it + if path != "/" { + return Ok(false); + } + // If options already contains `ro`, nothing to do + if options.split(',').any(|s| s == "ro") { + return Ok(false); + } + + writeln!(w, "# {}", crate::generator::BOOTC_EDITED_STAMP)?; + + // SAFETY: we unpacked the options before. + // This adds `ro` to the option list + assert!(!options.is_empty()); // Split wouldn't have turned this up if it was empty + let options = format!("{options},ro"); + for (i, part) in parts.into_iter().enumerate() { + // TODO: would obviously be nicer to preserve whitespace...but...eh. + if i > 0 { + write!(w, " ")?; + } + if i == options_idx { + write!(w, "{options}")?; + } else { + write!(w, "{part}")? + } + } + // And add the trailing newline + writeln!(w)?; + Ok(true) + } + + // Read the input, and atomically write a modified version + root.atomic_replace_with(fstab_path, move |mut w| { + for line in fd.lines() { + let line = line?; + if !edit_fstab_line(&line, &mut w)? { + writeln!(w, "{line}")?; + } + } + Ok(()) + }) + .context("Replacing /etc/fstab")?; + + println!("Updated /etc/fstab to add `ro` for `/`"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_switch_inplace() -> Result<()> { + use cap_std::fs::DirBuilderExt; + + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let mut builder = cap_std::fs::DirBuilder::new(); + let builder = builder.recursive(true).mode(0o755); + let deploydir = "sysroot/ostree/deploy/default/deploy"; + let target_deployment = + "af36eb0086bb55ac601600478c6168f834288013d60f8870b7851f44bf86c3c5.0"; + td.ensure_dir_with( + format!("sysroot/ostree/deploy/default/deploy/{target_deployment}"), + builder, + )?; + let deploydir = &td.open_dir(deploydir)?; + let orig_imgref = ImageReference { + image: "quay.io/exampleos/original:sometag".into(), + transport: "registry".into(), + signature: None, + }; + { + let origin = origin_from_imageref(&orig_imgref)?; + deploydir.atomic_write( + format!("{target_deployment}.origin"), + origin.to_data().as_bytes(), + )?; + } + + let target_imgref = ImageReference { + image: "quay.io/someother/otherimage:latest".into(), + transport: "registry".into(), + signature: None, + }; + + let replaced = switch_origin_inplace(&td, &target_imgref).unwrap(); + assert_eq!(replaced, target_deployment); + Ok(()) + } + + #[test] + fn test_fixup_etc_fstab_default() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n"; + tempdir.create_dir_all("etc")?; + tempdir.atomic_write("etc/fstab", default)?; + fixup_etc_fstab(&tempdir).unwrap(); + assert_eq!(tempdir.read_to_string("etc/fstab")?, default); + Ok(()) + } + + #[test] + fn test_fixup_etc_fstab_multi() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\ +UUID=6907-17CA /boot/efi vfat umask=0077,shortname=winnt 0 2\n"; + tempdir.create_dir_all("etc")?; + tempdir.atomic_write("etc/fstab", default)?; + fixup_etc_fstab(&tempdir).unwrap(); + assert_eq!(tempdir.read_to_string("etc/fstab")?, default); + Ok(()) + } + + #[test] + fn test_fixup_etc_fstab_ro() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\ +UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 / xfs ro 0 0\n\ +UUID=6907-17CA /boot/efi vfat umask=0077,shortname=winnt 0 2\n"; + tempdir.create_dir_all("etc")?; + tempdir.atomic_write("etc/fstab", default)?; + fixup_etc_fstab(&tempdir).unwrap(); + assert_eq!(tempdir.read_to_string("etc/fstab")?, default); + Ok(()) + } + + #[test] + fn test_fixup_etc_fstab_rw() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + // This case uses `defaults` + let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\ +UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 / xfs defaults 0 0\n\ +UUID=6907-17CA /boot/efi vfat umask=0077,shortname=winnt 0 2\n"; + let modified = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\ +# Updated by bootc-fstab-edit.service\n\ +UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 / xfs defaults,ro 0 0\n\ +UUID=6907-17CA /boot/efi vfat umask=0077,shortname=winnt 0 2\n"; + tempdir.create_dir_all("etc")?; + tempdir.atomic_write("etc/fstab", default)?; + fixup_etc_fstab(&tempdir).unwrap(); + assert_eq!(tempdir.read_to_string("etc/fstab")?, modified); + Ok(()) + } +} diff --git a/crates/lib/src/discoverable_partition_specification.rs b/crates/lib/src/discoverable_partition_specification.rs new file mode 100644 index 000000000..d5432e74b --- /dev/null +++ b/crates/lib/src/discoverable_partition_specification.rs @@ -0,0 +1,866 @@ +#![allow(dead_code)] + +//! Partition type GUIDs from the Discoverable Partitions Specification (DPS) +//! +//! This module contains constants for partition type GUIDs as defined by the +//! UAPI Group's Discoverable Partitions Specification. +//! +//! Reference: + +// ============================================================================ +// ROOT PARTITIONS +// ============================================================================ + +/// Root partition for Alpha architecture +pub const ROOT_ALPHA: &str = "6523f8ae-3eb1-4e2a-a05a-18b695ae656f"; + +/// Root partition for ARC architecture +pub const ROOT_ARC: &str = "d27f46ed-2919-4cb8-bd25-9531f3c16534"; + +/// Root partition for 32-bit ARM architecture +pub const ROOT_ARM: &str = "69dad710-2ce4-4e3c-b16c-21a1d49abed3"; + +/// Root partition for 64-bit ARM/AArch64 architecture +pub const ROOT_ARM64: &str = "b921b045-1df0-41c3-af44-4c6f280d3fae"; + +/// Root partition for Itanium/IA-64 architecture +pub const ROOT_IA64: &str = "993d8d3d-f80e-4225-855a-9daf8ed7ea97"; + +/// Root partition for 64-bit LoongArch architecture +pub const ROOT_LOONGARCH64: &str = "77055800-792c-4f94-b39a-98c91b762bb6"; + +/// Root partition for 32-bit MIPS Little Endian +pub const ROOT_MIPS_LE: &str = "37c58c8a-d913-4156-a25f-48b1b64e07f0"; + +/// Root partition for 64-bit MIPS Little Endian +pub const ROOT_MIPS64_LE: &str = "700bda43-7a34-4507-b179-eeb93d7a7ca3"; + +/// Root partition for 32-bit MIPS Big Endian +pub const ROOT_MIPS: &str = "e9434544-6e2c-47cc-bae2-12d6deafb44c"; + +/// Root partition for 64-bit MIPS Big Endian +pub const ROOT_MIPS64: &str = "d113af76-80ef-41b4-bdb6-0cff4d3d4a25"; + +/// Root partition for PA-RISC/HPPA architecture +pub const ROOT_PARISC: &str = "1aacdb3b-5444-4138-bd9e-e5c2239b2346"; + +/// Root partition for 32-bit PowerPC +pub const ROOT_PPC: &str = "1de3f1ef-fa98-47b5-8dcd-4a860a654d78"; + +/// Root partition for 64-bit PowerPC Big Endian +pub const ROOT_PPC64: &str = "912ade1d-a839-4913-8964-a10eee08fbd2"; + +/// Root partition for 64-bit PowerPC Little Endian +pub const ROOT_PPC64_LE: &str = "c31c45e6-3f39-412e-80fb-4809c4980599"; + +/// Root partition for 32-bit RISC-V +pub const ROOT_RISCV32: &str = "60d5a7fe-8e7d-435c-b714-3dd8162144e1"; + +/// Root partition for 64-bit RISC-V +pub const ROOT_RISCV64: &str = "72ec70a6-cf74-40e6-bd49-4bda08e8f224"; + +/// Root partition for s390 architecture +pub const ROOT_S390: &str = "08a7acea-624c-4a20-91e8-6e0fa67d23f9"; + +/// Root partition for s390x architecture +pub const ROOT_S390X: &str = "5eead9a9-fe09-4a1e-a1d7-520d00531306"; + +/// Root partition for TILE-Gx architecture +pub const ROOT_TILEGX: &str = "c50cdd70-3862-4cc3-90e1-809a8c93ee2c"; + +/// Root partition for 32-bit x86 +pub const ROOT_X86: &str = "44479540-f297-41b2-9af7-d131d5f0458a"; + +/// Root partition for 64-bit x86/AMD64 +pub const ROOT_X86_64: &str = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"; + +// ============================================================================ +// USR PARTITIONS +// ============================================================================ + +/// /usr partition for Alpha architecture +pub const USR_ALPHA: &str = "e18cf08c-33ec-4c0d-8246-c6c6fb3da024"; + +/// /usr partition for ARC architecture +pub const USR_ARC: &str = "7978a683-6316-4922-bbee-38bff5a2fecc"; + +/// /usr partition for 32-bit ARM +pub const USR_ARM: &str = "7d0359a3-02b3-4f0a-865c-654403e70625"; + +/// /usr partition for 64-bit ARM/AArch64 +pub const USR_ARM64: &str = "b0e01050-ee5f-4390-949a-9101b17104e9"; + +/// /usr partition for Itanium/IA-64 +pub const USR_IA64: &str = "4301d2a6-4e3b-4b2a-bb94-9e0b2c4225ea"; + +/// /usr partition for 64-bit LoongArch +pub const USR_LOONGARCH64: &str = "e611c702-575c-4cbe-9a46-434fa0bf7e3f"; + +/// /usr partition for 32-bit MIPS Big Endian +pub const USR_MIPS: &str = "773b2abc-2a99-4398-8bf5-03baac40d02b"; + +/// /usr partition for 64-bit MIPS Big Endian +pub const USR_MIPS64: &str = "57e13958-7331-4365-8e6e-35eeee17c61b"; + +/// /usr partition for 32-bit MIPS Little Endian +pub const USR_MIPS_LE: &str = "0f4868e9-9952-4706-979f-3ed3a473e947"; + +/// /usr partition for 64-bit MIPS Little Endian +pub const USR_MIPS64_LE: &str = "c97c1f32-ba06-40b4-9f22-236061b08aa8"; + +/// /usr partition for PA-RISC +pub const USR_PARISC: &str = "dc4a4480-6917-4262-a4ec-db9384949f25"; + +/// /usr partition for 32-bit PowerPC +pub const USR_PPC: &str = "7d14fec5-cc71-415d-9d6c-06bf0b3c3eaf"; + +/// /usr partition for 64-bit PowerPC Big Endian +pub const USR_PPC64: &str = "2c9739e2-f068-46b3-9fd0-01c5a9afbcca"; + +/// /usr partition for 64-bit PowerPC Little Endian +pub const USR_PPC64_LE: &str = "15bb03af-77e7-4d4a-b12b-c0d084f7491c"; + +/// /usr partition for 32-bit RISC-V +pub const USR_RISCV32: &str = "b933fb22-5c3f-4f91-af90-e2bb0fa50702"; + +/// /usr partition for 64-bit RISC-V +pub const USR_RISCV64: &str = "beaec34b-8442-439b-a40b-984381ed097d"; + +/// /usr partition for s390 +pub const USR_S390: &str = "cd0f869b-d0fb-4ca0-b141-9ea87cc78d66"; + +/// /usr partition for s390x +pub const USR_S390X: &str = "8a4f5770-50aa-4ed3-874a-99b710db6fea"; + +/// /usr partition for TILE-Gx +pub const USR_TILEGX: &str = "55497029-c7c1-44cc-aa39-815ed1558630"; + +/// /usr partition for 32-bit x86 +pub const USR_X86: &str = "75250d76-8cc6-458e-bd66-bd47cc81a812"; + +/// /usr partition for 64-bit x86/AMD64 +pub const USR_X86_64: &str = "8484680c-9521-48c6-9c11-b0720656f69e"; + +// ============================================================================ +// ROOT VERITY PARTITIONS +// ============================================================================ + +/// Root verity partition for Alpha +pub const ROOT_VERITY_ALPHA: &str = "fc56d9e9-e6e5-4c06-be32-e74407ce09a5"; + +/// Root verity partition for ARC +pub const ROOT_VERITY_ARC: &str = "24b2d975-0f97-4521-afa1-cd531e421b8d"; + +/// Root verity partition for 32-bit ARM +pub const ROOT_VERITY_ARM: &str = "7386cdf2-203c-47a9-a498-f2ecce45a2d6"; + +/// Root verity partition for 64-bit ARM/AArch64 +pub const ROOT_VERITY_ARM64: &str = "df3300ce-d69f-4c92-978c-9bfb0f38d820"; + +/// Root verity partition for Itanium/IA-64 +pub const ROOT_VERITY_IA64: &str = "86ed10d5-b607-45bb-8957-d350f23d0571"; + +/// Root verity partition for 64-bit LoongArch +pub const ROOT_VERITY_LOONGARCH64: &str = "f3393b22-e9af-4613-a948-9d3bfbd0c535"; + +/// Root verity partition for 32-bit MIPS Big Endian +pub const ROOT_VERITY_MIPS: &str = "7a430799-f711-4c7e-8e5b-1d685bd48607"; + +/// Root verity partition for 64-bit MIPS Big Endian +pub const ROOT_VERITY_MIPS64: &str = "579536f8-6a33-4055-a95a-df2d5e2c42a8"; + +/// Root verity partition for 32-bit MIPS Little Endian +pub const ROOT_VERITY_MIPS_LE: &str = "d7d150d2-2a04-4a33-8f12-16651205ff7b"; + +/// Root verity partition for 64-bit MIPS Little Endian +pub const ROOT_VERITY_MIPS64_LE: &str = "16b417f8-3e06-4f57-8dd2-9b5232f41aa6"; + +/// Root verity partition for PA-RISC +pub const ROOT_VERITY_PARISC: &str = "d212a430-fbc5-49f9-a983-a7feef2b8d0e"; + +/// Root verity partition for 32-bit PowerPC +pub const ROOT_VERITY_PPC: &str = "98cfe649-1588-46dc-b2f0-add147424925"; + +/// Root verity partition for 64-bit PowerPC Big Endian +pub const ROOT_VERITY_PPC64: &str = "9225a9a3-3c19-4d89-b4f6-eeff88f17631"; + +/// Root verity partition for 64-bit PowerPC Little Endian +pub const ROOT_VERITY_PPC64_LE: &str = "906bd944-4589-4aae-a4e4-dd983917446a"; + +/// Root verity partition for 32-bit RISC-V +pub const ROOT_VERITY_RISCV32: &str = "ae0253be-1167-4007-ac68-43926c14c5de"; + +/// Root verity partition for 64-bit RISC-V +pub const ROOT_VERITY_RISCV64: &str = "b6ed5582-440b-4209-b8da-5ff7c419ea3d"; + +/// Root verity partition for s390 +pub const ROOT_VERITY_S390: &str = "7ac63b47-b25c-463b-8df8-b4a94e6c90e1"; + +/// Root verity partition for s390x +pub const ROOT_VERITY_S390X: &str = "b325bfbe-c7be-4ab8-8357-139e652d2f6b"; + +/// Root verity partition for TILE-Gx +pub const ROOT_VERITY_TILEGX: &str = "966061ec-28e4-4b2e-b4a5-1f0a825a1d84"; + +/// Root verity partition for 32-bit x86 +pub const ROOT_VERITY_X86: &str = "d13c5d3b-b5d1-422a-b29f-9454fdc89d76"; + +/// Root verity partition for 64-bit x86/AMD64 +pub const ROOT_VERITY_X86_64: &str = "2c7357ed-ebd2-46d9-aec1-23d437ec2bf5"; + +// ============================================================================ +// USR VERITY PARTITIONS +// ============================================================================ + +/// /usr verity partition for Alpha +pub const USR_VERITY_ALPHA: &str = "8cce0d25-c0d0-4a44-bd87-46331bf1df67"; + +/// /usr verity partition for ARC +pub const USR_VERITY_ARC: &str = "fca0598c-d880-4591-8c16-4eda05c7347c"; + +/// /usr verity partition for 32-bit ARM +pub const USR_VERITY_ARM: &str = "c215d751-7bcd-4649-be90-6627490a4c05"; + +/// /usr verity partition for 64-bit ARM/AArch64 +pub const USR_VERITY_ARM64: &str = "6e11a4e7-fbca-4ded-b9e9-e1a512bb664e"; + +/// /usr verity partition for Itanium/IA-64 +pub const USR_VERITY_IA64: &str = "6a491e03-3be7-4545-8e38-83320e0ea880"; + +/// /usr verity partition for 64-bit LoongArch +pub const USR_VERITY_LOONGARCH64: &str = "f46b2c26-59ae-48f0-9106-c50ed47f673d"; + +/// /usr verity partition for 32-bit MIPS Big Endian +pub const USR_VERITY_MIPS: &str = "6e5a1bc8-d223-49b7-bca8-37a5fcceb996"; + +/// /usr verity partition for 64-bit MIPS Big Endian +pub const USR_VERITY_MIPS64: &str = "81cf9d90-7458-4df4-8dcf-c8a3a404f09b"; + +/// /usr verity partition for 32-bit MIPS Little Endian +pub const USR_VERITY_MIPS_LE: &str = "46b98d8d-b55c-4e8f-aab3-37fca7f80752"; + +/// /usr verity partition for 64-bit MIPS Little Endian +pub const USR_VERITY_MIPS64_LE: &str = "3c3d61fe-b5f3-414d-bb71-8739a694a4ef"; + +/// /usr verity partition for PA-RISC +pub const USR_VERITY_PARISC: &str = "5843d618-ec37-48d7-9f12-cea8e08768b2"; + +/// /usr verity partition for 32-bit PowerPC +pub const USR_VERITY_PPC: &str = "df765d00-270e-49e5-bc75-f47bb2118b09"; + +/// /usr verity partition for 64-bit PowerPC Big Endian +pub const USR_VERITY_PPC64: &str = "bdb528a5-a259-475f-a87d-da53fa736a07"; + +/// /usr verity partition for 64-bit PowerPC Little Endian +pub const USR_VERITY_PPC64_LE: &str = "ee2b9983-21e8-4153-86d9-b6901a54d1ce"; + +/// /usr verity partition for 32-bit RISC-V +pub const USR_VERITY_RISCV32: &str = "cb1ee4e3-8cd0-4136-a0a4-aa61a32e8730"; + +/// /usr verity partition for 64-bit RISC-V +pub const USR_VERITY_RISCV64: &str = "8f1056be-9b05-47c4-81d6-be53128e5b54"; + +/// /usr verity partition for s390 +pub const USR_VERITY_S390: &str = "b663c618-e7bc-4d6d-90aa-11b756bb1797"; + +/// /usr verity partition for s390x +pub const USR_VERITY_S390X: &str = "31741cc4-1a2a-4111-a581-e00b447d2d06"; + +/// /usr verity partition for TILE-Gx +pub const USR_VERITY_TILEGX: &str = "2fb4bf56-07fa-42da-8132-6b139f2026ae"; + +/// /usr verity partition for 32-bit x86 +pub const USR_VERITY_X86: &str = "8f461b0d-14ee-4e81-9aa9-049b6fb97abd"; + +/// /usr verity partition for 64-bit x86/AMD64 +pub const USR_VERITY_X86_64: &str = "77ff5f63-e7b6-4633-acf4-1565b864c0e6"; + +// ============================================================================ +// ROOT VERITY SIGNATURE PARTITIONS +// ============================================================================ + +/// Root verity signature partition for Alpha +pub const ROOT_VERITY_SIG_ALPHA: &str = "d46495b7-a053-414f-80f7-700c99921ef8"; + +/// Root verity signature partition for ARC +pub const ROOT_VERITY_SIG_ARC: &str = "143a70ba-cbd3-4f06-919f-6c05683a78bc"; + +/// Root verity signature partition for 32-bit ARM +pub const ROOT_VERITY_SIG_ARM: &str = "42b0455f-eb11-491d-98d3-56145ba9d037"; + +/// Root verity signature partition for 64-bit ARM/AArch64 +pub const ROOT_VERITY_SIG_ARM64: &str = "6db69de6-29f4-4758-a7a5-962190f00ce3"; + +/// Root verity signature partition for Itanium/IA-64 +pub const ROOT_VERITY_SIG_IA64: &str = "e98b36ee-32ba-4882-9b12-0ce14655f46a"; + +/// Root verity signature partition for 64-bit LoongArch +pub const ROOT_VERITY_SIG_LOONGARCH64: &str = "5afb67eb-ecc8-4f85-ae8e-ac1e7c50e7d0"; + +/// Root verity signature partition for 32-bit MIPS Big Endian +pub const ROOT_VERITY_SIG_MIPS: &str = "bba210a2-9c5d-45ee-9e87-ff2ccbd002d0"; + +/// Root verity signature partition for 64-bit MIPS Big Endian +pub const ROOT_VERITY_SIG_MIPS64: &str = "43ce94d4-0f3d-4999-8250-b9deafd98e6e"; + +/// Root verity signature partition for 32-bit MIPS Little Endian +pub const ROOT_VERITY_SIG_MIPS_LE: &str = "c919cc1f-4456-4eff-918c-f75e94525ca5"; + +/// Root verity signature partition for 64-bit MIPS Little Endian +pub const ROOT_VERITY_SIG_MIPS64_LE: &str = "904e58ef-5c65-4a31-9c57-6af5fc7c5de7"; + +/// Root verity signature partition for PA-RISC +pub const ROOT_VERITY_SIG_PARISC: &str = "15de6170-65d3-431c-916e-b0dcd8393f25"; + +/// Root verity signature partition for 32-bit PowerPC +pub const ROOT_VERITY_SIG_PPC: &str = "1b31b5aa-add9-463a-b2ed-bd467fc857e7"; + +/// Root verity signature partition for 64-bit PowerPC Big Endian +pub const ROOT_VERITY_SIG_PPC64: &str = "f5e2c20c-45b2-4ffa-bce9-2a60737e1aaf"; + +/// Root verity signature partition for 64-bit PowerPC Little Endian +pub const ROOT_VERITY_SIG_PPC64_LE: &str = "d4a236e7-e873-4c07-bf1d-bf6cf7f1c3c6"; + +/// Root verity signature partition for 32-bit RISC-V +pub const ROOT_VERITY_SIG_RISCV32: &str = "3a112a75-8729-4380-b4cf-764d79934448"; + +/// Root verity signature partition for 64-bit RISC-V +pub const ROOT_VERITY_SIG_RISCV64: &str = "efe0f087-ea8d-4469-821a-4c2a96a8386a"; + +/// Root verity signature partition for s390 +pub const ROOT_VERITY_SIG_S390: &str = "3482388e-4254-435a-a241-766a065f9960"; + +/// Root verity signature partition for s390x +pub const ROOT_VERITY_SIG_S390X: &str = "c80187a5-73a3-491a-901a-017c3fa953e9"; + +/// Root verity signature partition for TILE-Gx +pub const ROOT_VERITY_SIG_TILEGX: &str = "b3671439-97b0-4a53-90f7-2d5a8f3ad47b"; + +/// Root verity signature partition for 32-bit x86 +pub const ROOT_VERITY_SIG_X86: &str = "5996fc05-109c-48de-808b-23fa0830b676"; + +/// Root verity signature partition for 64-bit x86/AMD64 +pub const ROOT_VERITY_SIG_X86_64: &str = "41092b05-9fc8-4523-994f-2def0408b176"; + +// ============================================================================ +// USR VERITY SIGNATURE PARTITIONS +// ============================================================================ + +/// /usr verity signature partition for Alpha +pub const USR_VERITY_SIG_ALPHA: &str = "5c6e1c76-076a-457a-a0fe-f3b4cd21ce6e"; + +/// /usr verity signature partition for ARC +pub const USR_VERITY_SIG_ARC: &str = "94f9a9a1-9971-427a-a400-50cb297f0f35"; + +/// /usr verity signature partition for 32-bit ARM +pub const USR_VERITY_SIG_ARM: &str = "d7ff812f-37d1-4902-a810-d76ba57b975a"; + +/// /usr verity signature partition for 64-bit ARM/AArch64 +pub const USR_VERITY_SIG_ARM64: &str = "c23ce4ff-44bd-4b00-b2d4-b41b3419e02a"; + +/// /usr verity signature partition for Itanium/IA-64 +pub const USR_VERITY_SIG_IA64: &str = "8de58bc2-2a43-460d-b14e-a76e4a17b47f"; + +/// /usr verity signature partition for 64-bit LoongArch +pub const USR_VERITY_SIG_LOONGARCH64: &str = "b024f315-d330-444c-8461-44bbde524e99"; + +/// /usr verity signature partition for 32-bit MIPS Big Endian +pub const USR_VERITY_SIG_MIPS: &str = "97ae158d-f216-497b-8057-f7f905770f54"; + +/// /usr verity signature partition for 64-bit MIPS Big Endian +pub const USR_VERITY_SIG_MIPS64: &str = "05816ce2-dd40-4ac6-a61d-37d32dc1ba7d"; + +/// /usr verity signature partition for 32-bit MIPS Little Endian +pub const USR_VERITY_SIG_MIPS_LE: &str = "3e23ca0b-a4bc-4b4e-8087-5ab6a26aa8a9"; + +/// /usr verity signature partition for 64-bit MIPS Little Endian +pub const USR_VERITY_SIG_MIPS64_LE: &str = "f2c2c7ee-adcc-4351-b5c6-ee9816b66e16"; + +/// /usr verity signature partition for PA-RISC +pub const USR_VERITY_SIG_PARISC: &str = "450dd7d1-3224-45ec-9cf2-a43a346d71ee"; + +/// /usr verity signature partition for 32-bit PowerPC +pub const USR_VERITY_SIG_PPC: &str = "7007891d-d371-4a80-86a4-5cb875b9302e"; + +/// /usr verity signature partition for 64-bit PowerPC Big Endian +pub const USR_VERITY_SIG_PPC64: &str = "0b888863-d7f8-4d9e-9766-239fce4d58af"; + +/// /usr verity signature partition for 64-bit PowerPC Little Endian +pub const USR_VERITY_SIG_PPC64_LE: &str = "c8bfbd1e-268e-4521-8bba-bf314c399557"; + +/// /usr verity signature partition for 32-bit RISC-V +pub const USR_VERITY_SIG_RISCV32: &str = "c3836a13-3137-45ba-b583-b16c50fe5eb4"; + +/// /usr verity signature partition for 64-bit RISC-V +pub const USR_VERITY_SIG_RISCV64: &str = "d2f9000a-7a18-453f-b5cd-4d32f77a7b32"; + +/// /usr verity signature partition for s390 +pub const USR_VERITY_SIG_S390: &str = "17440e4f-a8d0-467f-a46e-3912ae6ef2c5"; + +/// /usr verity signature partition for s390x +pub const USR_VERITY_SIG_S390X: &str = "3f324816-667b-46ae-86ee-9b0c0c6c11b4"; + +/// /usr verity signature partition for TILE-Gx +pub const USR_VERITY_SIG_TILEGX: &str = "4ede75e2-6ccc-4cc8-b9c7-70334b087510"; + +/// /usr verity signature partition for 32-bit x86 +pub const USR_VERITY_SIG_X86: &str = "974a71c0-de41-43c3-be5d-5c5ccd1ad2c0"; + +/// /usr verity signature partition for 64-bit x86/AMD64 +pub const USR_VERITY_SIG_X86_64: &str = "e7bb33fb-06cf-4e81-8273-e543b413e2e2"; + +// ============================================================================ +// OTHER SPECIAL PARTITION TYPES +// ============================================================================ + +/// EFI System Partition (ESP) for UEFI boot +pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; + +/// Extended Boot Loader Partition +pub const XBOOTLDR: &str = "bc13c2ff-59e6-4262-a352-b275fd6f7172"; + +/// Swap partition +pub const SWAP: &str = "0657fd6d-a4ab-43c4-84e5-0933c84b4f4f"; + +/// Home partition (/home) +pub const HOME: &str = "933ac7e1-2eb4-4f13-b844-0e14e2aef915"; + +/// Server data partition (/srv) +pub const SRV: &str = "3b8f8425-20e0-4f3b-907f-1a25a76f98e8"; + +/// Variable data partition (/var) +pub const VAR: &str = "4d21b016-b534-45c2-a9fb-5c16e091fd2d"; + +/// Temporary data partition (/var/tmp) +pub const TMP: &str = "7ec6f557-3bc5-4aca-b293-16ef5df639d1"; + +/// Generic Linux filesystem data partition +pub const LINUX_DATA: &str = "0fc63daf-8483-4772-8e79-3d69d8477de4"; + +// ============================================================================ +// ARCHITECTURE-SPECIFIC HELPERS +// ============================================================================ + +/// Returns the root partition GUID for the current architecture. +/// +/// This is a compile-time constant function that selects the appropriate +/// root partition type GUID based on the target architecture and endianness. +pub const fn this_arch_root() -> &'static str { + cfg_if::cfg_if! { + if #[cfg(target_arch = "x86_64")] { + ROOT_X86_64 + } else if #[cfg(target_arch = "aarch64")] { + ROOT_ARM64 + } else if #[cfg(target_arch = "s390x")] { + ROOT_S390X + } else if #[cfg(all(target_arch = "powerpc64", target_endian = "big"))] { + ROOT_PPC64 + } else if #[cfg(all(target_arch = "powerpc64", target_endian = "little"))] { + ROOT_PPC64_LE + } else { + compile_error!("Unsupported architecture") + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[ignore = "Only run manually to validate against upstream spec"] + fn test_uuids_against_spec() { + // This test validates our partition type UUIDs against the upstream + // Discoverable Partitions Specification. The spec is committed to the + // repo at fixtures/discoverable_partitions_specification.md + // + // Spec source: https://github.com/uapi-group/specifications/blob/6f3a5dd31009456561eaa9f6fcfe7769ab97eb50/specs/discoverable_partitions_specification.md + + let spec_content = include_str!("fixtures/discoverable_partitions_specification.md"); + + // Parse the markdown tables and extract partition name -> UUID mappings + let mut spec_uuids: std::collections::HashMap<&str, &str> = + std::collections::HashMap::new(); + + // Regex to match table rows with partition type UUIDs + // Format: | _Name_ | `uuid` ... | ... | ... | + let re = regex::Regex::new( + r"(?m)^\|\s*_(.+?)_\s*\|\s*`([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`" + ) + .unwrap(); + + for cap in re.captures_iter(&spec_content) { + let name = cap.get(1).unwrap().as_str(); + let uuid = cap.get(2).unwrap().as_str(); + spec_uuids.insert(name, uuid); + } + + // Verify we parsed a reasonable number of entries + assert!( + spec_uuids.len() > 100, + "Expected to parse over 100 UUIDs, got {}", + spec_uuids.len() + ); + + // Now cross-reference our constants against the spec + macro_rules! check_uuid { + ($name:expr, $const_val:expr) => { + if let Some(&spec_uuid) = spec_uuids.get($name) { + assert_eq!( + $const_val, spec_uuid, + "UUID mismatch for {}: our value '{}' != spec value '{}'", + $name, $const_val, spec_uuid + ); + } else { + panic!("No spec entry found for {}", $name); + } + }; + } + + // Root Partitions + check_uuid!("Root Partition (Alpha)", ROOT_ALPHA); + check_uuid!("Root Partition (ARC)", ROOT_ARC); + check_uuid!("Root Partition (32-bit ARM)", ROOT_ARM); + check_uuid!("Root Partition (64-bit ARM/AArch64)", ROOT_ARM64); + check_uuid!("Root Partition (Itanium/IA-64)", ROOT_IA64); + check_uuid!("Root Partition (LoongArch 64-bit)", ROOT_LOONGARCH64); + check_uuid!( + "Root Partition (32-bit MIPS LittleEndian (mipsel))", + ROOT_MIPS_LE + ); + check_uuid!( + "Root Partition (64-bit MIPS LittleEndian (mips64el))", + ROOT_MIPS64_LE + ); + check_uuid!("Root Partition (32-bit MIPS BigEndian (mips))", ROOT_MIPS); + check_uuid!( + "Root Partition (64-bit MIPS BigEndian (mips64))", + ROOT_MIPS64 + ); + check_uuid!("Root Partition (HPPA/PARISC)", ROOT_PARISC); + check_uuid!("Root Partition (32-bit PowerPC)", ROOT_PPC); + check_uuid!("Root Partition (64-bit PowerPC BigEndian)", ROOT_PPC64); + check_uuid!( + "Root Partition (64-bit PowerPC LittleEndian)", + ROOT_PPC64_LE + ); + check_uuid!("Root Partition (RISC-V 32-bit)", ROOT_RISCV32); + check_uuid!("Root Partition (RISC-V 64-bit)", ROOT_RISCV64); + check_uuid!("Root Partition (s390)", ROOT_S390); + check_uuid!("Root Partition (s390x)", ROOT_S390X); + check_uuid!("Root Partition (TILE-Gx)", ROOT_TILEGX); + check_uuid!("Root Partition (x86)", ROOT_X86); + check_uuid!("Root Partition (amd64/x86_64)", ROOT_X86_64); + + // USR Partitions + check_uuid!("`/usr/` Partition (Alpha)", USR_ALPHA); + check_uuid!("`/usr/` Partition (ARC)", USR_ARC); + check_uuid!("`/usr/` Partition (32-bit ARM)", USR_ARM); + check_uuid!("`/usr/` Partition (64-bit ARM/AArch64)", USR_ARM64); + check_uuid!("`/usr/` Partition (Itanium/IA-64)", USR_IA64); + check_uuid!("`/usr/` Partition (LoongArch 64-bit)", USR_LOONGARCH64); + check_uuid!("`/usr/` Partition (32-bit MIPS BigEndian (mips))", USR_MIPS); + check_uuid!( + "`/usr/` Partition (64-bit MIPS BigEndian (mips64))", + USR_MIPS64 + ); + check_uuid!( + "`/usr/` Partition (32-bit MIPS LittleEndian (mipsel))", + USR_MIPS_LE + ); + check_uuid!( + "`/usr/` Partition (64-bit MIPS LittleEndian (mips64el))", + USR_MIPS64_LE + ); + check_uuid!("`/usr/` Partition (HPPA/PARISC)", USR_PARISC); + check_uuid!("`/usr/` Partition (32-bit PowerPC)", USR_PPC); + check_uuid!("`/usr/` Partition (64-bit PowerPC BigEndian)", USR_PPC64); + check_uuid!( + "`/usr/` Partition (64-bit PowerPC LittleEndian)", + USR_PPC64_LE + ); + check_uuid!("`/usr/` Partition (RISC-V 32-bit)", USR_RISCV32); + check_uuid!("`/usr/` Partition (RISC-V 64-bit)", USR_RISCV64); + check_uuid!("`/usr/` Partition (s390)", USR_S390); + check_uuid!("`/usr/` Partition (s390x)", USR_S390X); + check_uuid!("`/usr/` Partition (TILE-Gx)", USR_TILEGX); + check_uuid!("`/usr/` Partition (x86)", USR_X86); + check_uuid!("`/usr/` Partition (amd64/x86_64)", USR_X86_64); + + // Root Verity Partitions + check_uuid!("Root Verity Partition (Alpha)", ROOT_VERITY_ALPHA); + check_uuid!("Root Verity Partition (ARC)", ROOT_VERITY_ARC); + check_uuid!("Root Verity Partition (32-bit ARM)", ROOT_VERITY_ARM); + check_uuid!( + "Root Verity Partition (64-bit ARM/AArch64)", + ROOT_VERITY_ARM64 + ); + check_uuid!("Root Verity Partition (Itanium/IA-64)", ROOT_VERITY_IA64); + check_uuid!( + "Root Verity Partition (LoongArch 64-bit)", + ROOT_VERITY_LOONGARCH64 + ); + check_uuid!( + "Root Verity Partition (32-bit MIPS BigEndian (mips))", + ROOT_VERITY_MIPS + ); + check_uuid!( + "Root Verity Partition (64-bit MIPS BigEndian (mips64))", + ROOT_VERITY_MIPS64 + ); + check_uuid!( + "Root Verity Partition (32-bit MIPS LittleEndian (mipsel))", + ROOT_VERITY_MIPS_LE + ); + check_uuid!( + "Root Verity Partition (64-bit MIPS LittleEndian (mips64el))", + ROOT_VERITY_MIPS64_LE + ); + check_uuid!("Root Verity Partition (HPPA/PARISC)", ROOT_VERITY_PARISC); + check_uuid!("Root Verity Partition (32-bit PowerPC)", ROOT_VERITY_PPC); + check_uuid!( + "Root Verity Partition (64-bit PowerPC BigEndian)", + ROOT_VERITY_PPC64 + ); + check_uuid!( + "Root Verity Partition (64-bit PowerPC LittleEndian)", + ROOT_VERITY_PPC64_LE + ); + check_uuid!("Root Verity Partition (RISC-V 32-bit)", ROOT_VERITY_RISCV32); + check_uuid!("Root Verity Partition (RISC-V 64-bit)", ROOT_VERITY_RISCV64); + check_uuid!("Root Verity Partition (s390)", ROOT_VERITY_S390); + check_uuid!("Root Verity Partition (s390x)", ROOT_VERITY_S390X); + check_uuid!("Root Verity Partition (TILE-Gx)", ROOT_VERITY_TILEGX); + check_uuid!("Root Verity Partition (x86)", ROOT_VERITY_X86); + check_uuid!("Root Verity Partition (amd64/x86_64)", ROOT_VERITY_X86_64); + + // USR Verity Partitions + check_uuid!("`/usr/` Verity Partition (Alpha)", USR_VERITY_ALPHA); + check_uuid!("`/usr/` Verity Partition (ARC)", USR_VERITY_ARC); + check_uuid!("`/usr/` Verity Partition (32-bit ARM)", USR_VERITY_ARM); + check_uuid!( + "`/usr/` Verity Partition (64-bit ARM/AArch64)", + USR_VERITY_ARM64 + ); + check_uuid!("`/usr/` Verity Partition (Itanium/IA-64)", USR_VERITY_IA64); + check_uuid!( + "`/usr/` Verity Partition (LoongArch 64-bit)", + USR_VERITY_LOONGARCH64 + ); + check_uuid!( + "`/usr/` Verity Partition (32-bit MIPS BigEndian (mips))", + USR_VERITY_MIPS + ); + check_uuid!( + "`/usr/` Verity Partition (64-bit MIPS BigEndian (mips64))", + USR_VERITY_MIPS64 + ); + check_uuid!( + "`/usr/` Verity Partition (32-bit MIPS LittleEndian (mipsel))", + USR_VERITY_MIPS_LE + ); + check_uuid!( + "`/usr/` Verity Partition (64-bit MIPS LittleEndian (mips64el))", + USR_VERITY_MIPS64_LE + ); + check_uuid!("`/usr/` Verity Partition (HPPA/PARISC)", USR_VERITY_PARISC); + check_uuid!("`/usr/` Verity Partition (32-bit PowerPC)", USR_VERITY_PPC); + check_uuid!( + "`/usr/` Verity Partition (64-bit PowerPC BigEndian)", + USR_VERITY_PPC64 + ); + check_uuid!( + "`/usr/` Verity Partition (64-bit PowerPC LittleEndian)", + USR_VERITY_PPC64_LE + ); + check_uuid!( + "`/usr/` Verity Partition (RISC-V 32-bit)", + USR_VERITY_RISCV32 + ); + check_uuid!( + "`/usr/` Verity Partition (RISC-V 64-bit)", + USR_VERITY_RISCV64 + ); + check_uuid!("`/usr/` Verity Partition (s390)", USR_VERITY_S390); + check_uuid!("`/usr/` Verity Partition (s390x)", USR_VERITY_S390X); + check_uuid!("`/usr/` Verity Partition (TILE-Gx)", USR_VERITY_TILEGX); + check_uuid!("`/usr/` Verity Partition (x86)", USR_VERITY_X86); + check_uuid!("`/usr/` Verity Partition (amd64/x86_64)", USR_VERITY_X86_64); + + // Root Verity Signature Partitions + check_uuid!( + "Root Verity Signature Partition (Alpha)", + ROOT_VERITY_SIG_ALPHA + ); + check_uuid!("Root Verity Signature Partition (ARC)", ROOT_VERITY_SIG_ARC); + check_uuid!( + "Root Verity Signature Partition (32-bit ARM)", + ROOT_VERITY_SIG_ARM + ); + check_uuid!( + "Root Verity Signature Partition (64-bit ARM/AArch64)", + ROOT_VERITY_SIG_ARM64 + ); + check_uuid!( + "Root Verity Signature Partition (Itanium/IA-64)", + ROOT_VERITY_SIG_IA64 + ); + check_uuid!( + "Root Verity Signature Partition (LoongArch 64-bit)", + ROOT_VERITY_SIG_LOONGARCH64 + ); + check_uuid!( + "Root Verity Signature Partition (32-bit MIPS BigEndian (mips))", + ROOT_VERITY_SIG_MIPS + ); + check_uuid!( + "Root Verity Signature Partition (64-bit MIPS BigEndian (mips64))", + ROOT_VERITY_SIG_MIPS64 + ); + check_uuid!( + "Root Verity Signature Partition (32-bit MIPS LittleEndian (mipsel))", + ROOT_VERITY_SIG_MIPS_LE + ); + check_uuid!( + "Root Verity Signature Partition (64-bit MIPS LittleEndian (mips64el))", + ROOT_VERITY_SIG_MIPS64_LE + ); + check_uuid!( + "Root Verity Signature Partition (HPPA/PARISC)", + ROOT_VERITY_SIG_PARISC + ); + check_uuid!( + "Root Verity Signature Partition (32-bit PowerPC)", + ROOT_VERITY_SIG_PPC + ); + check_uuid!( + "Root Verity Signature Partition (64-bit PowerPC BigEndian)", + ROOT_VERITY_SIG_PPC64 + ); + check_uuid!( + "Root Verity Signature Partition (64-bit PowerPC LittleEndian)", + ROOT_VERITY_SIG_PPC64_LE + ); + check_uuid!( + "Root Verity Signature Partition (RISC-V 32-bit)", + ROOT_VERITY_SIG_RISCV32 + ); + check_uuid!( + "Root Verity Signature Partition (RISC-V 64-bit)", + ROOT_VERITY_SIG_RISCV64 + ); + check_uuid!( + "Root Verity Signature Partition (s390)", + ROOT_VERITY_SIG_S390 + ); + check_uuid!( + "Root Verity Signature Partition (s390x)", + ROOT_VERITY_SIG_S390X + ); + check_uuid!( + "Root Verity Signature Partition (TILE-Gx)", + ROOT_VERITY_SIG_TILEGX + ); + check_uuid!("Root Verity Signature Partition (x86)", ROOT_VERITY_SIG_X86); + check_uuid!( + "Root Verity Signature Partition (amd64/x86_64)", + ROOT_VERITY_SIG_X86_64 + ); + + // USR Verity Signature Partitions + check_uuid!( + "`/usr/` Verity Signature Partition (Alpha)", + USR_VERITY_SIG_ALPHA + ); + check_uuid!( + "`/usr/` Verity Signature Partition (ARC)", + USR_VERITY_SIG_ARC + ); + check_uuid!( + "`/usr/` Verity Signature Partition (32-bit ARM)", + USR_VERITY_SIG_ARM + ); + check_uuid!( + "`/usr/` Verity Signature Partition (64-bit ARM/AArch64)", + USR_VERITY_SIG_ARM64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (Itanium/IA-64)", + USR_VERITY_SIG_IA64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (LoongArch 64-bit)", + USR_VERITY_SIG_LOONGARCH64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (32-bit MIPS BigEndian (mips))", + USR_VERITY_SIG_MIPS + ); + check_uuid!( + "`/usr/` Verity Signature Partition (64-bit MIPS BigEndian (mips64))", + USR_VERITY_SIG_MIPS64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (32-bit MIPS LittleEndian (mipsel))", + USR_VERITY_SIG_MIPS_LE + ); + check_uuid!( + "`/usr/` Verity Signature Partition (64-bit MIPS LittleEndian (mips64el))", + USR_VERITY_SIG_MIPS64_LE + ); + check_uuid!( + "`/usr/` Verity Signature Partition (HPPA/PARISC)", + USR_VERITY_SIG_PARISC + ); + check_uuid!( + "`/usr/` Verity Signature Partition (32-bit PowerPC)", + USR_VERITY_SIG_PPC + ); + check_uuid!( + "`/usr/` Verity Signature Partition (64-bit PowerPC BigEndian)", + USR_VERITY_SIG_PPC64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (64-bit PowerPC LittleEndian)", + USR_VERITY_SIG_PPC64_LE + ); + check_uuid!( + "`/usr/` Verity Signature Partition (RISC-V 32-bit)", + USR_VERITY_SIG_RISCV32 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (RISC-V 64-bit)", + USR_VERITY_SIG_RISCV64 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (s390)", + USR_VERITY_SIG_S390 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (s390x)", + USR_VERITY_SIG_S390X + ); + check_uuid!( + "`/usr/` Verity Signature Partition (TILE-Gx)", + USR_VERITY_SIG_TILEGX + ); + check_uuid!( + "`/usr/` Verity Signature Partition (x86)", + USR_VERITY_SIG_X86 + ); + check_uuid!( + "`/usr/` Verity Signature Partition (amd64/x86_64)", + USR_VERITY_SIG_X86_64 + ); + + // Other special partition types + check_uuid!("EFI System Partition", ESP); + check_uuid!("Extended Boot Loader Partition", XBOOTLDR); + check_uuid!("Swap", SWAP); + check_uuid!("Home Partition", HOME); + check_uuid!("Server Data Partition", SRV); + check_uuid!("Variable Data Partition", VAR); + check_uuid!("Temporary Data Partition", TMP); + check_uuid!("Generic Linux Data Partition", LINUX_DATA); + } +} diff --git a/crates/lib/src/fixtures/discoverable_partitions_specification.md b/crates/lib/src/fixtures/discoverable_partitions_specification.md new file mode 100644 index 000000000..337b4acb5 --- /dev/null +++ b/crates/lib/src/fixtures/discoverable_partitions_specification.md @@ -0,0 +1,439 @@ +--- +title: Discoverable Partitions Specification +category: Concepts +layout: default +version: 1 +SPDX-License-Identifier: CC-BY-4.0 +--- +# The Discoverable Partitions Specification (DPS) + +_TL;DR: Let's automatically discover, mount and enable the root partition, +`/home/`, `/srv/`, `/var/` and `/var/tmp/` and the swap partitions based on +GUID Partition Tables (GPT)!_ + +This specification describes the use of GUID Partition Table (GPT) UUIDs to +enable automatic discovery of partitions and their intended mountpoints. +Traditionally Linux has made little use of partition types, mostly just +defining one UUID for file system/data partitions and another one for swap +partitions. With this specification, we introduce additional partition types +for specific uses. This has many benefits: + +* OS installers can automatically discover and make sense of partitions of + existing Linux installations. +* The OS can discover and mount the necessary file systems with a non-existent + or incomplete `/etc/fstab` file and without the `root=` kernel command line + option. +* Container managers (such as nspawn and libvirt-lxc) can introspect and set up + file systems contained in GPT disk images automatically and mount them to the + right places, thus allowing booting the same, identical images on bare metal + and in Linux containers. This enables true, natural portability of disk + images between physical machines and Linux containers. +* As a help to administrators and users partition manager tools can show more + descriptive information about partitions tables. + +Note that the OS side of this specification is currently implemented in +[systemd](https://systemd.io/) 211 and newer in the +[systemd-gpt-auto-generator(8)](https://www.freedesktop.org/software/systemd/man/systemd-gpt-auto-generator.html) +generator tool. Note that automatic discovery of the root only works if the +boot loader communicates this information to the OS, by implementing the +[Boot Loader Interface](https://systemd.io/BOOT_LOADER_INTERFACE). + +## Defined Partition Type UUIDs + +| Name | Partition Type UUID | Allowed File Systems | Explanation | +|------|---------------------|----------------------|-------------| +| _Root Partition (Alpha)_ | `6523f8ae-3eb1-4e2a-a05a-18b695ae656f` `SD_GPT_ROOT_ALPHA` | Any native, optionally in LUKS | On systems with matching architecture, the first partition with this type UUID on the disk containing the active EFI ESP is automatically mounted to the root directory `/`. If the partition is encrypted with LUKS or has dm-verity integrity data (see below), the device mapper file will be named `/dev/mapper/root`. | +| _Root Partition (ARC)_ | `d27f46ed-2919-4cb8-bd25-9531f3c16534` `SD_GPT_ROOT_ARC` | ditto | ditto | +| _Root Partition (32-bit ARM)_ | `69dad710-2ce4-4e3c-b16c-21a1d49abed3` `SD_GPT_ROOT_ARM` | ditto | ditto | +| _Root Partition (64-bit ARM/AArch64)_ | `b921b045-1df0-41c3-af44-4c6f280d3fae` `SD_GPT_ROOT_ARM64` | ditto | ditto | +| _Root Partition (Itanium/IA-64)_ | `993d8d3d-f80e-4225-855a-9daf8ed7ea97` `SD_GPT_ROOT_IA64` | ditto | ditto | +| _Root Partition (LoongArch 64-bit)_ | `77055800-792c-4f94-b39a-98c91b762bb6` `SD_GPT_ROOT_LOONGARCH64` | ditto | ditto | +| _Root Partition (32-bit MIPS BigEndian (mips))_ | `e9434544-6e2c-47cc-bae2-12d6deafb44c` | ditto | ditto | +| _Root Partition (64-bit MIPS BigEndian (mips64))_ | `d113af76-80ef-41b4-bdb6-0cff4d3d4a25` | ditto | ditto | +| _Root Partition (32-bit MIPS LittleEndian (mipsel))_ | `37c58c8a-d913-4156-a25f-48b1b64e07f0` `SD_GPT_ROOT_MIPS_LE` | ditto | ditto | +| _Root Partition (64-bit MIPS LittleEndian (mips64el))_ | `700bda43-7a34-4507-b179-eeb93d7a7ca3` `SD_GPT_ROOT_MIPS64_LE` | ditto | ditto | +| _Root Partition (HPPA/PARISC)_ | `1aacdb3b-5444-4138-bd9e-e5c2239b2346` `SD_GPT_ROOT_PARISC` | ditto | ditto | +| _Root Partition (32-bit PowerPC)_ | `1de3f1ef-fa98-47b5-8dcd-4a860a654d78` `SD_GPT_ROOT_PPC` | ditto | ditto | +| _Root Partition (64-bit PowerPC BigEndian)_ | `912ade1d-a839-4913-8964-a10eee08fbd2` `SD_GPT_ROOT_PPC64` | ditto | ditto | +| _Root Partition (64-bit PowerPC LittleEndian)_ | `c31c45e6-3f39-412e-80fb-4809c4980599` `SD_GPT_ROOT_PPC64_LE` | ditto | ditto | +| _Root Partition (RISC-V 32-bit)_ | `60d5a7fe-8e7d-435c-b714-3dd8162144e1` `SD_GPT_ROOT_RISCV32` | ditto | ditto | +| _Root Partition (RISC-V 64-bit)_ | `72ec70a6-cf74-40e6-bd49-4bda08e8f224` `SD_GPT_ROOT_RISCV64` | ditto | ditto | +| _Root Partition (s390)_ | `08a7acea-624c-4a20-91e8-6e0fa67d23f9` `SD_GPT_ROOT_S390` | ditto | ditto | +| _Root Partition (s390x)_ | `5eead9a9-fe09-4a1e-a1d7-520d00531306` `SD_GPT_ROOT_S390X` | ditto | ditto | +| _Root Partition (TILE-Gx)_ | `c50cdd70-3862-4cc3-90e1-809a8c93ee2c` `SD_GPT_ROOT_TILEGX` | ditto | ditto | +| _Root Partition (x86)_ | `44479540-f297-41b2-9af7-d131d5f0458a` `SD_GPT_ROOT_X86` | ditto | ditto | +| _Root Partition (amd64/x86_64)_ | `4f68bce3-e8cd-4db1-96e7-fbcaf984b709` `SD_GPT_ROOT_X86_64` | ditto | ditto | +| _`/usr/` Partition (Alpha)_ | `e18cf08c-33ec-4c0d-8246-c6c6fb3da024` `SD_GPT_USR_ALPHA` | Any native, optionally in LUKS | Similar semantics to root partition, but just the `/usr/` partition. | +| _`/usr/` Partition (ARC)_ | `7978a683-6316-4922-bbee-38bff5a2fecc` `SD_GPT_USR_ARC` | ditto | ditto | +| _`/usr/` Partition (32-bit ARM)_ | `7d0359a3-02b3-4f0a-865c-654403e70625` `SD_GPT_USR_ARM` | ditto | ditto | +| _`/usr/` Partition (64-bit ARM/AArch64)_ | `b0e01050-ee5f-4390-949a-9101b17104e9` `SD_GPT_USR_ARM64` | ditto | ditto | +| _`/usr/` Partition (Itanium/IA-64)_ | `4301d2a6-4e3b-4b2a-bb94-9e0b2c4225ea` `SD_GPT_USR_IA64` | ditto | ditto | +| _`/usr/` Partition (LoongArch 64-bit)_ | `e611c702-575c-4cbe-9a46-434fa0bf7e3f` `SD_GPT_USR_LOONGARCH64` | ditto | ditto | +| _`/usr/` Partition (32-bit MIPS BigEndian (mips))_ | `773b2abc-2a99-4398-8bf5-03baac40d02b` | ditto | ditto | +| _`/usr/` Partition (64-bit MIPS BigEndian (mips64))_ | `57e13958-7331-4365-8e6e-35eeee17c61b` | ditto | ditto | +| _`/usr/` Partition (32-bit MIPS LittleEndian (mipsel))_ | `0f4868e9-9952-4706-979f-3ed3a473e947` `SD_GPT_USR_MIPS_LE` | ditto | ditto | +| _`/usr/` Partition (64-bit MIPS LittleEndian (mips64el))_ | `c97c1f32-ba06-40b4-9f22-236061b08aa8` `SD_GPT_USR_MIPS64_LE` | ditto | ditto | +| _`/usr/` Partition (HPPA/PARISC)_ | `dc4a4480-6917-4262-a4ec-db9384949f25` `SD_GPT_USR_PARISC` | ditto | ditto | +| _`/usr/` Partition (32-bit PowerPC)_ | `7d14fec5-cc71-415d-9d6c-06bf0b3c3eaf` `SD_GPT_USR_PPC` | ditto | ditto | +| _`/usr/` Partition (64-bit PowerPC BigEndian)_ | `2c9739e2-f068-46b3-9fd0-01c5a9afbcca` `SD_GPT_USR_PPC64` | ditto | ditto | +| _`/usr/` Partition (64-bit PowerPC LittleEndian)_ | `15bb03af-77e7-4d4a-b12b-c0d084f7491c` `SD_GPT_USR_PPC64_LE` | ditto | ditto | +| _`/usr/` Partition (RISC-V 32-bit)_ | `b933fb22-5c3f-4f91-af90-e2bb0fa50702` `SD_GPT_USR_RISCV32` | ditto | ditto | +| _`/usr/` Partition (RISC-V 64-bit)_ | `beaec34b-8442-439b-a40b-984381ed097d` `SD_GPT_USR_RISCV64` | ditto | ditto | +| _`/usr/` Partition (s390)_ | `cd0f869b-d0fb-4ca0-b141-9ea87cc78d66` `SD_GPT_USR_S390` | ditto | ditto | +| _`/usr/` Partition (s390x)_ | `8a4f5770-50aa-4ed3-874a-99b710db6fea` `SD_GPT_USR_S390X` | ditto | ditto | +| _`/usr/` Partition (TILE-Gx)_ | `55497029-c7c1-44cc-aa39-815ed1558630` `SD_GPT_USR_TILEGX` | ditto | ditto | +| _`/usr/` Partition (x86)_ | `75250d76-8cc6-458e-bd66-bd47cc81a812` `SD_GPT_USR_X86` | ditto | ditto | +| _`/usr/` Partition (amd64/x86_64)_ | `8484680c-9521-48c6-9c11-b0720656f69e` `SD_GPT_USR_X86_64` | ditto | ditto | +| _Root Verity Partition (Alpha)_ | `fc56d9e9-e6e5-4c06-be32-e74407ce09a5` `SD_GPT_ROOT_ALPHA_VERITY` | A dm-verity superblock followed by hash data | Contains dm-verity integrity hash data for the matching root partition. If this feature is used the partition UUID of the root partition should be the first 128 bits of the root hash of the dm-verity hash data, and the partition UUID of this dm-verity partition should be the final 128 bits of it, so that the root partition and its Verity partition can be discovered easily, simply by specifying the root hash. | +| _Root Verity Partition (ARC)_ | `24b2d975-0f97-4521-afa1-cd531e421b8d` `SD_GPT_ROOT_ARC_VERITY` | ditto | ditto | +| _Root Verity Partition (32-bit ARM)_ | `7386cdf2-203c-47a9-a498-f2ecce45a2d6` `SD_GPT_ROOT_ARM_VERITY` | ditto | ditto | +| _Root Verity Partition (64-bit ARM/AArch64)_ | `df3300ce-d69f-4c92-978c-9bfb0f38d820` `SD_GPT_ROOT_ARM64_VERITY` | ditto | ditto | +| _Root Verity Partition (Itanium/IA-64)_ | `86ed10d5-b607-45bb-8957-d350f23d0571` `SD_GPT_ROOT_IA64_VERITY` | ditto | ditto | +| _Root Verity Partition (LoongArch 64-bit)_ | `f3393b22-e9af-4613-a948-9d3bfbd0c535` `SD_GPT_ROOT_LOONGARCH64_VERITY` | ditto | ditto | +| _Root Verity Partition (32-bit MIPS BigEndian (mips))_ | `7a430799-f711-4c7e-8e5b-1d685bd48607` | ditto | ditto | +| _Root Verity Partition (64-bit MIPS BigEndian (mips64))_ | `579536f8-6a33-4055-a95a-df2d5e2c42a8` | ditto | ditto | +| _Root Verity Partition (32-bit MIPS LittleEndian (mipsel))_ | `d7d150d2-2a04-4a33-8f12-16651205ff7b` `SD_GPT_ROOT_MIPS_LE_VERITY` | ditto | ditto | +| _Root Verity Partition (64-bit MIPS LittleEndian (mips64el))_ | `16b417f8-3e06-4f57-8dd2-9b5232f41aa6` `SD_GPT_ROOT_MIPS64_LE_VERITY` | ditto | ditto | +| _Root Verity Partition (HPPA/PARISC)_ | `d212a430-fbc5-49f9-a983-a7feef2b8d0e` `SD_GPT_ROOT_PARISC_VERITY` | ditto | ditto | +| _Root Verity Partition (64-bit PowerPC LittleEndian)_ | `906bd944-4589-4aae-a4e4-dd983917446a` `SD_GPT_ROOT_PPC64_LE_VERITY` | ditto | ditto | +| _Root Verity Partition (64-bit PowerPC BigEndian)_ | `9225a9a3-3c19-4d89-b4f6-eeff88f17631` `SD_GPT_ROOT_PPC64_VERITY` | ditto | ditto | +| _Root Verity Partition (32-bit PowerPC)_ | `98cfe649-1588-46dc-b2f0-add147424925` `SD_GPT_ROOT_PPC_VERITY` | ditto | ditto | +| _Root Verity Partition (RISC-V 32-bit)_ | `ae0253be-1167-4007-ac68-43926c14c5de` `SD_GPT_ROOT_RISCV32_VERITY` | ditto | ditto | +| _Root Verity Partition (RISC-V 64-bit)_ | `b6ed5582-440b-4209-b8da-5ff7c419ea3d` `SD_GPT_ROOT_RISCV64_VERITY` | ditto | ditto | +| _Root Verity Partition (s390)_ | `7ac63b47-b25c-463b-8df8-b4a94e6c90e1` `SD_GPT_ROOT_S390_VERITY` | ditto | ditto | +| _Root Verity Partition (s390x)_ | `b325bfbe-c7be-4ab8-8357-139e652d2f6b` `SD_GPT_ROOT_S390X_VERITY` | ditto | ditto | +| _Root Verity Partition (TILE-Gx)_ | `966061ec-28e4-4b2e-b4a5-1f0a825a1d84` `SD_GPT_ROOT_TILEGX_VERITY` | ditto | ditto | +| _Root Verity Partition (amd64/x86_64)_ | `2c7357ed-ebd2-46d9-aec1-23d437ec2bf5` `SD_GPT_ROOT_X86_64_VERITY` | ditto | ditto | +| _Root Verity Partition (x86)_ | `d13c5d3b-b5d1-422a-b29f-9454fdc89d76` `SD_GPT_ROOT_X86_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (Alpha)_ | `8cce0d25-c0d0-4a44-bd87-46331bf1df67` `SD_GPT_USR_ALPHA_VERITY` | A dm-verity superblock followed by hash data | Similar semantics to root Verity partition, but just for the `/usr/` partition. | +| _`/usr/` Verity Partition (ARC)_ | `fca0598c-d880-4591-8c16-4eda05c7347c` `SD_GPT_USR_ARC_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (32-bit ARM)_ | `c215d751-7bcd-4649-be90-6627490a4c05` `SD_GPT_USR_ARM_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (64-bit ARM/AArch64)_ | `6e11a4e7-fbca-4ded-b9e9-e1a512bb664e` `SD_GPT_USR_ARM64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (Itanium/IA-64)_ | `6a491e03-3be7-4545-8e38-83320e0ea880` `SD_GPT_USR_IA64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (LoongArch 64-bit)_ | `f46b2c26-59ae-48f0-9106-c50ed47f673d` `SD_GPT_USR_LOONGARCH64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (32-bit MIPS BigEndian (mips))_ | `6e5a1bc8-d223-49b7-bca8-37a5fcceb996` | ditto | ditto | +| _`/usr/` Verity Partition (64-bit MIPS BigEndian (mips64))_ | `81cf9d90-7458-4df4-8dcf-c8a3a404f09b` | ditto | ditto | +| _`/usr/` Verity Partition (32-bit MIPS LittleEndian (mipsel))_ | `46b98d8d-b55c-4e8f-aab3-37fca7f80752` `SD_GPT_USR_MIPS_LE_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (64-bit MIPS LittleEndian (mips64el))_ | `3c3d61fe-b5f3-414d-bb71-8739a694a4ef` `SD_GPT_USR_MIPS64_LE_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (HPPA/PARISC)_ | `5843d618-ec37-48d7-9f12-cea8e08768b2` `SD_GPT_USR_PARISC_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (64-bit PowerPC LittleEndian)_ | `ee2b9983-21e8-4153-86d9-b6901a54d1ce` `SD_GPT_USR_PPC64_LE_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (64-bit PowerPC BigEndian)_ | `bdb528a5-a259-475f-a87d-da53fa736a07` `SD_GPT_USR_PPC64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (32-bit PowerPC)_ | `df765d00-270e-49e5-bc75-f47bb2118b09` `SD_GPT_USR_PPC_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (RISC-V 32-bit)_ | `cb1ee4e3-8cd0-4136-a0a4-aa61a32e8730` `SD_GPT_USR_RISCV32_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (RISC-V 64-bit)_ | `8f1056be-9b05-47c4-81d6-be53128e5b54` `SD_GPT_USR_RISCV64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (s390)_ | `b663c618-e7bc-4d6d-90aa-11b756bb1797` `SD_GPT_USR_S390_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (s390x)_ | `31741cc4-1a2a-4111-a581-e00b447d2d06` `SD_GPT_USR_S390X_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (TILE-Gx)_ | `2fb4bf56-07fa-42da-8132-6b139f2026ae` `SD_GPT_USR_TILEGX_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (amd64/x86_64)_ | `77ff5f63-e7b6-4633-acf4-1565b864c0e6` `SD_GPT_USR_X86_64_VERITY` | ditto | ditto | +| _`/usr/` Verity Partition (x86)_ | `8f461b0d-14ee-4e81-9aa9-049b6fb97abd` `SD_GPT_USR_X86_VERITY` | ditto | ditto | +| _Root Verity Signature Partition (Alpha)_ | `d46495b7-a053-414f-80f7-700c99921ef8` `SD_GPT_ROOT_ALPHA_VERITY_SIG` | A serialized JSON object, see below | Contains a root hash and a PKCS#7 signature for it, permitting signed dm-verity GPT images. | +| _Root Verity Signature Partition (ARC)_ | `143a70ba-cbd3-4f06-919f-6c05683a78bc` `SD_GPT_ROOT_ARC_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (32-bit ARM)_ | `42b0455f-eb11-491d-98d3-56145ba9d037` `SD_GPT_ROOT_ARM_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (64-bit ARM/AArch64)_ | `6db69de6-29f4-4758-a7a5-962190f00ce3` `SD_GPT_ROOT_ARM64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (Itanium/IA-64)_ | `e98b36ee-32ba-4882-9b12-0ce14655f46a` `SD_GPT_ROOT_IA64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (LoongArch 64-bit)_ | `5afb67eb-ecc8-4f85-ae8e-ac1e7c50e7d0` `SD_GPT_ROOT_LOONGARCH64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (32-bit MIPS BigEndian (mips))_ | `bba210a2-9c5d-45ee-9e87-ff2ccbd002d0` | ditto | ditto | +| _Root Verity Signature Partition (64-bit MIPS BigEndian (mips64))_ | `43ce94d4-0f3d-4999-8250-b9deafd98e6e` | ditto | ditto | +| _Root Verity Signature Partition (32-bit MIPS LittleEndian (mipsel))_ | `c919cc1f-4456-4eff-918c-f75e94525ca5` `SD_GPT_ROOT_MIPS_LE_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (64-bit MIPS LittleEndian (mips64el))_ | `904e58ef-5c65-4a31-9c57-6af5fc7c5de7` `SD_GPT_ROOT_MIPS64_LE_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (HPPA/PARISC)_ | `15de6170-65d3-431c-916e-b0dcd8393f25` `SD_GPT_ROOT_PARISC_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (64-bit PowerPC LittleEndian)_ | `d4a236e7-e873-4c07-bf1d-bf6cf7f1c3c6` `SD_GPT_ROOT_PPC64_LE_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (64-bit PowerPC BigEndian)_ | `f5e2c20c-45b2-4ffa-bce9-2a60737e1aaf` `SD_GPT_ROOT_PPC64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (32-bit PowerPC)_ | `1b31b5aa-add9-463a-b2ed-bd467fc857e7` `SD_GPT_ROOT_PPC_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (RISC-V 32-bit)_ | `3a112a75-8729-4380-b4cf-764d79934448` `SD_GPT_ROOT_RISCV32_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (RISC-V 64-bit)_ | `efe0f087-ea8d-4469-821a-4c2a96a8386a` `SD_GPT_ROOT_RISCV64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (s390)_ | `3482388e-4254-435a-a241-766a065f9960` `SD_GPT_ROOT_S390_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (s390x)_ | `c80187a5-73a3-491a-901a-017c3fa953e9` `SD_GPT_ROOT_S390X_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (TILE-Gx)_ | `b3671439-97b0-4a53-90f7-2d5a8f3ad47b` `SD_GPT_ROOT_TILEGX_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (amd64/x86_64)_ | `41092b05-9fc8-4523-994f-2def0408b176` `SD_GPT_ROOT_X86_64_VERITY_SIG` | ditto | ditto | +| _Root Verity Signature Partition (x86)_ | `5996fc05-109c-48de-808b-23fa0830b676` `SD_GPT_ROOT_X86_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (Alpha)_ | `5c6e1c76-076a-457a-a0fe-f3b4cd21ce6e` `SD_GPT_USR_ALPHA_VERITY_SIG` | A serialized JSON object, see below | Similar semantics to root Verity signature partition, but just for the `/usr/` partition. | +| _`/usr/` Verity Signature Partition (ARC)_ | `94f9a9a1-9971-427a-a400-50cb297f0f35` `SD_GPT_USR_ARC_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (32-bit ARM)_ | `d7ff812f-37d1-4902-a810-d76ba57b975a` `SD_GPT_USR_ARM_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (64-bit ARM/AArch64)_ | `c23ce4ff-44bd-4b00-b2d4-b41b3419e02a` `SD_GPT_USR_ARM64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (Itanium/IA-64)_ | `8de58bc2-2a43-460d-b14e-a76e4a17b47f` `SD_GPT_USR_IA64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (LoongArch 64-bit)_ | `b024f315-d330-444c-8461-44bbde524e99` `SD_GPT_USR_LOONGARCH64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (32-bit MIPS BigEndian (mips))_ | `97ae158d-f216-497b-8057-f7f905770f54` | ditto | ditto | +| _`/usr/` Verity Signature Partition (64-bit MIPS BigEndian (mips64))_ | `05816ce2-dd40-4ac6-a61d-37d32dc1ba7d` | ditto | ditto | +| _`/usr/` Verity Signature Partition (32-bit MIPS LittleEndian (mipsel))_ | `3e23ca0b-a4bc-4b4e-8087-5ab6a26aa8a9` `SD_GPT_USR_MIPS_LE_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (64-bit MIPS LittleEndian (mips64el))_ | `f2c2c7ee-adcc-4351-b5c6-ee9816b66e16` `SD_GPT_USR_MIPS64_LE_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (HPPA/PARISC)_ | `450dd7d1-3224-45ec-9cf2-a43a346d71ee` `SD_GPT_USR_PARISC_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (64-bit PowerPC LittleEndian)_ | `c8bfbd1e-268e-4521-8bba-bf314c399557` `SD_GPT_USR_PPC64_LE_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (64-bit PowerPC BigEndian)_ | `0b888863-d7f8-4d9e-9766-239fce4d58af` `SD_GPT_USR_PPC64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (32-bit PowerPC)_ | `7007891d-d371-4a80-86a4-5cb875b9302e` `SD_GPT_USR_PPC_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (RISC-V 32-bit)_ | `c3836a13-3137-45ba-b583-b16c50fe5eb4` `SD_GPT_USR_RISCV32_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (RISC-V 64-bit)_ | `d2f9000a-7a18-453f-b5cd-4d32f77a7b32` `SD_GPT_USR_RISCV64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (s390)_ | `17440e4f-a8d0-467f-a46e-3912ae6ef2c5` `SD_GPT_USR_S390_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (s390x)_ | `3f324816-667b-46ae-86ee-9b0c0c6c11b4` `SD_GPT_USR_S390X_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (TILE-Gx)_ | `4ede75e2-6ccc-4cc8-b9c7-70334b087510` `SD_GPT_USR_TILEGX_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (amd64/x86_64)_ | `e7bb33fb-06cf-4e81-8273-e543b413e2e2` `SD_GPT_USR_X86_64_VERITY_SIG` | ditto | ditto | +| _`/usr/` Verity Signature Partition (x86)_ | `974a71c0-de41-43c3-be5d-5c5ccd1ad2c0` `SD_GPT_USR_X86_VERITY_SIG` | ditto | ditto | +| _EFI System Partition_ | `c12a7328-f81f-11d2-ba4b-00a0c93ec93b` `SD_GPT_ESP` | VFAT | The ESP used for the current boot is automatically mounted to `/boot/` or `/efi/`, unless a different partition is mounted there (possibly via `/etc/fstab`) or the mount point directory is non-empty on the root disk. If both ESP and XBOOTLDR exist, the `/efi/` mount point shall be used for ESP. This partition type is defined by the [UEFI Specification](http://www.uefi.org/specifications). | +| _Extended Boot Loader Partition_ | `bc13c2ff-59e6-4262-a352-b275fd6f7172` `SD_GPT_XBOOTLDR` | Typically VFAT | The Extended Boot Loader Partition (XBOOTLDR) used for the current boot is automatically mounted to `/boot/`, unless a different partition is mounted there (possibly via `/etc/fstab`) or the mount point directory is non-empty on the root disk. This partition type is defined by the [Boot Loader Specification](https://systemd.io/BOOT_LOADER_SPECIFICATION). | +| _Swap_ | `0657fd6d-a4ab-43c4-84e5-0933c84b4f4f` `SD_GPT_SWAP` | Swap, optionally in LUKS | All swap partitions on the disk containing the root partition are automatically enabled. If the partition is encrypted with LUKS, the device mapper file will be named `/dev/mapper/swap`. This partition type predates the Discoverable Partitions Specification. | +| _Home Partition_ | `933ac7e1-2eb4-4f13-b844-0e14e2aef915` `SD_GPT_HOME` | Any native, optionally in LUKS | The first partition with this type UUID on the disk containing the root partition is automatically mounted to `/home/`. If the partition is encrypted with LUKS, the device mapper file will be named `/dev/mapper/home`. | +| _Server Data Partition_ | `3b8f8425-20e0-4f3b-907f-1a25a76f98e8` `SD_GPT_SRV` | Any native, optionally in LUKS | The first partition with this type UUID on the disk containing the root partition is automatically mounted to `/srv/`. If the partition is encrypted with LUKS, the device mapper file will be named `/dev/mapper/srv`. | +| _Variable Data Partition_ | `4d21b016-b534-45c2-a9fb-5c16e091fd2d` `SD_GPT_VAR` | Any native, optionally in LUKS | The first partition with this type UUID on the disk containing the root partition is automatically mounted to `/var/` — under the condition that its partition UUID matches the first 128 bits of `HMAC-SHA256(machine-id, 0x4d21b016b53445c2a9fb5c16e091fd2d)` (i.e. the SHA256 HMAC hash of the binary type UUID keyed by the machine ID as read from [`/etc/machine-id`](https://www.freedesktop.org/software/systemd/man/machine-id.html). This special requirement is made because `/var/` (unlike the other partition types listed here) is inherently private to a specific installation and cannot possibly be shared between multiple OS installations on the same disk, and thus should be bound to a specific instance of the OS, identified by its machine ID. If the partition is encrypted with LUKS, the device mapper file will be named `/dev/mapper/var`. | +| _Temporary Data Partition_ | `7ec6f557-3bc5-4aca-b293-16ef5df639d1` `SD_GPT_TMP` | Any native, optionally in LUKS | The first partition with this type UUID on the disk containing the root partition is automatically mounted to `/var/tmp/`. If the partition is encrypted with LUKS, the device mapper file will be named `/dev/mapper/tmp`. Note that the intended mount point is indeed `/var/tmp/`, not `/tmp/`. The latter is typically maintained in memory via `tmpfs` and does not require a partition on disk. In some cases it might be desirable to make `/tmp/` persistent too, in which case it is recommended to make it a symlink or bind mount to `/var/tmp/`, thus not requiring its own partition type UUID. | +| _Per-user Home Partition_ | `773f91ef-66d4-49b5-bd83-d683bf40ad16` `SD_GPT_USER_HOME` | Any native, optionally in LUKS | A home partition of a user, managed by [`systemd-homed`](https://www.freedesktop.org/software/systemd/man/systemd-homed.html). | +| _Generic Linux Data Partition_ | `0fc63daf-8483-4772-8e79-3d69d8477de4` `SD_GPT_LINUX_GENERIC` | Any native, optionally in LUKS | No automatic mounting takes place for other Linux data partitions. This partition type should be used for all partitions that carry Linux file systems. The installer needs to mount them explicitly via entries in `/etc/fstab`. Optionally, these partitions may be encrypted with LUKS. This partition type predates the Discoverable Partitions Specification. | + +Other GPT type IDs might be used on Linux, for example to mark software RAID or +LVM partitions. The definitions of those GPT types is outside of the scope of +this specification. + +[systemd-id128(1)](https://www.freedesktop.org/software/systemd/man/systemd-id128.html)'s +`show` command may be used to list those GPT partition type UUIDs. + +## Partition Names + +For partitions of the types listed above it is recommended to use +human-friendly, descriptive partition names in the GPT partition table, for +example "*Home*", "*Server* *Data*", "*Fedora* *Root*" and similar, possibly +localized. + +For the Root/Verity/Verity signature partitions it might make sense to use a +versioned naming scheme reflecting the OS name and its version, +e.g. "fooOS_2021.4" or similar. +For details about the version format see the +[Version Format Specification](version_format_specification.md). The underscore +character (`_`) must be used to separate the version from the name of the image. + +## Partition Attribute Flags + +This specification defines three GPT partition attribute flags that may be set +for the partition types defined above: + +1. For the root, `/usr/`, Verity, Verity signature, home, server data, variable + data, temporary data, swap, and extended boot loader partitions, the + partition flag bit 63 ("*no-auto*", *SD_GPT_FLAG_NO_AUTO*) may be used to + turn off auto-discovery for the specific partition. If set, the partition + will not be automatically mounted or enabled. + +2. For the root, `/usr/`, Verity, Verity signature home, server data, variable + data, temporary data and extended boot loader partitions, the partition flag + bit 60 ("*read-only*", *SD_GPT_FLAG_READ_ONLY*) may be used to mark a + partition for read-only mounts only. If set, the partition will be mounted + read-only instead of read-write. Note that the variable data partition and + the temporary data partition will generally not be able to serve their + purpose if marked read-only, since by their very definition they are + supposed to be mutable. (The home and server data partitions are generally + assumed to be mutable as well, but the requirement for them is not equally + strong.) Because of that, while the read-only flag is defined and supported, + it's almost never a good idea to actually use it for these partitions. Also + note that Verity and signature partitions are by their semantics always + read-only. The flag is hence of little effect for them, and it is + recommended to set it unconditionally for the Verity and signature partition + types. + +3. For the root, `/usr/`, home, server data, variable data, temporary data and + extended boot loader partitions, the partition flag bit 59 + ("*grow-file-system*", *SD_GPT_FLAG_GROWFS*) may be used to mark a partition + for automatic growing of the contained file system to the size of the + partition when mounted. Tools that automatically mount disk image with a GPT + partition table are suggested to implicitly grow the contained file system + to the partition size they are contained in, if they are found to be + smaller. This flag is without effect on partitions marked "*read-only*". + +Note that the first two flag definitions happen to correspond nicely to the +same ones used by Microsoft Basic Data Partitions. + +All three of these flags generally affect only auto-discovery and automatic +mounting of disk images. If partitions marked with these flags are mounted +using low-level commands like +[mount(8)](https://man7.org/linux/man-pages/man2/mount.8.html) or directly with +[mount(2)](https://man7.org/linux/man-pages/man2/mount.2.html), they typically +have no effect. + +## Verity + +The Root/`/usr/` partition types and their matching Verity and Verity signature +partitions enable relatively automatic handling of `dm-verity` protected +setups. These types are defined with two modes of operation in mind: + +1. A trusted Verity root hash is passed in externally, for example is specified + on the kernel command line that is signed along with the kernel image using + SecureBoot PE signing (which in turn is tested against a set of + firmware-provided set of signing keys). If so, discovery and setup of a + Verity volume may be fully automatic: if the root partition's UUID is chosen + to match the first 128 bit of the root hash, and the matching Verity + partition UUIDs is chosen to match the last 128bit of the root hash, then + automatic discovery and match-up of the two partitions is possible, as the + root hash is enough to both find the partitions and then combine them in a + Verity volume. In this mode a Verity signature partition is not used and + unnecessary. + +2. A Verity signature partition is included on the disk, with a signature to be + tested against a system-provided set of signing keys. The signature + partition primarily contains two fields: the root hash to use, and a PKCS#7 + signature of it, using a signature key trusted by the OS. If so, discovery + and setup of a Verity volume may be fully automatic. First, the specified + root hash is validated with the signature and the OS-provided trusted + keys. If the signature checks out the root hash is then used in the same way + as in the first mode of operation described above. + +Both modes of operation may be combined in a single image. This is particularly +useful for images that shall be usable in two different contexts: for example +an image that shall be able to boot directly on UEFI systems (in which +case it makes sense to include the root hash on the kernel command line that is +included in the signed kernel image to boot, as per mode of operation #1 +above), but also be able to used as image for a container engine (such as +`systemd-nspawn`), which can use the signature partition to validate the image, +without making use of the signed kernel image (and thus following mode of +operation #2). + +The Verity signature partition's contents should be a serialized JSON object in +text form, padded with NUL bytes to the next multiple of 4096 bytes in +size. Currently three fields are defined for the JSON object: + +1. The (mandatory) `rootHash` field should be a string containing the Verity root hash, + formatted as series of (lowercase) hex characters. + +2. The (mandatory) `signature` field should be a string containing the PKCS#7 + signature of the root hash, in Base64-encoded DER format. This should be the + same format used by the Linux kernel's dm-verity signature logic, i.e. the + signed data should be the exact string representation of the hash, as stored + in `rootHash` above. + +3. The (optional) `certificateFingerprint` field should be a string containing + a SHA256 fingerprint of the X.509 certificate in DER format for the key that + signed the root hash, formatted as series of (lowercase) hex characters (no `:` + separators or such). + +More fields might be added in later revisions of this specification. + +## Suggested Mode of Operation + +An *installer* that repartitions the hard disk _should_ use the above UUID +partition types for appropriate partitions it creates. + +An *installer* which supports a "manual partitioning" interface _may_ choose to +pre-populate the interface with swap, `/home/`, `/srv/`, `/var/tmp/` partitions +of pre-existing Linux installations, identified with the GPT type UUIDs +above. The installer should not pre-populate such an interface with any +identified root, `/usr` or `/var/` partition unless the intention is to +overwrite an existing operating system that might be installed. + +An *installer* _may_ omit creating entries in `/etc/fstab` for root, `/home/`, +`/srv/`, `/var/`, `/var/tmp` and for the swap partitions if they use these UUID +partition types, and are the first partitions on the disk of each type. If the +ESP shall be mounted to `/efi/` (or `/boot/`), it may additionally omit +creating the entry for it in `/etc/fstab`. If the EFI partition shall not be +mounted to `/efi/` or `/boot/`, it _must_ create `/etc/fstab` entries for them. +If other partitions are used (for example for `/usr/local/` or +`/var/lib/mysql/`), the installer _must_ register these in `/etc/fstab`. The +`root=` parameter passed to the kernel by the boot loader may be omitted if the +root partition is the first one on the disk of its type. If the root partition +is not the first one on the disk, the `root=` parameter _must_ be passed to the +kernel by the boot loader. An installer that mounts a root, `/usr/`, `/home/`, +`/srv/`, `/var/`, or `/var/tmp/` file system with the partition types defined +as above which contains a LUKS header _must_ call the device mapper device +"root", "usr", "home", "srv", "var" or "tmp", respectively. This is necessary +to ensure that the automatic discovery will never result in different device +mapper names than any static configuration by the installer, thus eliminating +possible naming conflicts and ambiguities. + +An *operating* *system* _should_ automatically discover and mount the first +root partition that does not have the no-auto flag set (as described above) by +scanning the disk containing the currently used EFI ESP. It _should_ +automatically discover and mount the first `/usr/`, `/home/`, `/srv/`, `/var/`, +`/var/tmp/` and swap partitions that do not have the no-auto flag set by +scanning the disk containing the discovered root partition. It should +automatically discover and mount the partition containing the currently used +EFI ESP to `/efi/` (or `/boot/` as fallback). It should automatically discover +and mount the partition containing the currently used Extended Boot Loader +Partition to `/boot/`. It _should not_ discover or automatically mount +partitions with other UUID partition types, or partitions located on other +disks, or partitions with the no-auto flag set. User configuration shall +always override automatic discovery and mounting. If a root, `/usr/`, +`/home/`, `/srv/`, `/boot/`, `/var/`, `/var/tmp/`, `/efi/`, `/boot/` or swap +partition is listed in `/etc/fstab` or with `root=` on the kernel command line, +it _must_ take precedence over automatically discovered partitions. If a +`/home/`, `/usr/`, `/srv/`, `/boot/`, `/var/`, `/var/tmp/`, `/efi/` or `/boot/` +directory is found to be populated already in the root partition, the automatic +discovery _must not_ mount any discovered file system over it. Optionally, in +case of the root, `/usr/` and their Verity partitions instead of strictly +mounting the first suitable partition an OS might choose to mount the partition +whose label compares the highest according to `strverscmp()` or similar logic, +in order to implement a simple partition-based A/B versioning scheme. The +precise rules are left for the implementation to decide, but when in doubt +earlier partitions (by their index) should always win over later partitions if +the label comparison is inconclusive. + +A *container* *manager* should automatically discover and mount the root, +`/usr/`, `/home/`, `/srv/`, `/var/`, `/var/tmp/` partitions inside a container +disk image. It may choose to mount any discovered ESP and/or XBOOTLDR +partition to `/efi/` or `/boot/`. It should ignore any swap should they be +included in a container disk image. + +If a btrfs file system is automatically discovered and mounted by the operating +system/container manager it will be mounted with its *default* subvolume. The +installer should make sure to set the default subvolume correctly using "btrfs +subvolume set-default". + +## Sharing of File Systems between Installations + +If two Linux-based operating systems are installed on the same disk, the scheme +above suggests that they may share the swap, `/home/`, `/srv/`, `/var/tmp/`, +ESP, XBOOTLDR. However, they should each have their own root, `/usr/` and +`/var/` partition. + +## Frequently Asked Questions + +### Why are you taking my `/etc/fstab` away? + +We are not. `/etc/fstab` always overrides automatic discovery and is indeed +mentioned in the specifications. We are simply trying to make the boot and +installation processes of Linux a bit more robust and self-descriptive. + +### Why did you only define the root partition for these listed architectures? + +Please submit a patch that adds appropriate partition type UUIDs for the +architecture of your choice should they be missing so far. The only reason they +aren't defined yet is that nobody submitted them yet. + +### Why define distinct root partition UUIDs for the various architectures? + +This allows disk images that may be booted on multiple architectures to use +discovery of the appropriate root partition on each architecture. + +### Doesn't this break multi-boot scenarios? + +No, it doesn't. The specification says that installers may not stop creating +`/etc/fstab` or stop including `root=` on the kernel command line, unless the used +partitions are the first ones of their type on the disk. Additionally, +`/etc/fstab` and `root=` both override automatic discovery. Multi-boot is hence +well supported, since it doesn't change anything for anything but the first +installation. + +That all said, it's not expected that generic installers generally stop setting +`root=` and creating `/etc/fstab` anyway. The option to drop these configuration +bits is primarily something for appliance-like devices. However, generic +installers should *still* set the right GPT partition types for the partitions +they create so that container managers, partition tools and administrators can +benefit. Phrased differently, this specification introduces A) the +*recommendation* to use the newly defined partition types to tag things +properly and B) the *option* to then drop `root=` and `/etc/fstab`. While we +advertise A) to *all* installers, we only propose B) for simpler, +appliance-like installations. + +### What partitioning tools will create a DPS-compliant partition table? + +As of util-linux 2.25.2, the `fdisk` tool provides type codes to create the +root, home, and swap partitions that the DPS expects. By default, `fdisk` will +create an old-style MBR, not a GPT, so typing `l` to list partition types will +not show the choices to let you set the correct UUID. Make sure to first create +an empty GPT, then type `l` in order for the DPS-compliant type codes to be +available. + +The `gdisk` tool (from version 1.0.5 onward) and its variants (`sgdisk`, +`cgdisk`) also support creation of partitions with a matching type code. + +## Links + +[Boot Loader Specification](boot_loader_specification.md)
+[Boot Loader Interface](https://systemd.io/BOOT_LOADER_INTERFACE)
+[Safely Building Images](https://systemd.io/BUILDING_IMAGES)
+[`systemd-boot(7)`](https://www.freedesktop.org/software/systemd/man/systemd-boot.html)
+[`bootctl(1)`](https://www.freedesktop.org/software/systemd/man/bootctl.html)
+[`systemd-gpt-auto-generator(8)`](https://www.freedesktop.org/software/systemd/man/systemd-gpt-auto-generator.html) diff --git a/crates/lib/src/fixtures/spec-booted-pinned.yaml b/crates/lib/src/fixtures/spec-booted-pinned.yaml new file mode 100644 index 000000000..4d460e41c --- /dev/null +++ b/crates/lib/src/fixtures/spec-booted-pinned.yaml @@ -0,0 +1,46 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: null + booted: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: null + incompatible: false + pinned: true + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + otherDeployments: + - image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b37 + architecture: arm64 + cachedUpdate: null + incompatible: false + pinned: true + ostree: + checksum: 99b2cc3b6edce9ebaef6a6076effa5ee3e1dcff3523016ffc94a1b27c6c67e12 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost diff --git a/crates/lib/src/fixtures/spec-only-booted.yaml b/crates/lib/src/fixtures/spec-only-booted.yaml new file mode 100644 index 000000000..2adbf5e91 --- /dev/null +++ b/crates/lib/src/fixtures/spec-only-booted.yaml @@ -0,0 +1,30 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: null + booted: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: null + incompatible: false + pinned: false + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost \ No newline at end of file diff --git a/crates/lib/src/fixtures/spec-ostree-remote.yaml b/crates/lib/src/fixtures/spec-ostree-remote.yaml new file mode 100644 index 000000000..2a9770de9 --- /dev/null +++ b/crates/lib/src/fixtures/spec-ostree-remote.yaml @@ -0,0 +1,28 @@ +# This one drops the now-optional signature schema +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/fedora/fedora-coreos:stable + transport: registry + signature: !ostreeRemote "fedora" +status: + booted: + image: + image: + image: quay.io/otherexample/otherimage:latest + transport: registry + architecture: arm64 + version: 20231230.1 + timestamp: 2023-12-30T16:10:11Z + imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c + incompatible: false + pinned: false + ostree: + checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3 + deploySerial: 0 + stateroot: default + rollback: null + isContainer: false diff --git a/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml b/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml new file mode 100644 index 000000000..8adcd6c94 --- /dev/null +++ b/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml @@ -0,0 +1,40 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: s390x + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: null + incompatible: false + pinned: false + store: ostreeContainer + ostree: + checksum: 05cbf6dcae32e7a1c5a0774a648a073a5834a305ca92204b53fb6c281fe49db1 + deploySerial: 0 + stateroot: default + booted: + image: null + cachedUpdate: null + incompatible: false + pinned: false + store: null + ostree: + checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: null diff --git a/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml b/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml new file mode 100644 index 000000000..c38ac5e72 --- /dev/null +++ b/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml @@ -0,0 +1,31 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: null + bootOrder: default +status: + staged: + image: null + cachedUpdate: null + incompatible: true + pinned: false + store: null + ostree: + checksum: 1c24260fdd1be20f72a4a97a75c582834ee3431fbb0fa8e4f482bb219d633a45 + deploySerial: 0 + stateroot: default + booted: + image: null + cachedUpdate: null + incompatible: false + pinned: false + store: null + ostree: + checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: null diff --git a/crates/lib/src/fixtures/spec-staged-booted.yaml b/crates/lib/src/fixtures/spec-staged-booted.yaml new file mode 100644 index 000000000..c85fb1b93 --- /dev/null +++ b/crates/lib/src/fixtures/spec-staged-booted.yaml @@ -0,0 +1,45 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure +status: + staged: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: arm64 + version: nightly + # This one has nanoseconds, which should be dropped for human consumption + timestamp: 2023-10-14T19:22:15.42Z + imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 + incompatible: false + pinned: false + ostree: + checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d + deploySerial: 0 + stateroot: default + booted: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: arm64 + version: nightly + timestamp: 2023-09-30T19:22:16Z + imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 + incompatible: false + pinned: false + ostree: + checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c + deploySerial: 0 + stateroot: default + rollback: null + isContainer: false diff --git a/crates/lib/src/fixtures/spec-staged-rollback.yaml b/crates/lib/src/fixtures/spec-staged-rollback.yaml new file mode 100644 index 000000000..9b53d61fa --- /dev/null +++ b/crates/lib/src/fixtures/spec-staged-rollback.yaml @@ -0,0 +1,44 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure +status: + staged: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: s390x + version: nightly + timestamp: 2023-10-14T19:22:15Z + imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 + incompatible: false + pinned: false + ostree: + checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d + deploySerial: 0 + stateroot: default + booted: null + rollback: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: s390x + version: nightly + timestamp: 2023-09-30T19:22:16Z + imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 + incompatible: false + pinned: false + ostree: + checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c + deploySerial: 0 + stateroot: default + isContainer: false diff --git a/crates/lib/src/fixtures/spec-v1-null.json b/crates/lib/src/fixtures/spec-v1-null.json new file mode 100644 index 000000000..afd801a9e --- /dev/null +++ b/crates/lib/src/fixtures/spec-v1-null.json @@ -0,0 +1 @@ +{"apiVersion":"org.containers.bootc/v1","kind":"BootcHost","metadata":{"name":"host"},"spec":{"image":null,"bootOrder":"default"},"status":{"staged":null,"booted":null,"rollback":null,"rollbackQueued":false,"type":null}} \ No newline at end of file diff --git a/crates/lib/src/fixtures/spec-v1a1-orig.yaml b/crates/lib/src/fixtures/spec-v1a1-orig.yaml new file mode 100644 index 000000000..3419dd344 --- /dev/null +++ b/crates/lib/src/fixtures/spec-v1a1-orig.yaml @@ -0,0 +1,44 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure +status: + staged: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + version: nightly + timestamp: 2023-10-14T19:22:15Z + imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 + architecture: amd64 + incompatible: false + pinned: false + ostree: + checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d + deploySerial: 0 + stateroot: default + booted: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + version: nightly + timestamp: 2023-09-30T19:22:16Z + imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 + architecture: amd64 + incompatible: false + pinned: false + ostree: + checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c + deploySerial: 0 + stateroot: default + rollback: null + isContainer: false diff --git a/crates/lib/src/fixtures/spec-v1a1.yaml b/crates/lib/src/fixtures/spec-v1a1.yaml new file mode 100644 index 000000000..e98da73f4 --- /dev/null +++ b/crates/lib/src/fixtures/spec-v1a1.yaml @@ -0,0 +1,27 @@ +# This one drops the now-optional signature schema +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/otherexample/otherimage:latest + transport: registry +status: + booted: + image: + image: + image: quay.io/otherexample/otherimage:latest + transport: registry + architecture: s390x + version: 20231230.1 + timestamp: 2023-12-30T16:10:11Z + imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c + incompatible: false + pinned: false + ostree: + checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3 + deploySerial: 0 + stateroot: default + rollback: null + isContainer: false diff --git a/crates/lib/src/fixtures/spec-via-local-oci.yaml b/crates/lib/src/fixtures/spec-via-local-oci.yaml new file mode 100644 index 000000000..65b076925 --- /dev/null +++ b/crates/lib/src/fixtures/spec-via-local-oci.yaml @@ -0,0 +1,30 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: /var/mnt/osupdate:latest + transport: oci + bootOrder: default +status: + staged: null + booted: + image: + image: + image: /var/mnt/osupdate + transport: oci + architecture: amd64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: null + incompatible: false + pinned: false + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost \ No newline at end of file diff --git a/crates/lib/src/fsck.rs b/crates/lib/src/fsck.rs new file mode 100644 index 000000000..e0aea5fc1 --- /dev/null +++ b/crates/lib/src/fsck.rs @@ -0,0 +1,306 @@ +//! # Perform consistency checking. +//! +//! This is an internal module, backing the experimental `bootc internals fsck` +//! command. + +// Unfortunately needed here to work with linkme +#![allow(unsafe_code)] + +use std::fmt::Write as _; +use std::future::Future; +use std::num::NonZeroUsize; +use std::pin::Pin; + +use bootc_utils::collect_until; +use camino::Utf8PathBuf; +use cap_std::fs::{Dir, MetadataExt as _}; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use linkme::distributed_slice; +use ostree_ext::ostree_prepareroot::Tristate; +use ostree_ext::{composefs, ostree}; + +use crate::store::Storage; + +use std::os::fd::AsFd; + +/// A lint check has failed. +#[derive(thiserror::Error, Debug)] +struct FsckError(String); + +/// The outer error is for unexpected fatal runtime problems; the +/// inner error is for the check failing in an expected way. +type FsckResult = anyhow::Result>; + +/// Everything is OK - we didn't encounter a runtime error, and +/// the targeted check passed. +fn fsck_ok() -> FsckResult { + Ok(Ok(())) +} + +/// We successfully found a failure. +fn fsck_err(msg: impl AsRef) -> FsckResult { + Ok(Err(FsckError::new(msg))) +} + +impl std::fmt::Display for FsckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl FsckError { + fn new(msg: impl AsRef) -> Self { + Self(msg.as_ref().to_owned()) + } +} + +type FsckFn = fn(&Storage) -> FsckResult; +type AsyncFsckFn = fn(&Storage) -> Pin + '_>>; +#[derive(Debug)] +enum FsckFnImpl { + Sync(FsckFn), + Async(AsyncFsckFn), +} + +impl From for FsckFnImpl { + fn from(value: FsckFn) -> Self { + Self::Sync(value) + } +} + +impl From for FsckFnImpl { + fn from(value: AsyncFsckFn) -> Self { + Self::Async(value) + } +} + +#[derive(Debug)] +struct FsckCheck { + name: &'static str, + ordering: u16, + f: FsckFnImpl, +} + +#[distributed_slice] +pub(crate) static FSCK_CHECKS: [FsckCheck]; + +impl FsckCheck { + pub(crate) const fn new(name: &'static str, ordering: u16, f: FsckFnImpl) -> Self { + FsckCheck { name, ordering, f } + } +} + +#[distributed_slice(FSCK_CHECKS)] +static CHECK_RESOLVCONF: FsckCheck = + FsckCheck::new("etc-resolvconf", 5, FsckFnImpl::Sync(check_resolvconf)); +/// See https://github.com/bootc-dev/bootc/pull/1096 and https://github.com/containers/bootc/pull/1167 +/// Basically verify that if /usr/etc/resolv.conf exists, it is not a zero-sized file that was +/// probably injected by buildah and that bootc should have removed. +/// +/// Note that this fsck check can fail for systems upgraded from old bootc right now, as +/// we need the *new* bootc to fix it. +/// +/// But at the current time fsck is an experimental feature that we should only be running +/// in our CI. +fn check_resolvconf(storage: &Storage) -> FsckResult { + let ostree = storage.get_ostree()?; + // For now we only check the booted deployment. + if ostree.booted_deployment().is_none() { + return fsck_ok(); + } + // Read usr/etc/resolv.conf directly. + let usr = Dir::open_ambient_dir("/usr", cap_std::ambient_authority())?; + let Some(meta) = usr.symlink_metadata_optional("etc/resolv.conf")? else { + return fsck_ok(); + }; + if meta.is_file() && meta.size() == 0 { + return fsck_err("Found usr/etc/resolv.conf as zero-sized file"); + } + fsck_ok() +} + +#[derive(Debug, Default)] +struct ObjectsVerityState { + /// Count of objects with fsverity + enabled: u64, + /// Count of objects without fsverity + disabled: u64, + /// Objects which should have fsverity but do not + missing: Vec, +} + +/// Check the fsverity state of all regular files in this object directory. +#[context("Computing verity state")] +fn verity_state_of_objects( + d: &Dir, + prefix: &str, + expected: bool, +) -> anyhow::Result { + let mut enabled = 0; + let mut disabled = 0; + let mut missing = Vec::new(); + for ent in d.entries()? { + let ent = ent?; + if !ent.file_type()?.is_file() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + let Some("file") = name.extension() else { + continue; + }; + let f = d.open(&name)?; + let r: Option = + composefs::fsverity::measure_verity_opt(f.as_fd())?; + drop(f); + if r.is_some() { + enabled += 1; + } else { + disabled += 1; + if expected { + missing.push(format!("{prefix}{name}")); + } + } + } + let r = ObjectsVerityState { + enabled, + disabled, + missing, + }; + Ok(r) +} + +async fn verity_state_of_all_objects( + repo: &ostree::Repo, + expected: bool, +) -> anyhow::Result { + // Limit concurrency here + const MAX_CONCURRENT: usize = 3; + + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + + // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically. + let mut joinset = tokio::task::JoinSet::new(); + let mut results = Vec::new(); + + for ent in repodir.read_dir("objects")? { + // Block here if the queue is full + while joinset.len() >= MAX_CONCURRENT { + results.push(joinset.join_next().await.unwrap()??); + } + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + + let objdir = ent.open_dir()?; + joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected)); + } + + // Drain the remaining tasks. + while let Some(output) = joinset.join_next().await { + results.push(output??); + } + // Fold the results. + let r = results + .into_iter() + .fold(ObjectsVerityState::default(), |mut acc, v| { + acc.enabled += v.enabled; + acc.disabled += v.disabled; + acc.missing.extend(v.missing); + acc + }); + Ok(r) +} + +#[distributed_slice(FSCK_CHECKS)] +static CHECK_FSVERITY: FsckCheck = + FsckCheck::new("fsverity", 10, FsckFnImpl::Async(check_fsverity)); +fn check_fsverity(storage: &Storage) -> Pin + '_>> { + Box::pin(check_fsverity_inner(storage)) +} + +async fn check_fsverity_inner(storage: &Storage) -> FsckResult { + let ostree = storage.get_ostree()?; + let repo = &ostree.repo(); + let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?; + tracing::debug!( + "verity: expected={:?} found={:?}", + verity_state.desired, + verity_state.enabled + ); + + let verity_found_state = + verity_state_of_all_objects(&ostree.repo(), verity_state.desired == Tristate::Enabled) + .await?; + let Some((missing, rest)) = collect_until( + verity_found_state.missing.iter(), + const { NonZeroUsize::new(5).unwrap() }, + ) else { + return fsck_ok(); + }; + let mut err = String::from("fsverity enabled, but objects without fsverity:\n"); + for obj in missing { + // SAFETY: Writing into a String + writeln!(err, " {obj}").unwrap(); + } + if rest > 0 { + // SAFETY: Writing into a String + writeln!(err, " ...and {rest} more").unwrap(); + } + fsck_err(err) +} + +pub(crate) async fn fsck(storage: &Storage, mut output: impl std::io::Write) -> anyhow::Result<()> { + let mut checks = FSCK_CHECKS.static_slice().iter().collect::>(); + checks.sort_by(|a, b| a.ordering.cmp(&b.ordering)); + + let mut errors = false; + for check in checks.iter() { + let name = check.name; + let r = match check.f { + FsckFnImpl::Sync(f) => f(&storage), + FsckFnImpl::Async(f) => f(&storage).await, + }; + match r { + Ok(Ok(())) => { + println!("ok: {name}"); + } + Ok(Err(e)) => { + errors = true; + writeln!(output, "fsck error: {name}: {e}")?; + } + Err(e) => { + errors = true; + writeln!(output, "Unexpected runtime error in check {name}: {e}")?; + } + } + } + if errors { + anyhow::bail!("Encountered errors") + } + + // Run an `ostree fsck` (yes, ostree exposes enough APIs + // that we could reimplement this in Rust, but eh) + // TODO: Fix https://github.com/bootc-dev/bootc/issues/1216 so we can + // do this. + // let st = Command::new("ostree") + // .arg("fsck") + // .stdin(std::process::Stdio::inherit()) + // .status()?; + // if !st.success() { + // anyhow::bail!("ostree fsck failed"); + // } + + Ok(()) +} diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs new file mode 100644 index 000000000..a2e75318b --- /dev/null +++ b/crates/lib/src/generator.rs @@ -0,0 +1,268 @@ +use std::io::BufRead; + +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use cap_std::fs::Dir; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use ostree_ext::container_utils::{is_ostree_booted_in, OSTREE_BOOTED}; +use rustix::{fd::AsFd, fs::StatVfsMountFlags}; + +use crate::install::DESTRUCTIVE_CLEANUP; + +const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target"; +const STATUS_PATH_UNIT: &str = "bootc-status-updated.path"; +const CLEANUP_UNIT: &str = "bootc-destructive-cleanup.service"; +const MULTI_USER_TARGET: &str = "multi-user.target"; +const EDIT_UNIT: &str = "bootc-fstab-edit.service"; +const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda"; +pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service"; + +/// Called when the root is read-only composefs to reconcile /etc/fstab +#[context("bootc generator")] +pub(crate) fn fstab_generator_impl(root: &Dir, unit_dir: &Dir) -> Result { + // Do nothing if not ostree-booted + if !is_ostree_booted_in(root)? { + return Ok(false); + } + + if let Some(fd) = root + .open_optional("etc/fstab") + .context("Opening /etc/fstab")? + .map(std::io::BufReader::new) + { + let mut from_anaconda = false; + for line in fd.lines() { + let line = line.context("Reading /etc/fstab")?; + if line.contains(BOOTC_EDITED_STAMP) { + // We're done + return Ok(false); + } + if line.contains(FSTAB_ANACONDA_STAMP) { + from_anaconda = true; + } + } + if !from_anaconda { + return Ok(false); + } + tracing::debug!("/etc/fstab from anaconda: {from_anaconda}"); + if from_anaconda { + generate_fstab_editor(unit_dir)?; + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn enable_unit(unitdir: &Dir, name: &str, target: &str) -> Result<()> { + let wants = Utf8PathBuf::from(format!("{target}.wants")); + unitdir + .create_dir_all(&wants) + .with_context(|| format!("Creating {wants}"))?; + let source = format!("/usr/lib/systemd/system/{name}"); + let target = wants.join(name); + unitdir.remove_file_optional(&target)?; + unitdir + .symlink_contents(&source, &target) + .with_context(|| format!("Writing {name}"))?; + Ok(()) +} + +/// Enable our units +pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> { + for unit in [STATUS_ONBOOT_UNIT, STATUS_PATH_UNIT] { + enable_unit(unit_dir, unit, MULTI_USER_TARGET)?; + } + + if sysroot.try_exists(DESTRUCTIVE_CLEANUP)? { + tracing::debug!("Found {DESTRUCTIVE_CLEANUP}"); + enable_unit(unit_dir, CLEANUP_UNIT, MULTI_USER_TARGET)?; + } else { + tracing::debug!("Didn't find {DESTRUCTIVE_CLEANUP}"); + } + + Ok(()) +} + +/// Main entrypoint for the generator +pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { + // Only run on ostree systems + if !root.try_exists(OSTREE_BOOTED)? { + return Ok(()); + } + + let Some(ref sysroot) = root.open_dir_optional("sysroot")? else { + return Ok(()); + }; + + unit_enablement_impl(sysroot, unit_dir)?; + + // Also only run if the root is a read-only overlayfs (a composefs really) + let st = rustix::fs::fstatfs(root.as_fd())?; + if st.f_type != libc::OVERLAYFS_SUPER_MAGIC { + tracing::trace!("Root is not overlayfs"); + return Ok(()); + } + let st = rustix::fs::fstatvfs(root.as_fd())?; + if !st.f_flag.contains(StatVfsMountFlags::RDONLY) { + tracing::trace!("Root is writable"); + return Ok(()); + } + let updated = fstab_generator_impl(root, unit_dir)?; + tracing::trace!("Generated fstab: {updated}"); + + Ok(()) +} + +/// Parse /etc/fstab and check if the root mount is out of sync with the composefs +/// state, and if so, fix it. +fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> { + unit_dir.atomic_write( + EDIT_UNIT, + "[Unit]\n\ +DefaultDependencies=no\n\ +After=systemd-fsck-root.service\n\ +Before=local-fs-pre.target local-fs.target shutdown.target systemd-remount-fs.service\n\ +\n\ +[Service]\n\ +Type=oneshot\n\ +RemainAfterExit=yes\n\ +ExecStart=bootc internals fixup-etc-fstab\n\ +", + )?; + let target = "local-fs-pre.target.wants"; + unit_dir.create_dir_all(target)?; + unit_dir.symlink(&format!("../{EDIT_UNIT}"), &format!("{target}/{EDIT_UNIT}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use camino::Utf8Path; + + use super::*; + + fn fixture() -> Result { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir("etc")?; + tempdir.create_dir("run")?; + tempdir.create_dir("sysroot")?; + tempdir.create_dir_all("run/systemd/system")?; + Ok(tempdir) + } + + #[test] + fn test_generator_no_fstab() -> Result<()> { + let tempdir = fixture()?; + let unit_dir = &tempdir.open_dir("run/systemd/system")?; + fstab_generator_impl(&tempdir, &unit_dir).unwrap(); + + assert_eq!(unit_dir.entries()?.count(), 0); + Ok(()) + } + + #[test] + fn test_units() -> Result<()> { + let tempdir = &fixture()?; + let sysroot = &tempdir.open_dir("sysroot").unwrap(); + let unit_dir = &tempdir.open_dir("run/systemd/system")?; + + let verify = |wantsdir: &Dir, n: u32| -> Result<()> { + assert_eq!(unit_dir.entries()?.count(), 1); + let r = wantsdir.read_link_contents(STATUS_ONBOOT_UNIT)?; + let r: Utf8PathBuf = r.try_into().unwrap(); + assert_eq!(r, format!("/usr/lib/systemd/system/{STATUS_ONBOOT_UNIT}")); + assert_eq!(wantsdir.entries()?.count(), n as usize); + anyhow::Ok(()) + }; + + // Explicitly run this twice to test idempotency + + unit_enablement_impl(sysroot, &unit_dir).unwrap(); + unit_enablement_impl(sysroot, &unit_dir).unwrap(); + let wantsdir = &unit_dir.open_dir("multi-user.target.wants")?; + verify(wantsdir, 2)?; + assert!(wantsdir + .symlink_metadata_optional(CLEANUP_UNIT) + .unwrap() + .is_none()); + + // Now create sysroot and rerun the generator + unit_enablement_impl(sysroot, &unit_dir).unwrap(); + verify(wantsdir, 2)?; + + // Create the destructive stamp + sysroot + .create_dir_all(Utf8Path::new(DESTRUCTIVE_CLEANUP).parent().unwrap()) + .unwrap(); + sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"").unwrap(); + unit_enablement_impl(sysroot, unit_dir).unwrap(); + verify(wantsdir, 3)?; + + // And now the unit should be enabled + assert!(wantsdir + .symlink_metadata(CLEANUP_UNIT) + .unwrap() + .is_symlink()); + + Ok(()) + } + + #[cfg(test)] + mod test { + use super::*; + + use ostree_ext::container_utils::OSTREE_BOOTED; + + #[test] + fn test_generator_fstab() -> Result<()> { + let tempdir = fixture()?; + let unit_dir = &tempdir.open_dir("run/systemd/system")?; + // Should still be a no-op + tempdir.atomic_write("etc/fstab", "# Some dummy fstab")?; + fstab_generator_impl(&tempdir, &unit_dir).unwrap(); + assert_eq!(unit_dir.entries()?.count(), 0); + + // Also a no-op, not booted via ostree + tempdir.atomic_write("etc/fstab", &format!("# {FSTAB_ANACONDA_STAMP}"))?; + fstab_generator_impl(&tempdir, &unit_dir).unwrap(); + assert_eq!(unit_dir.entries()?.count(), 0); + + // Now it should generate + tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?; + fstab_generator_impl(&tempdir, &unit_dir).unwrap(); + assert_eq!(unit_dir.entries()?.count(), 2); + + Ok(()) + } + + #[test] + fn test_generator_fstab_idempotent() -> Result<()> { + let anaconda_fstab = indoc::indoc! { " +# +# /etc/fstab +# Created by anaconda on Tue Mar 19 12:24:29 2024 +# +# Accessible filesystems, by reference, are maintained under '/dev/disk/'. +# See man pages fstab(5), findfs(8), mount(8) and/or blkid(8) for more info. +# +# After editing this file, run 'systemctl daemon-reload' to update systemd +# units generated from this file. +# +# Updated by bootc-fstab-edit.service +UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 / xfs ro 0 0 +UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaults 0 0 +" }; + let tempdir = fixture()?; + let unit_dir = &tempdir.open_dir("run/systemd/system")?; + + tempdir.atomic_write("etc/fstab", anaconda_fstab)?; + tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?; + let updated = fstab_generator_impl(&tempdir, &unit_dir).unwrap(); + assert!(!updated); + assert_eq!(unit_dir.entries()?.count(), 0); + + Ok(()) + } + } +} diff --git a/crates/lib/src/glyph.rs b/crates/lib/src/glyph.rs new file mode 100644 index 000000000..2f60953b3 --- /dev/null +++ b/crates/lib/src/glyph.rs @@ -0,0 +1,41 @@ +//! Special Unicode characters used for display with ASCII fallbacks +//! in case we're not in a UTF-8 locale. + +use std::fmt::Display; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum Glyph { + BlackCircle, +} + +impl Glyph { + // TODO: Add support for non-Unicode output + #[allow(dead_code)] + pub(crate) fn as_ascii(&self) -> &'static str { + match self { + Glyph::BlackCircle => "*", + } + } + + pub(crate) fn as_utf8(&self) -> &'static str { + match self { + Glyph::BlackCircle => "●", + } + } +} + +impl Display for Glyph { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_utf8()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glyph() { + assert_eq!(Glyph::BlackCircle.as_utf8(), "●"); + } +} diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs new file mode 100644 index 000000000..584f4c46d --- /dev/null +++ b/crates/lib/src/image.rs @@ -0,0 +1,183 @@ +//! # Controlling bootc-managed images +//! +//! APIs for operating on container images in the bootc storage. + +use anyhow::{bail, Context, Result}; +use bootc_utils::CommandRunExt; +use cap_std_ext::cap_std::{self, fs::Dir}; +use clap::ValueEnum; +use comfy_table::{presets::NOTHING, Table}; +use fn_error_context::context; +use ostree_ext::container::{ImageReference, Transport}; +use serde::Serialize; + +use crate::{ + boundimage::query_bound_images, + cli::{ImageListFormat, ImageListType}, + podstorage::{ensure_floating_c_storage_initialized, CStorage}, +}; + +/// The name of the image we push to containers-storage if nothing is specified. +const IMAGE_DEFAULT: &str = "localhost/bootc"; + +#[derive(Clone, Serialize, ValueEnum)] +enum ImageListTypeColumn { + Host, + Logical, +} + +impl std::fmt::Display for ImageListTypeColumn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +#[derive(Serialize)] +struct ImageOutput { + image_type: ImageListTypeColumn, + image: String, + // TODO: Add hash, size, etc? Difficult because [`ostree_ext::container::store::list_images`] + // only gives us the pullspec. +} + +#[context("Listing host images")] +fn list_host_images(sysroot: &crate::store::Storage) -> Result> { + let ostree = sysroot.get_ostree()?; + let repo = ostree.repo(); + let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?; + + Ok(images + .into_iter() + .map(|image| ImageOutput { + image, + image_type: ImageListTypeColumn::Host, + }) + .collect()) +} + +#[context("Listing logical images")] +fn list_logical_images(root: &Dir) -> Result> { + let bound = query_bound_images(root)?; + + Ok(bound + .into_iter() + .map(|image| ImageOutput { + image: image.image, + image_type: ImageListTypeColumn::Logical, + }) + .collect()) +} + +async fn list_images(list_type: ImageListType) -> Result> { + let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) + .context("Opening /")?; + + let sysroot: Option = + if ostree_ext::container_utils::running_in_container() { + None + } else { + Some(crate::cli::get_storage().await?) + }; + + Ok(match (list_type, sysroot) { + // TODO: Should we list just logical images silently here, or error? + (ImageListType::All, None) => list_logical_images(&rootfs)?, + (ImageListType::All, Some(sysroot)) => list_host_images(&sysroot)? + .into_iter() + .chain(list_logical_images(&rootfs)?) + .collect(), + (ImageListType::Logical, _) => list_logical_images(&rootfs)?, + (ImageListType::Host, None) => { + bail!("Listing host images requires a booted bootc system") + } + (ImageListType::Host, Some(sysroot)) => list_host_images(&sysroot)?, + }) +} + +#[context("Listing images")] +pub(crate) async fn list_entrypoint( + list_type: ImageListType, + list_format: ImageListFormat, +) -> Result<()> { + let images = list_images(list_type).await?; + + match list_format { + ImageListFormat::Table => { + let mut table = Table::new(); + + table + .load_preset(NOTHING) + .set_content_arrangement(comfy_table::ContentArrangement::Dynamic) + .set_header(["REPOSITORY", "TYPE"]); + + for image in images { + table.add_row([image.image, image.image_type.to_string()]); + } + + println!("{table}"); + } + ImageListFormat::Json => { + let mut stdout = std::io::stdout(); + serde_json::to_writer_pretty(&mut stdout, &images)?; + } + } + + Ok(()) +} + +/// Implementation of `bootc image push-to-storage`. +#[context("Pushing image")] +pub(crate) async fn push_entrypoint(source: Option<&str>, target: Option<&str>) -> Result<()> { + let transport = Transport::ContainerStorage; + let sysroot = crate::cli::get_storage().await?; + let ostree = sysroot.get_ostree()?; + let repo = &ostree.repo(); + + // If the target isn't specified, push to containers-storage + our default image + let target = if let Some(target) = target { + ImageReference { + transport, + name: target.to_owned(), + } + } else { + ensure_floating_c_storage_initialized(); + ImageReference { + transport: Transport::ContainerStorage, + name: IMAGE_DEFAULT.to_string(), + } + }; + + // If the source isn't specified, we use the booted image + let source = if let Some(source) = source { + ImageReference::try_from(source).context("Parsing source image")? + } else { + let status = crate::status::get_status_require_booted(&ostree)?; + // SAFETY: We know it's booted + let booted = status.2.status.booted.unwrap(); + let booted_image = booted.image.unwrap().image; + ImageReference { + transport: Transport::try_from(booted_image.transport.as_str()).unwrap(), + name: booted_image.image, + } + }; + let mut opts = ostree_ext::container::store::ExportToOCIOpts::default(); + opts.progress_to_stdout = true; + println!("Copying local image {source} to {target} ..."); + let r = ostree_ext::container::store::export(repo, &source, &target, Some(opts)).await?; + + println!("Pushed: {target} {r}"); + Ok(()) +} + +/// Thin wrapper for invoking `podman image ` but set up for our internal +/// image store (as distinct from /var/lib/containers default). +pub(crate) async fn imgcmd_entrypoint( + storage: &CStorage, + arg: &str, + args: &[std::ffi::OsString], +) -> std::result::Result<(), anyhow::Error> { + let mut cmd = storage.new_image_cmd()?; + cmd.arg(arg); + cmd.args(args); + cmd.run_capture_stderr() +} diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs new file mode 100644 index 000000000..12e65c599 --- /dev/null +++ b/crates/lib/src/install.rs @@ -0,0 +1,2576 @@ +//! # Writing a container to a block device in a bootable way +//! +//! This module supports installing a bootc-compatible image to +//! a block device directly via the `install` verb, or to an externally +//! set up filesystem via `install to-filesystem`. + +// This sub-module is the "basic" installer that handles creating basic block device +// and filesystem setup. +mod aleph; +#[cfg(feature = "install-to-disk")] +pub(crate) mod baseline; +pub(crate) mod completion; +pub(crate) mod config; +mod osbuild; +pub(crate) mod osconfig; + +use std::collections::HashMap; +use std::io::Write; +use std::os::fd::{AsFd, AsRawFd}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process; +use std::process::Command; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use aleph::InstallAleph; +use anyhow::{anyhow, ensure, Context, Result}; +use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use camino::Utf8PathBuf; +use canon_json::CanonJsonSerialize; +use cap_std::fs::{Dir, MetadataExt}; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::FileType; +use cap_std_ext::cap_std::fs_utf8::DirEntry as DirEntryUtf8; +use cap_std_ext::cap_tempfile::TempDir; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use cap_std_ext::prelude::CapStdExtDirExt; +use clap::ValueEnum; +use fn_error_context::context; +use ostree::gio; +use ostree_ext::ostree; +use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; +use ostree_ext::prelude::Cast; +use ostree_ext::sysroot::{allocate_new_stateroot, list_stateroots, SysrootLock}; +use ostree_ext::{container as ostree_container, ostree_prepareroot}; +#[cfg(feature = "install-to-disk")] +use rustix::fs::FileTypeExt; +use rustix::fs::MetadataExt as _; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "install-to-disk")] +use self::baseline::InstallBlockDeviceOpts; +use crate::bootc_composefs::{boot::setup_composefs_boot, repo::initialize_composefs_repository}; +use crate::boundimage::{BoundImage, ResolvedBoundImage}; +use crate::containerenv::ContainerExecutionInfo; +use crate::deploy::{ + prepare_for_pull, pull_from_prepared, MergeState, PreparedImportMeta, PreparedPullResult, +}; +use crate::lsm; +use crate::progress_jsonl::ProgressWriter; +use crate::spec::{Bootloader, ImageReference}; +use crate::store::Storage; +use crate::task::Task; +use crate::utils::sigpolicy_from_opt; +use bootc_kernel_cmdline::{bytes, utf8, INITRD_ARG_PREFIX, ROOTFLAGS}; +use bootc_mount::Filesystem; +use composefs::fsverity::FsVerityHashValue; + +/// The toplevel boot directory +pub(crate) const BOOT: &str = "boot"; +/// Directory for transient runtime state +#[cfg(feature = "install-to-disk")] +const RUN_BOOTC: &str = "/run/bootc"; +/// The default path for the host rootfs +const ALONGSIDE_ROOT_MOUNT: &str = "/target"; +/// Global flag to signal the booted system was provisioned via an alongside bootc install +pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup"; +/// This is an ext4 special directory we need to ignore. +const LOST_AND_FOUND: &str = "lost+found"; +/// The filename of the composefs EROFS superblock; TODO move this into ostree +const OSTREE_COMPOSEFS_SUPER: &str = ".ostree.cfs"; +/// The mount path for selinux +const SELINUXFS: &str = "/sys/fs/selinux"; +/// The mount path for uefi +pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars"; +pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); + +pub(crate) const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; + +const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ + // Default to avoiding grub2-mkconfig etc. + ("sysroot.bootloader", "none"), + // Always flip this one on because we need to support alongside installs + // to systems without a separate boot partition. + ("sysroot.bootprefix", "true"), + ("sysroot.readonly", "true"), +]; + +/// Kernel argument used to specify we want the rootfs mounted read-write by default +pub(crate) const RW_KARG: &str = "rw"; + +#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallTargetOpts { + // TODO: A size specifier which allocates free space for the root in *addition* to the base container image size + // pub(crate) root_additional_size: Option + /// The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry`. + #[clap(long, default_value = "registry")] + #[serde(default)] + pub(crate) target_transport: String, + + /// Specify the image to fetch for subsequent updates + #[clap(long)] + pub(crate) target_imgref: Option, + + /// This command line argument does nothing; it exists for compatibility. + /// + /// As of newer versions of bootc, this value is enabled by default, + /// i.e. it is not enforced that a signature + /// verification policy is enabled. Hence to enable it, one can specify + /// `--target-no-signature-verification=false`. + /// + /// It is likely that the functionality here will be replaced with a different signature + /// enforcement scheme in the future that integrates with `podman`. + #[clap(long, hide = true)] + #[serde(default)] + pub(crate) target_no_signature_verification: bool, + + /// This is the inverse of the previous `--target-no-signature-verification` (which is now + /// a no-op). Enabling this option enforces that `/etc/containers/policy.json` includes a + /// default policy which requires signatures. + #[clap(long)] + #[serde(default)] + pub(crate) enforce_container_sigpolicy: bool, + + /// Verify the image can be fetched from the bootc image. Updates may fail when the installation + /// host is authenticated with the registry but the pull secret is not in the bootc image. + #[clap(long)] + #[serde(default)] + pub(crate) run_fetch_check: bool, + + /// Verify the image can be fetched from the bootc image. Updates may fail when the installation + /// host is authenticated with the registry but the pull secret is not in the bootc image. + #[clap(long)] + #[serde(default)] + pub(crate) skip_fetch_check: bool, +} + +#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallSourceOpts { + /// Install the system from an explicitly given source. + /// + /// By default, bootc install and install-to-filesystem assumes that it runs in a podman container, and + /// it takes the container image to install from the podman's container registry. + /// If --source-imgref is given, bootc uses it as the installation source, instead of the behaviour explained + /// in the previous paragraph. See skopeo(1) for accepted formats. + #[clap(long)] + pub(crate) source_imgref: Option, +} + +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum BoundImagesOpt { + /// Bound images must exist in the source's root container storage (default) + #[default] + Stored, + #[clap(hide = true)] + /// Do not resolve any "logically bound" images at install time. + Skip, + // TODO: Once we implement https://github.com/bootc-dev/bootc/issues/863 update this comment + // to mention source's root container storage being used as lookaside cache + /// Bound images will be pulled and stored directly in the target's bootc container storage + Pull, +} + +impl std::fmt::Display for BoundImagesOpt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallConfigOpts { + /// Disable SELinux in the target (installed) system. + /// + /// This is currently necessary to install *from* a system with SELinux disabled + /// but where the target does have SELinux enabled. + #[clap(long)] + #[serde(default)] + pub(crate) disable_selinux: bool, + + /// Add a kernel argument. This option can be provided multiple times. + /// + /// Example: --karg=nosmt --karg=console=ttyS0,115200n8 + #[clap(long)] + pub(crate) karg: Option>, + + /// The path to an `authorized_keys` that will be injected into the `root` account. + /// + /// The implementation of this uses systemd `tmpfiles.d`, writing to a file named + /// `/etc/tmpfiles.d/bootc-root-ssh.conf`. This will have the effect that by default, + /// the SSH credentials will be set if not present. The intention behind this + /// is to allow mounting the whole `/root` home directory as a `tmpfs`, while still + /// getting the SSH key replaced on boot. + #[clap(long)] + root_ssh_authorized_keys: Option, + + /// Perform configuration changes suitable for a "generic" disk image. + /// At the moment: + /// + /// - All bootloader types will be installed + /// - Changes to the system firmware will be skipped + #[clap(long)] + #[serde(default)] + pub(crate) generic_image: bool, + + /// How should logically bound images be retrieved. + #[clap(long)] + #[serde(default)] + #[arg(default_value_t)] + pub(crate) bound_images: BoundImagesOpt, + + /// The stateroot name to use. Defaults to `default`. + #[clap(long)] + pub(crate) stateroot: Option, +} + +#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallComposefsOpts { + /// If true, composefs backend is used, else ostree backend is used + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) composefs_backend: bool, + + /// Make fs-verity validation optional in case the filesystem doesn't support it + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) insecure: bool, + + /// The bootloader to use. + #[clap(long)] + #[serde(default)] + pub(crate) bootloader: Option, + + /// Name of the UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed. + #[clap(long)] + #[serde(default)] + pub(crate) uki_addon: Option>, +} + +#[cfg(feature = "install-to-disk")] +#[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallToDiskOpts { + #[clap(flatten)] + #[serde(flatten)] + pub(crate) block_opts: InstallBlockDeviceOpts, + + #[clap(flatten)] + #[serde(flatten)] + pub(crate) source_opts: InstallSourceOpts, + + #[clap(flatten)] + #[serde(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + #[clap(flatten)] + #[serde(flatten)] + pub(crate) config_opts: InstallConfigOpts, + + /// Instead of targeting a block device, write to a file via loopback. + #[clap(long)] + #[serde(default)] + pub(crate) via_loopback: bool, + + #[clap(flatten)] + #[serde(flatten)] + pub(crate) composefs_opts: InstallComposefsOpts, +} + +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ReplaceMode { + /// Completely wipe the contents of the target filesystem. This cannot + /// be done if the target filesystem is the one the system is booted from. + Wipe, + /// This is a destructive operation in the sense that the bootloader state + /// will have its contents wiped and replaced. However, + /// the running system (and all files) will remain in place until reboot. + /// + /// As a corollary to this, you will also need to remove all the old operating + /// system binaries after the reboot into the target system; this can be done + /// with code in the new target system, or manually. + Alongside, +} + +impl std::fmt::Display for ReplaceMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +/// Options for installing to a filesystem +#[derive(Debug, Clone, clap::Args, PartialEq, Eq)] +pub(crate) struct InstallTargetFilesystemOpts { + /// Path to the mounted root filesystem. + /// + /// By default, the filesystem UUID will be discovered and used for mounting. + /// To override this, use `--root-mount-spec`. + pub(crate) root_path: Utf8PathBuf, + + /// Source device specification for the root filesystem. For example, `UUID=2e9f4241-229b-4202-8429-62d2302382e1`. + /// If not provided, the UUID of the target filesystem will be used. This option is provided + /// as some use cases might prefer to mount by a label instead via e.g. `LABEL=rootfs`. + #[clap(long)] + pub(crate) root_mount_spec: Option, + + /// Mount specification for the /boot filesystem. + /// + /// This is optional. If `/boot` is detected as a mounted partition, then + /// its UUID will be used. + #[clap(long)] + pub(crate) boot_mount_spec: Option, + + /// Initialize the system in-place; at the moment, only one mode for this is implemented. + /// In the future, it may also be supported to set up an explicit "dual boot" system. + #[clap(long)] + pub(crate) replace: Option, + + /// If the target is the running system's root filesystem, this will skip any warnings. + #[clap(long)] + pub(crate) acknowledge_destructive: bool, + + /// The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar + /// operations, and finally mounting it readonly. This option skips those operations. It + /// is then the responsibility of the invoking code to perform those operations. + #[clap(long)] + pub(crate) skip_finalize: bool, +} + +#[derive(Debug, Clone, clap::Parser, PartialEq, Eq)] +pub(crate) struct InstallToFilesystemOpts { + #[clap(flatten)] + pub(crate) filesystem_opts: InstallTargetFilesystemOpts, + + #[clap(flatten)] + pub(crate) source_opts: InstallSourceOpts, + + #[clap(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + #[clap(flatten)] + pub(crate) config_opts: InstallConfigOpts, + + #[clap(flatten)] + pub(crate) composefs_opts: InstallComposefsOpts, +} + +#[derive(Debug, Clone, clap::Parser, PartialEq, Eq)] +pub(crate) struct InstallToExistingRootOpts { + /// Configure how existing data is treated. + #[clap(long, default_value = "alongside")] + pub(crate) replace: Option, + + #[clap(flatten)] + pub(crate) source_opts: InstallSourceOpts, + + #[clap(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + #[clap(flatten)] + pub(crate) config_opts: InstallConfigOpts, + + /// Accept that this is a destructive action and skip a warning timer. + #[clap(long)] + pub(crate) acknowledge_destructive: bool, + + /// Add the bootc-destructive-cleanup systemd service to delete files from + /// the previous install on first boot + #[clap(long)] + pub(crate) cleanup: bool, + + /// Path to the mounted root; this is now not necessary to provide. + /// Historically it was necessary to ensure the host rootfs was mounted at here + /// via e.g. `-v /:/target`. + #[clap(default_value = ALONGSIDE_ROOT_MOUNT)] + pub(crate) root_path: Utf8PathBuf, + + #[clap(flatten)] + pub(crate) composefs_opts: InstallComposefsOpts, +} + +#[derive(Debug, clap::Parser, PartialEq, Eq)] +pub(crate) struct InstallResetOpts { + /// Acknowledge that this command is experimental. + #[clap(long)] + pub(crate) experimental: bool, + + #[clap(flatten)] + pub(crate) source_opts: InstallSourceOpts, + + #[clap(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + /// Name of the target stateroot. If not provided, one will be automatically + /// generated of the form s- where starts at zero and + /// increments automatically. + #[clap(long)] + pub(crate) stateroot: Option, + + /// Don't display progress + #[clap(long)] + pub(crate) quiet: bool, + + #[clap(flatten)] + pub(crate) progress: crate::cli::ProgressOptions, + + /// Restart or reboot into the new target image. + /// + /// Currently, this option always reboots. In the future this command + /// will detect the case where no kernel changes are queued, and perform + /// a userspace-only restart. + #[clap(long)] + pub(crate) apply: bool, + + /// Skip inheriting any automatically discovered root file system kernel arguments. + #[clap(long)] + no_root_kargs: bool, + + /// Add a kernel argument. This option can be provided multiple times. + /// + /// Example: --karg=nosmt --karg=console=ttyS0,114800n8 + #[clap(long)] + karg: Option>, +} + +/// Global state captured from the container. +#[derive(Debug, Clone)] +pub(crate) struct SourceInfo { + /// Image reference we'll pull from (today always containers-storage: type) + pub(crate) imageref: ostree_container::ImageReference, + /// The digest to use for pulls + pub(crate) digest: Option, + /// Whether or not SELinux appears to be enabled in the source commit + pub(crate) selinux: bool, + /// Whether the source is available in the host mount namespace + pub(crate) in_host_mountns: bool, +} + +// Shared read-only global state +#[derive(Debug)] +pub(crate) struct State { + pub(crate) source: SourceInfo, + /// Force SELinux off in target system + pub(crate) selinux_state: SELinuxFinalState, + #[allow(dead_code)] + pub(crate) config_opts: InstallConfigOpts, + pub(crate) target_imgref: ostree_container::OstreeImageReference, + #[allow(dead_code)] + pub(crate) prepareroot_config: HashMap, + pub(crate) install_config: Option, + /// The parsed contents of the authorized_keys (not the file path) + pub(crate) root_ssh_authorized_keys: Option, + #[allow(dead_code)] + pub(crate) host_is_container: bool, + /// The root filesystem of the running container + pub(crate) container_root: Dir, + pub(crate) tempdir: TempDir, + + /// Set if we have determined that composefs is required + #[allow(dead_code)] + pub(crate) composefs_required: bool, + + // If Some, then --composefs_native is passed + pub(crate) composefs_options: InstallComposefsOpts, +} + +// Shared read-only global state +#[derive(Debug)] +pub(crate) struct PostFetchState { + /// Detected bootloader type for the target system + pub(crate) detected_bootloader: crate::spec::Bootloader, +} + +impl InstallTargetOpts { + pub(crate) fn imageref(&self) -> Result> { + let Some(target_imgname) = self.target_imgref.as_deref() else { + return Ok(None); + }; + let target_transport = + ostree_container::Transport::try_from(self.target_transport.as_str())?; + let target_imgref = ostree_container::OstreeImageReference { + sigverify: ostree_container::SignatureSource::ContainerPolicyAllowInsecure, + imgref: ostree_container::ImageReference { + transport: target_transport, + name: target_imgname.to_string(), + }, + }; + Ok(Some(target_imgref)) + } +} + +impl State { + #[context("Loading SELinux policy")] + pub(crate) fn load_policy(&self) -> Result> { + if !self.selinux_state.enabled() { + return Ok(None); + } + // We always use the physical container root to bootstrap policy + let r = lsm::new_sepolicy_at(&self.container_root)? + .ok_or_else(|| anyhow::anyhow!("SELinux enabled, but no policy found in root"))?; + // SAFETY: Policy must have a checksum here + tracing::debug!("Loaded SELinux policy: {}", r.csum().unwrap()); + Ok(Some(r)) + } + + #[context("Finalizing state")] + #[allow(dead_code)] + pub(crate) fn consume(self) -> Result<()> { + self.tempdir.close()?; + // If we had invoked `setenforce 0`, then let's re-enable it. + if let SELinuxFinalState::Enabled(Some(guard)) = self.selinux_state { + guard.consume()?; + } + Ok(()) + } + + /// Return an error if kernel arguments are provided, intended to be used for UKI paths + pub(crate) fn require_no_kargs_for_uki(&self) -> Result<()> { + if self + .config_opts + .karg + .as_ref() + .map(|v| !v.is_empty()) + .unwrap_or_default() + { + anyhow::bail!("Cannot use externally specified kernel arguments with UKI"); + } + Ok(()) + } + + fn stateroot(&self) -> &str { + self.config_opts + .stateroot + .as_deref() + .unwrap_or(ostree_ext::container::deploy::STATEROOT_DEFAULT) + } +} + +/// A mount specification is a subset of a line in `/etc/fstab`. +/// +/// There are 3 (ASCII) whitespace separated values: +/// +/// SOURCE TARGET [OPTIONS] +/// +/// Examples: +/// - /dev/vda3 /boot ext4 ro +/// - /dev/nvme0n1p4 / +/// - /dev/sda2 /var/mnt xfs +#[derive(Debug, Clone)] +pub(crate) struct MountSpec { + pub(crate) source: String, + pub(crate) target: String, + pub(crate) fstype: String, + pub(crate) options: Option, +} + +impl MountSpec { + const AUTO: &'static str = "auto"; + + pub(crate) fn new(src: &str, target: &str) -> Self { + MountSpec { + source: src.to_string(), + target: target.to_string(), + fstype: Self::AUTO.to_string(), + options: None, + } + } + + /// Construct a new mount that uses the provided uuid as a source. + pub(crate) fn new_uuid_src(uuid: &str, target: &str) -> Self { + Self::new(&format!("UUID={uuid}"), target) + } + + pub(crate) fn get_source_uuid(&self) -> Option<&str> { + if let Some((t, rest)) = self.source.split_once('=') { + if t.eq_ignore_ascii_case("uuid") { + return Some(rest); + } + } + None + } + + pub(crate) fn to_fstab(&self) -> String { + let options = self.options.as_deref().unwrap_or("defaults"); + format!( + "{} {} {} {} 0 0", + self.source, self.target, self.fstype, options + ) + } + + /// Append a mount option + pub(crate) fn push_option(&mut self, opt: &str) { + let options = self.options.get_or_insert_with(Default::default); + if !options.is_empty() { + options.push(','); + } + options.push_str(opt); + } +} + +impl FromStr for MountSpec { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut parts = s.split_ascii_whitespace().fuse(); + let source = parts.next().unwrap_or_default(); + if source.is_empty() { + tracing::debug!("Empty mount specification"); + return Ok(Self { + source: String::new(), + target: String::new(), + fstype: Self::AUTO.into(), + options: None, + }); + } + let target = parts + .next() + .ok_or_else(|| anyhow!("Missing target in mount specification {s}"))?; + let fstype = parts.next().unwrap_or(Self::AUTO); + let options = parts.next().map(ToOwned::to_owned); + Ok(Self { + source: source.to_string(), + fstype: fstype.to_string(), + target: target.to_string(), + options, + }) + } +} + +#[cfg(feature = "install-to-disk")] +impl InstallToDiskOpts { + pub(crate) fn validate(&self) -> Result<()> { + if !self.composefs_opts.composefs_backend { + // Reject using --insecure without --composefs-backend + if self.composefs_opts.insecure != false { + anyhow::bail!("--insecure must not be provided without --composefs-backend"); + } + } + + Ok(()) + } +} + +impl SourceInfo { + // Inspect container information and convert it to an ostree image reference + // that pulls from containers-storage. + #[context("Gathering source info from container env")] + pub(crate) fn from_container( + root: &Dir, + container_info: &ContainerExecutionInfo, + ) -> Result { + if !container_info.engine.starts_with("podman") { + anyhow::bail!("Currently this command only supports being executed via podman"); + } + if container_info.imageid.is_empty() { + anyhow::bail!("Invalid empty imageid"); + } + let imageref = ostree_container::ImageReference { + transport: ostree_container::Transport::ContainerStorage, + name: container_info.image.clone(), + }; + tracing::debug!("Finding digest for image ID {}", container_info.imageid); + let digest = crate::podman::imageid_to_digest(&container_info.imageid)?; + + Self::new(imageref, Some(digest), root, true) + } + + #[context("Creating source info from a given imageref")] + pub(crate) fn from_imageref(imageref: &str, root: &Dir) -> Result { + let imageref = ostree_container::ImageReference::try_from(imageref)?; + Self::new(imageref, None, root, false) + } + + fn have_selinux_from_repo(root: &Dir) -> Result { + let cancellable = ostree::gio::Cancellable::NONE; + + let commit = Command::new("ostree") + .args(["--repo=/ostree/repo", "rev-parse", "--single"]) + .run_get_string()?; + let repo = ostree::Repo::open_at_dir(root.as_fd(), "ostree/repo")?; + let root = repo + .read_commit(commit.trim(), cancellable) + .context("Reading commit")? + .0; + let root = root.downcast_ref::().unwrap(); + let xattrs = root.xattrs(cancellable)?; + Ok(crate::lsm::xattrs_have_selinux(&xattrs)) + } + + /// Construct a new source information structure + fn new( + imageref: ostree_container::ImageReference, + digest: Option, + root: &Dir, + in_host_mountns: bool, + ) -> Result { + let selinux = if Path::new("/ostree/repo").try_exists()? { + Self::have_selinux_from_repo(root)? + } else { + lsm::have_selinux_policy(root)? + }; + Ok(Self { + imageref, + digest, + selinux, + in_host_mountns, + }) + } +} + +pub(crate) fn print_configuration() -> Result<()> { + let mut install_config = config::load_config()?.unwrap_or_default(); + install_config.filter_to_external(); + let stdout = std::io::stdout().lock(); + anyhow::Ok(install_config.to_canon_json_writer(stdout)?) +} + +#[context("Creating ostree deployment")] +async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result<(Storage, bool)> { + let sepolicy = state.load_policy()?; + let sepolicy = sepolicy.as_ref(); + // Load a fd for the mounted target physical root + let rootfs_dir = &root_setup.physical_root; + let cancellable = gio::Cancellable::NONE; + + let stateroot = state.stateroot(); + + let has_ostree = rootfs_dir.try_exists("ostree/repo")?; + if !has_ostree { + Task::new("Initializing ostree layout", "ostree") + .args(["admin", "init-fs", "--modern", "."]) + .cwd(rootfs_dir)? + .run()?; + } else { + println!("Reusing extant ostree layout"); + + let path = ".".into(); + let _ = crate::utils::open_dir_remount_rw(rootfs_dir, path) + .context("remounting target as read-write")?; + crate::utils::remove_immutability(rootfs_dir, path)?; + } + + // Ensure that the physical root is labeled. + // Another implementation: https://github.com/coreos/coreos-assembler/blob/3cd3307904593b3a131b81567b13a4d0b6fe7c90/src/create_disk.sh#L295 + crate::lsm::ensure_dir_labeled(rootfs_dir, "", Some("/".into()), 0o755.into(), sepolicy)?; + + // And also label /boot AKA xbootldr, if it exists + if rootfs_dir.try_exists("boot")? { + crate::lsm::ensure_dir_labeled(rootfs_dir, "boot", None, 0o755.into(), sepolicy)?; + } + + for (k, v) in DEFAULT_REPO_CONFIG.iter() { + Command::new("ostree") + .args(["config", "--repo", "ostree/repo", "set", k, v]) + .cwd_dir(rootfs_dir.try_clone()?) + .run_capture_stderr()?; + } + + let sysroot = { + let path = format!( + "/proc/{}/fd/{}", + process::id(), + rootfs_dir.as_fd().as_raw_fd() + ); + ostree::Sysroot::new(Some(&gio::File::for_path(path))) + }; + sysroot.load(cancellable)?; + let repo = &sysroot.repo(); + + let repo_verity_state = ostree_ext::fsverity::is_verity_enabled(&repo)?; + let prepare_root_composefs = state + .prepareroot_config + .get("composefs.enabled") + .map(|v| ComposefsState::from_str(&v)) + .transpose()? + .unwrap_or(ComposefsState::default()); + if prepare_root_composefs.requires_fsverity() || repo_verity_state.desired == Tristate::Enabled + { + ostree_ext::fsverity::ensure_verity(repo).await?; + } + + if let Some(booted) = sysroot.booted_deployment() { + if stateroot == booted.stateroot() { + anyhow::bail!("Cannot redeploy over booted stateroot {stateroot}"); + } + } + + let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; + + // init_osname fails when ostree/deploy/{stateroot} already exists + // the stateroot directory can be left over after a failed install attempt, + // so only create it via init_osname if it doesn't exist + // (ideally this would be handled by init_osname) + let stateroot_path = format!("ostree/deploy/{stateroot}"); + if !sysroot_dir.try_exists(stateroot_path)? { + sysroot + .init_osname(stateroot, cancellable) + .context("initializing stateroot")?; + } + + state.tempdir.create_dir("temp-run")?; + let temp_run = state.tempdir.open_dir("temp-run")?; + + // Bootstrap the initial labeling of the /ostree directory as usr_t + // and create the imgstorage with the same labels as /var/lib/containers + if let Some(policy) = sepolicy { + let ostree_dir = rootfs_dir.open_dir("ostree")?; + crate::lsm::ensure_dir_labeled( + &ostree_dir, + ".", + Some("/usr".into()), + 0o755.into(), + Some(policy), + )?; + } + + sysroot.load(cancellable)?; + let sysroot = SysrootLock::new_from_sysroot(&sysroot).await?; + let storage = Storage::new_ostree(sysroot, &temp_run)?; + + Ok((storage, has_ostree)) +} + +fn check_disk_space( + repo_fd: impl AsFd, + image_meta: &PreparedImportMeta, + imgref: &ImageReference, +) -> Result<()> { + let stat = rustix::fs::fstatvfs(repo_fd)?; + let bytes_avail: u64 = stat.f_bsize * stat.f_bavail; + tracing::trace!("bytes_avail: {bytes_avail}"); + + if image_meta.bytes_to_fetch > bytes_avail { + anyhow::bail!( + "Insufficient free space for {image} (available: {bytes_avail} required: {bytes_to_fetch})", + bytes_avail = ostree_ext::glib::format_size(bytes_avail), + bytes_to_fetch = ostree_ext::glib::format_size(image_meta.bytes_to_fetch), + image = imgref.image, + ); + } + + Ok(()) +} + +#[context("Creating ostree deployment")] +async fn install_container( + state: &State, + root_setup: &RootSetup, + sysroot: &ostree::Sysroot, + has_ostree: bool, +) -> Result<(ostree::Deployment, InstallAleph)> { + let sepolicy = state.load_policy()?; + let sepolicy = sepolicy.as_ref(); + let stateroot = state.stateroot(); + + // TODO factor out this + let (src_imageref, proxy_cfg) = if !state.source.in_host_mountns { + (state.source.imageref.clone(), None) + } else { + let src_imageref = { + // We always use exactly the digest of the running image to ensure predictability. + let digest = state + .source + .digest + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing container image digest"))?; + let spec = crate::utils::digested_pullspec(&state.source.imageref.name, digest); + ostree_container::ImageReference { + transport: ostree_container::Transport::ContainerStorage, + name: spec, + } + }; + + let proxy_cfg = ostree_container::store::ImageProxyConfig::default(); + (src_imageref, Some(proxy_cfg)) + }; + let src_imageref = ostree_container::OstreeImageReference { + // There are no signatures to verify since we're fetching the already + // pulled container. + sigverify: ostree_container::SignatureSource::ContainerPolicyAllowInsecure, + imgref: src_imageref, + }; + + // Pull the container image into the target root filesystem. Since this is + // an install path, we don't need to fsync() individual layers. + let spec_imgref = ImageReference::from(src_imageref.clone()); + let repo = &sysroot.repo(); + repo.set_disable_fsync(true); + + let pulled_image = match prepare_for_pull(repo, &spec_imgref, Some(&state.target_imgref)) + .await? + { + PreparedPullResult::AlreadyPresent(existing) => existing, + PreparedPullResult::Ready(image_meta) => { + check_disk_space(root_setup.physical_root.as_fd(), &image_meta, &spec_imgref)?; + pull_from_prepared(&spec_imgref, false, ProgressWriter::default(), *image_meta).await? + } + }; + + repo.set_disable_fsync(false); + + // We need to read the kargs from the target merged ostree commit before + // we do the deployment. + let merged_ostree_root = sysroot + .repo() + .read_commit(pulled_image.ostree_commit.as_str(), gio::Cancellable::NONE)? + .0; + let kargsd = crate::bootc_kargs::get_kargs_from_ostree_root( + &sysroot.repo(), + merged_ostree_root.downcast_ref().unwrap(), + std::env::consts::ARCH, + )?; + + // If the target uses aboot, then we need to set that bootloader in the ostree + // config before deploying the commit + if ostree_ext::bootabletree::commit_has_aboot_img(&merged_ostree_root, None)? { + tracing::debug!("Setting bootloader to aboot"); + Command::new("ostree") + .args([ + "config", + "--repo", + "ostree/repo", + "set", + "sysroot.bootloader", + "aboot", + ]) + .cwd_dir(root_setup.physical_root.try_clone()?) + .run_capture_stderr() + .context("Setting bootloader config to aboot")?; + sysroot.repo().reload_config(None::<&gio::Cancellable>)?; + } + + // Keep this in sync with install/completion.rs for the Anaconda fixups + let install_config_kargs = state.install_config.as_ref().and_then(|c| c.kargs.as_ref()); + + // Final kargs, in order: + // - root filesystem kargs + // - install config kargs + // - kargs.d from container image + // - args specified on the CLI + let mut kargs = Cmdline::new(); + + kargs.extend(&root_setup.kargs); + + if let Some(install_config_kargs) = install_config_kargs { + for karg in install_config_kargs { + kargs.extend(&Cmdline::from(karg.as_str())); + } + } + + kargs.extend(&kargsd); + + if let Some(cli_kargs) = state.config_opts.karg.as_ref() { + for karg in cli_kargs { + kargs.extend(karg); + } + } + + // Finally map into &[&str] for ostree_container + let kargs_strs: Vec<&str> = kargs.iter_str().collect(); + + let mut options = ostree_container::deploy::DeployOpts::default(); + options.kargs = Some(kargs_strs.as_slice()); + options.target_imgref = Some(&state.target_imgref); + options.proxy_cfg = proxy_cfg; + options.skip_completion = true; // Must be set to avoid recursion! + options.no_clean = has_ostree; + let imgstate = crate::utils::async_task_with_spinner( + "Deploying container image", + ostree_container::deploy::deploy(&sysroot, stateroot, &src_imageref, Some(options)), + ) + .await?; + + let deployment = sysroot + .deployments() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("Failed to find deployment"))?; + // SAFETY: There must be a path + let path = sysroot.deployment_dirpath(&deployment); + let root = root_setup + .physical_root + .open_dir(path.as_str()) + .context("Opening deployment dir")?; + + // And do another recursive relabeling pass over the ostree-owned directories + // but avoid recursing into the deployment root (because that's a *distinct* + // logical root). + if let Some(policy) = sepolicy { + let deployment_root_meta = root.dir_metadata()?; + let deployment_root_devino = (deployment_root_meta.dev(), deployment_root_meta.ino()); + for d in ["ostree", "boot"] { + let mut pathbuf = Utf8PathBuf::from(d); + crate::lsm::ensure_dir_labeled_recurse( + &root_setup.physical_root, + &mut pathbuf, + policy, + Some(deployment_root_devino), + ) + .with_context(|| format!("Recursive SELinux relabeling of {d}"))?; + } + + if let Some(cfs_super) = root.open_optional(OSTREE_COMPOSEFS_SUPER)? { + let label = crate::lsm::require_label(policy, "/usr".into(), 0o644)?; + crate::lsm::set_security_selinux(cfs_super.as_fd(), label.as_bytes())?; + } else { + tracing::warn!("Missing {OSTREE_COMPOSEFS_SUPER}; composefs is not enabled?"); + } + } + + // Write the entry for /boot to /etc/fstab. TODO: Encourage OSes to use the karg? + // Or better bind this with the grub data. + // We omit it if the boot mountspec argument was empty + if let Some(boot) = root_setup.boot.as_ref() { + if !boot.source.is_empty() { + crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| { + writeln!(w, "{}", boot.to_fstab()).map_err(Into::into) + })?; + } + } + + if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { + osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?; + } + + let aleph = InstallAleph::new(&src_imageref, &imgstate, &state.selinux_state)?; + Ok((deployment, aleph)) +} + +/// Run a command in the host mount namespace +pub(crate) fn run_in_host_mountns(cmd: &str) -> Result { + let mut c = Command::new(bootc_utils::reexec::executable_path()?); + c.lifecycle_bind() + .args(["exec-in-host-mount-namespace", cmd]); + Ok(c) +} + +#[context("Re-exec in host mountns")] +pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> { + let (cmd, args) = args + .split_first() + .ok_or_else(|| anyhow::anyhow!("Missing command"))?; + tracing::trace!("{cmd:?} {args:?}"); + let pid1mountns = std::fs::File::open("/proc/1/ns/mnt").context("open pid1 mountns")?; + rustix::thread::move_into_link_name_space( + pid1mountns.as_fd(), + Some(rustix::thread::LinkNameSpaceType::Mount), + ) + .context("setns")?; + rustix::process::chdir("/").context("chdir")?; + // Work around supermin doing chroot() and not pivot_root + // https://github.com/libguestfs/supermin/blob/5230e2c3cd07e82bd6431e871e239f7056bf25ad/init/init.c#L288 + if !Utf8Path::new("/usr").try_exists().context("/usr")? + && Utf8Path::new("/root/usr") + .try_exists() + .context("/root/usr")? + { + tracing::debug!("Using supermin workaround"); + rustix::process::chroot("/root").context("chroot")?; + } + Err(Command::new(cmd).args(args).arg0(bootc_utils::NAME).exec()).context("exec")? +} + +pub(crate) struct RootSetup { + #[cfg(feature = "install-to-disk")] + luks_device: Option, + pub(crate) device_info: bootc_blockdev::PartitionTable, + /// Absolute path to the location where we've mounted the physical + /// root filesystem for the system we're installing. + pub(crate) physical_root_path: Utf8PathBuf, + /// Directory file descriptor for the above physical root. + pub(crate) physical_root: Dir, + pub(crate) rootfs_uuid: Option, + /// True if we should skip finalizing + skip_finalize: bool, + boot: Option, + pub(crate) kargs: CmdlineOwned, +} + +fn require_boot_uuid(spec: &MountSpec) -> Result<&str> { + spec.get_source_uuid() + .ok_or_else(|| anyhow!("/boot is not specified via UUID= (this is currently required)")) +} + +impl RootSetup { + /// Get the UUID= mount specifier for the /boot filesystem; if there isn't one, the root UUID will + /// be returned. + pub(crate) fn get_boot_uuid(&self) -> Result> { + self.boot.as_ref().map(require_boot_uuid).transpose() + } + + // Drop any open file descriptors and return just the mount path and backing luks device, if any + #[cfg(feature = "install-to-disk")] + fn into_storage(self) -> (Utf8PathBuf, Option) { + (self.physical_root_path, self.luks_device) + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) enum SELinuxFinalState { + /// Host and target both have SELinux, but user forced it off for target + ForceTargetDisabled, + /// Host and target both have SELinux + Enabled(Option), + /// Host has SELinux disabled, target is enabled. + HostDisabled, + /// Neither host or target have SELinux + Disabled, +} + +impl SELinuxFinalState { + /// Returns true if the target system will have SELinux enabled. + pub(crate) fn enabled(&self) -> bool { + match self { + SELinuxFinalState::ForceTargetDisabled | SELinuxFinalState::Disabled => false, + SELinuxFinalState::Enabled(_) | SELinuxFinalState::HostDisabled => true, + } + } + + /// Returns the canonical stringified version of self. This is only used + /// for debugging purposes. + pub(crate) fn to_aleph(&self) -> &'static str { + match self { + SELinuxFinalState::ForceTargetDisabled => "force-target-disabled", + SELinuxFinalState::Enabled(_) => "enabled", + SELinuxFinalState::HostDisabled => "host-disabled", + SELinuxFinalState::Disabled => "disabled", + } + } +} + +/// If we detect that the target ostree commit has SELinux labels, +/// and we aren't passed an override to disable it, then ensure +/// the running process is labeled with install_t so it can +/// write arbitrary labels. +pub(crate) fn reexecute_self_for_selinux_if_needed( + srcdata: &SourceInfo, + override_disable_selinux: bool, +) -> Result { + // If the target state has SELinux enabled, we need to check the host state. + if srcdata.selinux { + let host_selinux = crate::lsm::selinux_enabled()?; + tracing::debug!("Target has SELinux, host={host_selinux}"); + let r = if override_disable_selinux { + println!("notice: Target has SELinux enabled, overriding to disable"); + SELinuxFinalState::ForceTargetDisabled + } else if host_selinux { + // /sys/fs/selinuxfs is not normally mounted, so we do that now. + // Because SELinux enablement status is cached process-wide and was very likely + // already queried by something else (e.g. glib's constructor), we would also need + // to re-exec. But, selinux_ensure_install does that unconditionally right now too, + // so let's just fall through to that. + setup_sys_mount("selinuxfs", SELINUXFS)?; + // This will re-execute the current process (once). + let g = crate::lsm::selinux_ensure_install_or_setenforce()?; + SELinuxFinalState::Enabled(g) + } else { + SELinuxFinalState::HostDisabled + }; + Ok(r) + } else { + Ok(SELinuxFinalState::Disabled) + } +} + +/// Trim, flush outstanding writes, and freeze/thaw the target mounted filesystem; +/// these steps prepare the filesystem for its first booted use. +pub(crate) fn finalize_filesystem( + fsname: &str, + root: &Dir, + path: impl AsRef, +) -> Result<()> { + let path = path.as_ref(); + // fstrim ensures the underlying block device knows about unused space + Task::new(format!("Trimming {fsname}"), "fstrim") + .args(["--quiet-unsupported", "-v", path.as_str()]) + .cwd(root)? + .run()?; + // Remounting readonly will flush outstanding writes and ensure we error out if there were background + // writeback problems. + Task::new(format!("Finalizing filesystem {fsname}"), "mount") + .cwd(root)? + .args(["-o", "remount,ro", path.as_str()]) + .run()?; + // Finally, freezing (and thawing) the filesystem will flush the journal, which means the next boot is clean. + for a in ["-f", "-u"] { + Command::new("fsfreeze") + .cwd_dir(root.try_clone()?) + .args([a, path.as_str()]) + .run_capture_stderr()?; + } + Ok(()) +} + +/// A heuristic check that we were invoked with --pid=host +fn require_host_pidns() -> Result<()> { + if rustix::process::getpid().is_init() { + anyhow::bail!("This command must be run with the podman --pid=host flag") + } + tracing::trace!("OK: we're not pid 1"); + Ok(()) +} + +/// Verify that we can access /proc/1, which will catch rootless podman (with --pid=host) +/// for example. +fn require_host_userns() -> Result<()> { + let proc1 = "/proc/1"; + let pid1_uid = Path::new(proc1) + .metadata() + .with_context(|| format!("Querying {proc1}"))? + .uid(); + // We must really be in a rootless container, or in some way + // we're not part of the host user namespace. + ensure!(pid1_uid == 0, "{proc1} is owned by {pid1_uid}, not zero; this command must be run in the root user namespace (e.g. not rootless podman)"); + tracing::trace!("OK: we're in a matching user namespace with pid1"); + Ok(()) +} + +/// Ensure that /tmp is a tmpfs because in some cases we might perform +/// operations which expect it (as it is on a proper host system). +/// Ideally we have people run this container via podman run --read-only-tmpfs +/// actually. +pub(crate) fn setup_tmp_mount() -> Result<()> { + let st = rustix::fs::statfs("/tmp")?; + if st.f_type == libc::TMPFS_MAGIC { + tracing::trace!("Already have tmpfs /tmp") + } else { + // Note we explicitly also don't want a "nosuid" tmp, because that + // suppresses our install_t transition + Command::new("mount") + .args(["tmpfs", "-t", "tmpfs", "/tmp"]) + .run_capture_stderr()?; + } + Ok(()) +} + +/// By default, podman/docker etc. when passed `--privileged` mount `/sys` as read-only, +/// but non-recursively. We selectively grab sub-filesystems that we need. +#[context("Ensuring sys mount {fspath} {fstype}")] +pub(crate) fn setup_sys_mount(fstype: &str, fspath: &str) -> Result<()> { + tracing::debug!("Setting up sys mounts"); + let rootfs = format!("/proc/1/root/{fspath}"); + // Does mount point even exist in the host? + if !Path::new(rootfs.as_str()).try_exists()? { + return Ok(()); + } + + // Now, let's find out if it's populated + if std::fs::read_dir(rootfs)?.next().is_none() { + return Ok(()); + } + + // Check that the path that should be mounted is even populated. + // Since we are dealing with /sys mounts here, if it's populated, + // we can be at least a little certain that it's mounted. + if Path::new(fspath).try_exists()? && std::fs::read_dir(fspath)?.next().is_some() { + return Ok(()); + } + + // This means the host has this mounted, so we should mount it too + Command::new("mount") + .args(["-t", fstype, fstype, fspath]) + .run_capture_stderr()?; + + Ok(()) +} + +/// Verify that we can load the manifest of the target image +#[context("Verifying fetch")] +async fn verify_target_fetch( + tmpdir: &Dir, + imgref: &ostree_container::OstreeImageReference, +) -> Result<()> { + let tmpdir = &TempDir::new_in(&tmpdir)?; + let tmprepo = &ostree::Repo::create_at_dir(tmpdir.as_fd(), ".", ostree::RepoMode::Bare, None) + .context("Init tmp repo")?; + + tracing::trace!("Verifying fetch for {imgref}"); + let mut imp = + ostree_container::store::ImageImporter::new(tmprepo, imgref, Default::default()).await?; + use ostree_container::store::PrepareResult; + let prep = match imp.prepare().await? { + // SAFETY: It's impossible that the image was already fetched into this newly created temporary repository + PrepareResult::AlreadyPresent(_) => unreachable!(), + PrepareResult::Ready(r) => r, + }; + tracing::debug!("Fetched manifest with digest {}", prep.manifest_digest); + Ok(()) +} + +fn root_has_uki(root: &Dir) -> Result { + crate::bootc_composefs::boot::container_root_has_uki(root) +} + +/// Preparation for an install; validates and prepares some (thereafter immutable) global state. +async fn prepare_install( + config_opts: InstallConfigOpts, + source_opts: InstallSourceOpts, + target_opts: InstallTargetOpts, + mut composefs_options: InstallComposefsOpts, +) -> Result> { + tracing::trace!("Preparing install"); + let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) + .context("Opening /")?; + + let host_is_container = crate::containerenv::is_container(&rootfs); + let external_source = source_opts.source_imgref.is_some(); + let (source, target_rootfs) = match source_opts.source_imgref { + None => { + ensure!(host_is_container, "Either --source-imgref must be defined or this command must be executed inside a podman container."); + + crate::cli::require_root(true)?; + + require_host_pidns()?; + // Out of conservatism we only verify the host userns path when we're expecting + // to do a self-install (e.g. not bootc-image-builder or equivalent). + require_host_userns()?; + let container_info = crate::containerenv::get_container_execution_info(&rootfs)?; + // This command currently *must* be run inside a privileged container. + match container_info.rootless.as_deref() { + Some("1") => anyhow::bail!( + "Cannot install from rootless podman; this command must be run as root" + ), + Some(o) => tracing::debug!("rootless={o}"), + // This one shouldn't happen except on old podman + None => tracing::debug!( + "notice: Did not find rootless= entry in {}", + crate::containerenv::PATH, + ), + }; + tracing::trace!("Read container engine info {:?}", container_info); + + let source = SourceInfo::from_container(&rootfs, &container_info)?; + (source, Some(rootfs.try_clone()?)) + } + Some(source) => { + crate::cli::require_root(false)?; + let source = SourceInfo::from_imageref(&source, &rootfs)?; + (source, None) + } + }; + + // Parse the target CLI image reference options and create the *target* image + // reference, which defaults to pulling from a registry. + if target_opts.target_no_signature_verification { + // Perhaps log this in the future more prominently, but no reason to annoy people. + tracing::debug!( + "Use of --target-no-signature-verification flag which is enabled by default" + ); + } + let target_sigverify = sigpolicy_from_opt(target_opts.enforce_container_sigpolicy); + let target_imgname = target_opts + .target_imgref + .as_deref() + .unwrap_or(source.imageref.name.as_str()); + let target_transport = + ostree_container::Transport::try_from(target_opts.target_transport.as_str())?; + let target_imgref = ostree_container::OstreeImageReference { + sigverify: target_sigverify, + imgref: ostree_container::ImageReference { + transport: target_transport, + name: target_imgname.to_string(), + }, + }; + tracing::debug!("Target image reference: {target_imgref}"); + + let composefs_required = if let Some(root) = target_rootfs.as_ref() { + root_has_uki(root)? + } else { + false + }; + + tracing::debug!("Composefs required: {composefs_required}"); + + if composefs_required { + composefs_options.composefs_backend = true; + } + + // We need to access devices that are set up by the host udev + bootc_mount::ensure_mirrored_host_mount("/dev")?; + // We need to read our own container image (and any logically bound images) + // from the host container store. + bootc_mount::ensure_mirrored_host_mount("/var/lib/containers")?; + // In some cases we may create large files, and it's better not to have those + // in our overlayfs. + bootc_mount::ensure_mirrored_host_mount("/var/tmp")?; + // We also always want /tmp to be a proper tmpfs on general principle. + setup_tmp_mount()?; + // Allocate a temporary directory we can use in various places to avoid + // creating multiple. + let tempdir = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + // And continue to init global state + osbuild::adjust_for_bootc_image_builder(&rootfs, &tempdir)?; + + if target_opts.run_fetch_check { + verify_target_fetch(&tempdir, &target_imgref).await?; + } + + // Even though we require running in a container, the mounts we create should be specific + // to this process, so let's enter a private mountns to avoid leaking them. + if !external_source && std::env::var_os("BOOTC_SKIP_UNSHARE").is_none() { + super::cli::ensure_self_unshared_mount_namespace()?; + } + + setup_sys_mount("efivarfs", EFIVARFS)?; + + // Now, deal with SELinux state. + let selinux_state = reexecute_self_for_selinux_if_needed(&source, config_opts.disable_selinux)?; + tracing::debug!("SELinux state: {selinux_state:?}"); + + println!("Installing image: {:#}", &target_imgref); + if let Some(digest) = source.digest.as_deref() { + println!("Digest: {digest}"); + } + + let install_config = config::load_config()?; + if install_config.is_some() { + tracing::debug!("Loaded install configuration"); + } else { + tracing::debug!("No install configuration found"); + } + + // Convert the keyfile to a hashmap because GKeyFile isnt Send for probably bad reasons. + let prepareroot_config = { + let kf = ostree_prepareroot::require_config_from_root(&rootfs)?; + let mut r = HashMap::new(); + for grp in kf.groups() { + for key in kf.keys(&grp)? { + let key = key.as_str(); + let value = kf.value(&grp, key)?; + r.insert(format!("{grp}.{key}"), value.to_string()); + } + } + r + }; + + // Eagerly read the file now to ensure we error out early if e.g. it doesn't exist, + // instead of much later after we're 80% of the way through an install. + let root_ssh_authorized_keys = config_opts + .root_ssh_authorized_keys + .as_ref() + .map(|p| std::fs::read_to_string(p).with_context(|| format!("Reading {p}"))) + .transpose()?; + + // Create our global (read-only) state which gets wrapped in an Arc + // so we can pass it to worker threads too. Right now this just + // combines our command line options along with some bind mounts from the host. + let state = Arc::new(State { + selinux_state, + source, + config_opts, + target_imgref, + install_config, + prepareroot_config, + root_ssh_authorized_keys, + container_root: rootfs, + tempdir, + host_is_container, + composefs_required, + composefs_options, + }); + + Ok(state) +} + +impl PostFetchState { + pub(crate) fn new(state: &State, d: &Dir) -> Result { + // Determine bootloader type for the target system + // Priority: user-specified > bootupd availability > systemd-boot fallback + let detected_bootloader = { + if let Some(bootloader) = state.composefs_options.bootloader.clone() { + bootloader + } else { + if crate::bootloader::supports_bootupd(d)? { + crate::spec::Bootloader::Grub + } else { + crate::spec::Bootloader::Systemd + } + } + }; + println!("Bootloader: {detected_bootloader}"); + let r = Self { + detected_bootloader, + }; + Ok(r) + } +} + +/// Given a baseline root filesystem with an ostree sysroot initialized: +/// - install the container to that root +/// - install the bootloader +/// - Other post operations, such as pulling bound images +async fn install_with_sysroot( + state: &State, + rootfs: &RootSetup, + storage: &Storage, + boot_uuid: &str, + bound_images: BoundImages, + has_ostree: bool, +) -> Result<()> { + let ostree = storage.get_ostree()?; + let c_storage = storage.get_ensure_imgstore()?; + + // And actually set up the container in that root, returning a deployment and + // the aleph state (see below). + let (deployment, aleph) = install_container(state, rootfs, ostree, has_ostree).await?; + // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging. + aleph.write_to(&rootfs.physical_root)?; + + let deployment_path = ostree.deployment_dirpath(&deployment); + + let deployment_dir = rootfs + .physical_root + .open_dir(&deployment_path) + .context("Opening deployment dir")?; + let postfetch = PostFetchState::new(state, &deployment_dir)?; + + if cfg!(target_arch = "s390x") { + // TODO: Integrate s390x support into install_via_bootupd + crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?; + } else { + match postfetch.detected_bootloader { + Bootloader::Grub => { + crate::bootloader::install_via_bootupd( + &rootfs.device_info, + &rootfs.physical_root_path, + &state.config_opts, + Some(&deployment_path.as_str()), + )?; + } + Bootloader::Systemd => { + anyhow::bail!("bootupd is required for ostree-based installs"); + } + } + } + tracing::debug!("Installed bootloader"); + + tracing::debug!("Perfoming post-deployment operations"); + + match bound_images { + BoundImages::Skip => {} + BoundImages::Resolved(resolved_bound_images) => { + // Now copy each bound image from the host's container storage into the target. + for image in resolved_bound_images { + let image = image.image.as_str(); + c_storage.pull_from_host_storage(image).await?; + } + } + BoundImages::Unresolved(bound_images) => { + crate::boundimage::pull_images_impl(c_storage, bound_images) + .await + .context("pulling bound images")?; + } + } + + Ok(()) +} + +enum BoundImages { + Skip, + Resolved(Vec), + Unresolved(Vec), +} + +impl BoundImages { + async fn from_state(state: &State) -> Result { + let bound_images = match state.config_opts.bound_images { + BoundImagesOpt::Skip => BoundImages::Skip, + others => { + let queried_images = crate::boundimage::query_bound_images(&state.container_root)?; + match others { + BoundImagesOpt::Stored => { + // Verify each bound image is present in the container storage + let mut r = Vec::with_capacity(queried_images.len()); + for image in queried_images { + let resolved = ResolvedBoundImage::from_image(&image).await?; + tracing::debug!("Resolved {}: {}", resolved.image, resolved.digest); + r.push(resolved) + } + BoundImages::Resolved(r) + } + BoundImagesOpt::Pull => { + // No need to resolve the images, we will pull them into the target later + BoundImages::Unresolved(queried_images) + } + BoundImagesOpt::Skip => anyhow::bail!("unreachable error"), + } + } + }; + + Ok(bound_images) + } +} + +async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> { + // We verify this upfront because it's currently required by bootupd + let boot_uuid = rootfs + .get_boot_uuid()? + .or(rootfs.rootfs_uuid.as_deref()) + .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + tracing::debug!("boot uuid={boot_uuid}"); + + let bound_images = BoundImages::from_state(state).await?; + + // Initialize the ostree sysroot (repo, stateroot, etc.) + + { + let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?; + + install_with_sysroot( + state, + rootfs, + &sysroot, + &boot_uuid, + bound_images, + has_ostree, + ) + .await?; + let ostree = sysroot.get_ostree()?; + + if matches!(cleanup, Cleanup::TriggerOnNextBoot) { + let sysroot_dir = crate::utils::sysroot_dir(ostree)?; + tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); + sysroot_dir.atomic_write(DESTRUCTIVE_CLEANUP, b"")?; + } + + // We must drop the sysroot here in order to close any open file + // descriptors. + }; + + // Run this on every install as the penultimate step + install_finalize(&rootfs.physical_root_path).await?; + + Ok(()) +} + +async fn install_to_filesystem_impl( + state: &State, + rootfs: &mut RootSetup, + cleanup: Cleanup, +) -> Result<()> { + if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { + rootfs.kargs.extend(&Cmdline::from("selinux=0")); + } + // Drop exclusive ownership since we're done with mutation + let rootfs = &*rootfs; + + match &rootfs.device_info.label { + bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning( + "Installing to `dos` format partitions is not recommended", + ), + bootc_blockdev::PartitionType::Gpt => { + // The only thing we should be using in general + } + bootc_blockdev::PartitionType::Unknown(o) => { + crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}")) + } + } + + if state.composefs_options.composefs_backend { + // Load a fd for the mounted target physical root + + let (id, verity) = initialize_composefs_repository(state, rootfs).await?; + tracing::info!("id: {}, verity: {}", hex::encode(id), verity.to_hex()); + + setup_composefs_boot(rootfs, state, &hex::encode(id))?; + } else { + ostree_install(state, rootfs, cleanup).await?; + } + + // Finalize mounted filesystems + if !rootfs.skip_finalize { + let bootfs = rootfs.boot.as_ref().map(|_| ("boot", "boot")); + for (fsname, fs) in std::iter::once(("root", ".")).chain(bootfs) { + finalize_filesystem(fsname, &rootfs.physical_root, fs)?; + } + } + + Ok(()) +} + +fn installation_complete() { + println!("Installation complete!"); +} + +/// Implementation of the `bootc install to-disk` CLI command. +#[context("Installing to disk")] +#[cfg(feature = "install-to-disk")] +pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { + opts.validate()?; + + // Log the disk installation operation to systemd journal + const INSTALL_DISK_JOURNAL_ID: &str = "8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2"; + let source_image = opts + .source_opts + .source_imgref + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("none"); + let target_device = opts.block_opts.device.as_str(); + + tracing::info!( + message_id = INSTALL_DISK_JOURNAL_ID, + bootc.source_image = source_image, + bootc.target_device = target_device, + bootc.via_loopback = if opts.via_loopback { "true" } else { "false" }, + "Starting disk installation from {} to {}", + source_image, + target_device + ); + + let mut block_opts = opts.block_opts; + let target_blockdev_meta = block_opts + .device + .metadata() + .with_context(|| format!("Querying {}", &block_opts.device))?; + if opts.via_loopback { + if !opts.config_opts.generic_image { + crate::utils::medium_visibility_warning( + "Automatically enabling --generic-image when installing via loopback", + ); + opts.config_opts.generic_image = true; + } + if !target_blockdev_meta.file_type().is_file() { + anyhow::bail!( + "Not a regular file (to be used via loopback): {}", + block_opts.device + ); + } + } else if !target_blockdev_meta.file_type().is_block_device() { + anyhow::bail!("Not a block device: {}", block_opts.device); + } + + let state = prepare_install( + opts.config_opts, + opts.source_opts, + opts.target_opts, + opts.composefs_opts, + ) + .await?; + + // This is all blocking stuff + let (mut rootfs, loopback) = { + let loopback_dev = if opts.via_loopback { + let loopback_dev = + bootc_blockdev::LoopbackDevice::new(block_opts.device.as_std_path())?; + block_opts.device = loopback_dev.path().into(); + Some(loopback_dev) + } else { + None + }; + + let state = state.clone(); + let rootfs = tokio::task::spawn_blocking(move || { + baseline::install_create_rootfs(&state, block_opts) + }) + .await??; + (rootfs, loopback_dev) + }; + + install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?; + + // Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed. + let (root_path, luksdev) = rootfs.into_storage(); + Task::new_and_run( + "Unmounting filesystems", + "umount", + ["-R", root_path.as_str()], + )?; + if let Some(luksdev) = luksdev.as_deref() { + Task::new_and_run("Closing root LUKS device", "cryptsetup", ["close", luksdev])?; + } + + if let Some(loopback_dev) = loopback { + loopback_dev.close()?; + } + + // At this point, all other threads should be gone. + if let Some(state) = Arc::into_inner(state) { + state.consume()?; + } else { + // This shouldn't happen...but we will make it not fatal right now + tracing::warn!("Failed to consume state Arc"); + } + + installation_complete(); + + Ok(()) +} + +#[context("Verifying empty rootfs")] +fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> { + for e in rootfs_fd.entries()? { + let e = DirEntryUtf8::from_cap_std(e?); + let name = e.file_name()?; + if name == LOST_AND_FOUND { + continue; + } + // There must be a boot directory (that is empty) + if name == BOOT { + let mut entries = rootfs_fd.read_dir(BOOT)?; + if let Some(e) = entries.next() { + let e = DirEntryUtf8::from_cap_std(e?); + let name = e.file_name()?; + if matches!(name.as_str(), LOST_AND_FOUND | crate::bootloader::EFI_DIR) { + continue; + } + anyhow::bail!("Non-empty boot directory, found {name}"); + } + } else { + anyhow::bail!("Non-empty root filesystem; found {name:?}"); + } + } + Ok(()) +} + +/// Remove all entries in a directory, but do not traverse across distinct devices. +/// If mount_err is true, then an error is returned if a mount point is found; +/// otherwise it is silently ignored. +fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> { + for entry in d.entries()? { + let entry = entry?; + let name = entry.file_name(); + let etype = entry.file_type()?; + if etype == FileType::dir() { + if let Some(subdir) = d.open_dir_noxdev(&name)? { + remove_all_in_dir_no_xdev(&subdir, mount_err)?; + d.remove_dir(&name)?; + } else if mount_err { + anyhow::bail!("Found unexpected mount point {name:?}"); + } + } else { + d.remove_file_optional(&name)?; + } + } + anyhow::Ok(()) +} + +#[context("Removing boot directory content")] +fn clean_boot_directories(rootfs: &Dir, is_ostree: bool) -> Result<()> { + let bootdir = + crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?; + + if is_ostree { + // On ostree systems, the boot directory already has our desired format, we should only + // remove the bootupd-state.json file to avoid bootupctl complaining it already exists. + bootdir + .remove_file_optional("bootupd-state.json") + .context("removing bootupd-state.json")?; + } else { + // This should not remove /boot/efi note. + remove_all_in_dir_no_xdev(&bootdir, false).context("Emptying /boot")?; + // TODO: Discover the ESP the same way bootupd does it; we should also + // support not wiping the ESP. + if ARCH_USES_EFI { + if let Some(efidir) = bootdir + .open_dir_optional(crate::bootloader::EFI_DIR) + .context("Opening /boot/efi")? + { + remove_all_in_dir_no_xdev(&efidir, false) + .context("Emptying EFI system partition")?; + } + } + } + + Ok(()) +} + +struct RootMountInfo { + mount_spec: String, + kargs: Vec, +} + +/// Discover how to mount the root filesystem, using existing kernel arguments and information +/// about the root mount. +fn find_root_args_to_inherit( + cmdline: &bytes::Cmdline, + root_info: &Filesystem, +) -> Result { + // If we have a root= karg, then use that + let root = cmdline + .find_utf8("root")? + .and_then(|p| p.value().map(|p| p.to_string())); + let (mount_spec, kargs) = if let Some(root) = root { + let rootflags = cmdline.find(ROOTFLAGS); + let inherit_kargs = cmdline.find_all_starting_with(INITRD_ARG_PREFIX); + ( + root, + rootflags + .into_iter() + .chain(inherit_kargs) + .map(|p| utf8::Parameter::try_from(p).map(|p| p.to_string())) + .collect::, _>>()?, + ) + } else { + let uuid = root_info + .uuid + .as_deref() + .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?; + (format!("UUID={uuid}"), Vec::new()) + }; + + Ok(RootMountInfo { mount_spec, kargs }) +} + +fn warn_on_host_root(rootfs_fd: &Dir) -> Result<()> { + // Seconds for which we wait while warning + const DELAY_SECONDS: u64 = 20; + + let host_root_dfd = &Dir::open_ambient_dir("/proc/1/root", cap_std::ambient_authority())?; + let host_root_devstat = rustix::fs::fstatvfs(host_root_dfd)?; + let target_devstat = rustix::fs::fstatvfs(rootfs_fd)?; + if host_root_devstat.f_fsid != target_devstat.f_fsid { + tracing::debug!("Not the host root"); + return Ok(()); + } + let dashes = "----------------------------"; + let timeout = Duration::from_secs(DELAY_SECONDS); + eprintln!("{dashes}"); + crate::utils::medium_visibility_warning( + "WARNING: This operation will OVERWRITE THE BOOTED HOST ROOT FILESYSTEM and is NOT REVERSIBLE.", + ); + eprintln!("Waiting {timeout:?} to continue; interrupt (Control-C) to cancel."); + eprintln!("{dashes}"); + + let bar = indicatif::ProgressBar::new_spinner(); + bar.enable_steady_tick(Duration::from_millis(100)); + std::thread::sleep(timeout); + bar.finish(); + + Ok(()) +} + +pub enum Cleanup { + Skip, + TriggerOnNextBoot, +} + +/// Implementation of the `bootc install to-filsystem` CLI command. +#[context("Installing to filesystem")] +pub(crate) async fn install_to_filesystem( + opts: InstallToFilesystemOpts, + targeting_host_root: bool, + cleanup: Cleanup, +) -> Result<()> { + // Log the installation operation to systemd journal + const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; + let source_image = opts + .source_opts + .source_imgref + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("none"); + let target_path = opts.filesystem_opts.root_path.as_str(); + + tracing::info!( + message_id = INSTALL_FILESYSTEM_JOURNAL_ID, + bootc.source_image = source_image, + bootc.target_path = target_path, + bootc.targeting_host_root = if targeting_host_root { "true" } else { "false" }, + "Starting filesystem installation from {} to {}", + source_image, + target_path + ); + + // Gather global state, destructuring the provided options. + // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) + // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. + // IMPORTANT: In practice, we should only be gathering information before this point, + // IMPORTANT: and not performing any mutations at all. + let state = prepare_install( + opts.config_opts, + opts.source_opts, + opts.target_opts, + opts.composefs_opts, + ) + .await?; + + // And the last bit of state here is the fsopts, which we also destructure now. + let mut fsopts = opts.filesystem_opts; + + // If we're doing an alongside install, automatically set up the host rootfs + // mount if it wasn't done already. + if targeting_host_root + && fsopts.root_path.as_str() == ALONGSIDE_ROOT_MOUNT + && !fsopts.root_path.try_exists()? + { + tracing::debug!("Mounting host / to {ALONGSIDE_ROOT_MOUNT}"); + std::fs::create_dir(ALONGSIDE_ROOT_MOUNT)?; + bootc_mount::bind_mount_from_pidns( + bootc_mount::PID1, + "/".into(), + ALONGSIDE_ROOT_MOUNT.into(), + true, + ) + .context("Mounting host / to {ALONGSIDE_ROOT_MOUNT}")?; + } + + // Check that the target is a directory + { + let root_path = &fsopts.root_path; + let st = root_path + .symlink_metadata() + .with_context(|| format!("Querying target filesystem {root_path}"))?; + if !st.is_dir() { + anyhow::bail!("Not a directory: {root_path}"); + } + } + + // Check to see if this happens to be the real host root + if !fsopts.acknowledge_destructive { + let root_path = &fsopts.root_path; + let rootfs_fd = Dir::open_ambient_dir(root_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening target root directory {root_path}"))?; + warn_on_host_root(&rootfs_fd)?; + } + + // If we're installing to an ostree root, then find the physical root from + // the deployment root. + let possible_physical_root = fsopts.root_path.join("sysroot"); + let possible_ostree_dir = possible_physical_root.join("ostree"); + let is_already_ostree = possible_ostree_dir.exists(); + if is_already_ostree { + tracing::debug!( + "ostree detected in {possible_ostree_dir}, assuming target is a deployment root and using {possible_physical_root}" + ); + fsopts.root_path = possible_physical_root; + }; + + // Get a file descriptor for the root path + let rootfs_fd = { + let root_path = &fsopts.root_path; + let rootfs_fd = Dir::open_ambient_dir(&fsopts.root_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening target root directory {root_path}"))?; + + tracing::debug!("Root filesystem: {root_path}"); + + if let Some(false) = rootfs_fd.is_mountpoint(".")? { + anyhow::bail!("Not a mountpoint: {root_path}"); + } + rootfs_fd + }; + + match fsopts.replace { + Some(ReplaceMode::Wipe) => { + let rootfs_fd = rootfs_fd.try_clone()?; + println!("Wiping contents of root"); + tokio::task::spawn_blocking(move || remove_all_in_dir_no_xdev(&rootfs_fd, true)) + .await??; + } + Some(ReplaceMode::Alongside) => clean_boot_directories(&rootfs_fd, is_already_ostree)?, + None => require_empty_rootdir(&rootfs_fd)?, + } + + // Gather data about the root filesystem + let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?; + + // We support overriding the mount specification for root (i.e. LABEL vs UUID versus + // raw paths). + // We also support an empty specification as a signal to omit any mountspec kargs. + let root_info = if let Some(s) = fsopts.root_mount_spec { + RootMountInfo { + mount_spec: s.to_string(), + kargs: Vec::new(), + } + } else if targeting_host_root { + // In the to-existing-root case, look at /proc/cmdline + let cmdline = bytes::Cmdline::from_proc()?; + find_root_args_to_inherit(&cmdline, &inspect)? + } else { + // Otherwise, gather metadata from the provided root and use its provided UUID as a + // default root= karg. + let uuid = inspect + .uuid + .as_deref() + .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?; + let kargs = match inspect.fstype.as_str() { + "btrfs" => { + let subvol = crate::utils::find_mount_option(&inspect.options, "subvol"); + subvol + .map(|vol| format!("rootflags=subvol={vol}")) + .into_iter() + .collect::>() + } + _ => Vec::new(), + }; + RootMountInfo { + mount_spec: format!("UUID={uuid}"), + kargs, + } + }; + tracing::debug!("Root mount: {} {:?}", root_info.mount_spec, root_info.kargs); + + let boot_is_mount = { + let root_dev = rootfs_fd.dir_metadata()?.dev(); + let boot_dev = rootfs_fd + .symlink_metadata_optional(BOOT)? + .ok_or_else(|| { + anyhow!("No /{BOOT} directory found in root; this is is currently required") + })? + .dev(); + tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}"); + root_dev != boot_dev + }; + // Find the UUID of /boot because we need it for GRUB. + let boot_uuid = if boot_is_mount { + let boot_path = fsopts.root_path.join(BOOT); + let u = bootc_mount::inspect_filesystem(&boot_path) + .context("Inspecting /{BOOT}")? + .uuid + .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; + Some(u) + } else { + None + }; + tracing::debug!("boot UUID: {boot_uuid:?}"); + + // Find the real underlying backing device for the root. This is currently just required + // for GRUB (BIOS) and in the future zipl (I think). + let backing_device = { + let mut dev = inspect.source; + loop { + tracing::debug!("Finding parents for {dev}"); + let mut parents = bootc_blockdev::find_parent_devices(&dev)?.into_iter(); + let Some(parent) = parents.next() else { + break; + }; + if let Some(next) = parents.next() { + anyhow::bail!( + "Found multiple parent devices {parent} and {next}; not currently supported" + ); + } + dev = parent; + } + dev + }; + tracing::debug!("Backing device: {backing_device}"); + let device_info = bootc_blockdev::partitions_of(Utf8Path::new(&backing_device))?; + + let rootarg = format!("root={}", root_info.mount_spec); + let mut boot = if let Some(spec) = fsopts.boot_mount_spec { + // An empty boot mount spec signals to ommit the mountspec kargs + // See https://github.com/bootc-dev/bootc/issues/1441 + if spec.is_empty() { + None + } else { + Some(MountSpec::new(&spec, "/boot")) + } + } else { + boot_uuid + .as_deref() + .map(|boot_uuid| MountSpec::new_uuid_src(boot_uuid, "/boot")) + }; + // Ensure that we mount /boot readonly because it's really owned by bootc/ostree + // and we don't want e.g. apt/dnf trying to mutate it. + if let Some(boot) = boot.as_mut() { + boot.push_option("ro"); + } + // By default, we inject a boot= karg because things like FIPS compliance currently + // require checking in the initramfs. + let bootarg = boot.as_ref().map(|boot| format!("boot={}", &boot.source)); + + // If the root mount spec is empty, we omit the mounts kargs entirely. + // https://github.com/bootc-dev/bootc/issues/1441 + let mut kargs = if root_info.mount_spec.is_empty() { + Vec::new() + } else { + [rootarg] + .into_iter() + .chain(root_info.kargs) + .collect::>() + }; + + kargs.push(RW_KARG.to_string()); + + if let Some(bootarg) = bootarg { + kargs.push(bootarg); + } + + let kargs = Cmdline::from(kargs.join(" ")); + + let skip_finalize = + matches!(fsopts.replace, Some(ReplaceMode::Alongside)) || fsopts.skip_finalize; + let mut rootfs = RootSetup { + #[cfg(feature = "install-to-disk")] + luks_device: None, + device_info, + physical_root_path: fsopts.root_path, + physical_root: rootfs_fd, + rootfs_uuid: inspect.uuid.clone(), + boot, + kargs, + skip_finalize, + }; + + install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + + // Drop all data about the root except the path to ensure any file descriptors etc. are closed. + drop(rootfs); + + installation_complete(); + + Ok(()) +} + +pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> Result<()> { + // Log the existing root installation operation to systemd journal + const INSTALL_EXISTING_ROOT_JOURNAL_ID: &str = "7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1"; + let source_image = opts + .source_opts + .source_imgref + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("none"); + let target_path = opts.root_path.as_str(); + + tracing::info!( + message_id = INSTALL_EXISTING_ROOT_JOURNAL_ID, + bootc.source_image = source_image, + bootc.target_path = target_path, + bootc.cleanup = if opts.cleanup { + "trigger_on_next_boot" + } else { + "skip" + }, + "Starting installation to existing root from {} to {}", + source_image, + target_path + ); + + let cleanup = match opts.cleanup { + true => Cleanup::TriggerOnNextBoot, + false => Cleanup::Skip, + }; + + let opts = InstallToFilesystemOpts { + filesystem_opts: InstallTargetFilesystemOpts { + root_path: opts.root_path, + root_mount_spec: None, + boot_mount_spec: None, + replace: opts.replace, + skip_finalize: true, + acknowledge_destructive: opts.acknowledge_destructive, + }, + source_opts: opts.source_opts, + target_opts: opts.target_opts, + config_opts: opts.config_opts, + composefs_opts: opts.composefs_opts, + }; + + install_to_filesystem(opts, true, cleanup).await +} + +/// Read the /boot entry from /etc/fstab, if it exists +fn read_boot_fstab_entry(root: &Dir) -> Result> { + let fstab_path = "etc/fstab"; + let fstab = match root.open_optional(fstab_path)? { + Some(f) => f, + None => return Ok(None), + }; + + let reader = std::io::BufReader::new(fstab); + for line in std::io::BufRead::lines(reader) { + let line = line?; + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Parse the mount spec + let spec = MountSpec::from_str(line)?; + + // Check if this is a /boot entry + if spec.target == "/boot" { + return Ok(Some(spec)); + } + } + + Ok(None) +} + +pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> { + let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + if !opts.experimental { + anyhow::bail!("This command requires --experimental"); + } + + let prog: ProgressWriter = opts.progress.try_into()?; + + let sysroot = &crate::cli::get_storage().await?; + let ostree = sysroot.get_ostree()?; + let repo = &ostree.repo(); + let (booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?; + + let stateroots = list_stateroots(ostree)?; + let target_stateroot = if let Some(s) = opts.stateroot { + s + } else { + let now = chrono::Utc::now(); + let r = allocate_new_stateroot(&ostree, &stateroots, now)?; + r.name + }; + + let booted_stateroot = booted_ostree.stateroot(); + assert!(booted_stateroot.as_str() != target_stateroot); + let (fetched, spec) = if let Some(target) = opts.target_opts.imageref()? { + let mut new_spec = host.spec; + new_spec.image = Some(target.into()); + let fetched = crate::deploy::pull( + repo, + &new_spec.image.as_ref().unwrap(), + None, + opts.quiet, + prog.clone(), + ) + .await?; + (fetched, new_spec) + } else { + let imgstate = host + .status + .booted + .map(|b| b.query_image(repo)) + .transpose()? + .flatten() + .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + (Box::new((*imgstate).into()), host.spec) + }; + let spec = crate::deploy::RequiredHostSpec::from_spec(&spec)?; + + // Compute the kernel arguments to inherit. By default, that's only those involved + // in the root filesystem. + let mut kargs = crate::bootc_kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)?; + + // Extend with root kargs + if !opts.no_root_kargs { + let bootcfg = booted_ostree + .deployment + .bootconfig() + .ok_or_else(|| anyhow!("Missing bootcfg for booted deployment"))?; + if let Some(options) = bootcfg.get("options") { + let options_cmdline = Cmdline::from(options.as_str()); + let root_kargs = crate::bootc_kargs::root_args_from_cmdline(&options_cmdline); + kargs.extend(&root_kargs); + } + } + + // Extend with user-provided kargs + if let Some(user_kargs) = opts.karg.as_ref() { + for karg in user_kargs { + kargs.extend(karg); + } + } + + let from = MergeState::Reset { + stateroot: target_stateroot.clone(), + kargs, + }; + crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone()).await?; + + // Copy /boot entry from /etc/fstab to the new stateroot if it exists + if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? { + let staged_deployment = ostree + .staged_deployment() + .ok_or_else(|| anyhow!("No staged deployment found"))?; + let deployment_path = ostree.deployment_dirpath(&staged_deployment); + let sysroot_dir = crate::utils::sysroot_dir(ostree)?; + let deployment_root = sysroot_dir.open_dir(&deployment_path)?; + + // Write the /boot entry to /etc/fstab in the new deployment + crate::lsm::atomic_replace_labeled( + &deployment_root, + "etc/fstab", + 0o644.into(), + None, + |w| writeln!(w, "{}", boot_spec.to_fstab()).map_err(Into::into), + )?; + + tracing::debug!( + "Copied /boot entry to new stateroot: {}", + boot_spec.to_fstab() + ); + } + + sysroot.update_mtime()?; + + if opts.apply { + crate::reboot::reboot()?; + } + Ok(()) +} + +/// Implementation of `bootc install finalize`. +pub(crate) async fn install_finalize(target: &Utf8Path) -> Result<()> { + // Log the installation finalization operation to systemd journal + const INSTALL_FINALIZE_JOURNAL_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0"; + + tracing::info!( + message_id = INSTALL_FINALIZE_JOURNAL_ID, + bootc.target_path = target.as_str(), + "Starting installation finalization for target: {}", + target + ); + + crate::cli::require_root(false)?; + let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(target))); + sysroot.load(gio::Cancellable::NONE)?; + let deployments = sysroot.deployments(); + // Verify we find a deployment + if deployments.is_empty() { + anyhow::bail!("Failed to find deployment in {target}"); + } + + // Log successful finalization + tracing::info!( + message_id = INSTALL_FINALIZE_JOURNAL_ID, + bootc.target_path = target.as_str(), + "Successfully finalized installation for target: {}", + target + ); + + // For now that's it! We expect to add more validation/postprocessing + // later, such as munging `etc/fstab` if needed. See + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_opts_serializable() { + let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ + "device": "/dev/vda" + })) + .unwrap(); + assert_eq!(c.block_opts.device, "/dev/vda"); + } + + #[test] + fn test_mountspec() { + let mut ms = MountSpec::new("/dev/vda4", "/boot"); + assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto defaults 0 0"); + ms.push_option("ro"); + assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro 0 0"); + ms.push_option("relatime"); + assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro,relatime 0 0"); + } + + #[test] + fn test_gather_root_args() { + // A basic filesystem using a UUID + let inspect = Filesystem { + source: "/dev/vda4".into(), + target: "/".into(), + fstype: "xfs".into(), + maj_min: "252:4".into(), + options: "rw".into(), + uuid: Some("965eb3c7-5a3f-470d-aaa2-1bcf04334bc6".into()), + children: None, + }; + let kargs = bytes::Cmdline::from(""); + let r = find_root_args_to_inherit(&kargs, &inspect).unwrap(); + assert_eq!(r.mount_spec, "UUID=965eb3c7-5a3f-470d-aaa2-1bcf04334bc6"); + + let kargs = bytes::Cmdline::from( + "root=/dev/mapper/root rw someother=karg rd.lvm.lv=root systemd.debug=1", + ); + + // In this case we take the root= from the kernel cmdline + let r = find_root_args_to_inherit(&kargs, &inspect).unwrap(); + assert_eq!(r.mount_spec, "/dev/mapper/root"); + assert_eq!(r.kargs.len(), 1); + assert_eq!(r.kargs[0], "rd.lvm.lv=root"); + + // non-UTF8 data in non-essential parts of the cmdline should be ignored + let kargs = bytes::Cmdline::from( + b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1", + ); + let r = find_root_args_to_inherit(&kargs, &inspect).unwrap(); + assert_eq!(r.mount_spec, "/dev/mapper/root"); + assert_eq!(r.kargs.len(), 1); + assert_eq!(r.kargs[0], "rd.lvm.lv=root"); + + // non-UTF8 data in `root` should fail + let kargs = bytes::Cmdline::from( + b"root=/dev/mapper/ro\xffot rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1", + ); + let r = find_root_args_to_inherit(&kargs, &inspect); + assert!(r.is_err()); + + // non-UTF8 data in `rd.` should fail + let kargs = bytes::Cmdline::from( + b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=ro\xffot systemd.debug=1", + ); + let r = find_root_args_to_inherit(&kargs, &inspect); + assert!(r.is_err()); + } + + // As this is a unit test we don't try to test mountpoints, just verify + // that we have the equivalent of rm -rf * + #[test] + fn test_remove_all_noxdev() -> Result<()> { + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + td.create_dir_all("foo/bar/baz")?; + td.write("foo/bar/baz/test", b"sometest")?; + td.symlink_contents("/absolute-nonexistent-link", "somelink")?; + td.write("toptestfile", b"othertestcontents")?; + + remove_all_in_dir_no_xdev(&td, true).unwrap(); + + assert_eq!(td.entries()?.count(), 0); + + Ok(()) + } + + #[test] + fn test_read_boot_fstab_entry() -> Result<()> { + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + // Test with no /etc/fstab + assert!(read_boot_fstab_entry(&td)?.is_none()); + + // Test with /etc/fstab but no /boot entry + td.create_dir("etc")?; + td.write("etc/fstab", "UUID=test-uuid / ext4 defaults 0 0\n")?; + assert!(read_boot_fstab_entry(&td)?.is_none()); + + // Test with /boot entry + let fstab_content = "\ +# /etc/fstab +UUID=root-uuid / ext4 defaults 0 0 +UUID=boot-uuid /boot ext4 ro 0 0 +UUID=home-uuid /home ext4 defaults 0 0 +"; + td.write("etc/fstab", fstab_content)?; + let boot_spec = read_boot_fstab_entry(&td)?.unwrap(); + assert_eq!(boot_spec.source, "UUID=boot-uuid"); + assert_eq!(boot_spec.target, "/boot"); + assert_eq!(boot_spec.fstype, "ext4"); + assert_eq!(boot_spec.options, Some("ro".to_string())); + + // Test with /boot entry with comments + let fstab_content = "\ +# /etc/fstab +# Created by anaconda +UUID=root-uuid / ext4 defaults 0 0 +# Boot partition +UUID=boot-uuid /boot ext4 defaults 0 0 +"; + td.write("etc/fstab", fstab_content)?; + let boot_spec = read_boot_fstab_entry(&td)?.unwrap(); + assert_eq!(boot_spec.source, "UUID=boot-uuid"); + assert_eq!(boot_spec.target, "/boot"); + + Ok(()) + } +} diff --git a/crates/lib/src/install/aleph.rs b/crates/lib/src/install/aleph.rs new file mode 100644 index 000000000..5983513da --- /dev/null +++ b/crates/lib/src/install/aleph.rs @@ -0,0 +1,63 @@ +use anyhow::{Context as _, Result}; +use canon_json::CanonJsonSerialize as _; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt as _}; +use fn_error_context::context; +use ostree_ext::{container as ostree_container, oci_spec}; +use serde::Serialize; + +use super::SELinuxFinalState; + +/// Path to initially deployed version information +pub(crate) const BOOTC_ALEPH_PATH: &str = ".bootc-aleph.json"; + +/// The "aleph" version information is injected into /root/.bootc-aleph.json +/// and contains the image ID that was initially used to install. This can +/// be used to trace things like the specific version of `mkfs.ext4` or +/// kernel version that was used. +#[derive(Debug, Serialize)] +pub(crate) struct InstallAleph { + /// Digested pull spec for installed image + pub(crate) image: String, + /// The version number + pub(crate) version: Option, + /// The timestamp + pub(crate) timestamp: Option>, + /// The `uname -r` of the kernel doing the installation + pub(crate) kernel: String, + /// The state of SELinux at install time + pub(crate) selinux: String, +} + +impl InstallAleph { + #[context("Creating aleph data")] + pub(crate) fn new( + src_imageref: &ostree_container::OstreeImageReference, + imgstate: &ostree_container::store::LayeredImageState, + selinux_state: &SELinuxFinalState, + ) -> Result { + let uname = rustix::system::uname(); + let labels = crate::status::labels_of_config(&imgstate.configuration); + let timestamp = labels + .and_then(|l| { + l.get(oci_spec::image::ANNOTATION_CREATED) + .map(|s| s.as_str()) + }) + .and_then(bootc_utils::try_deserialize_timestamp); + let r = InstallAleph { + image: src_imageref.imgref.name.clone(), + version: imgstate.version().as_ref().map(|s| s.to_string()), + timestamp, + kernel: uname.release().to_str()?.to_string(), + selinux: selinux_state.to_aleph().to_string(), + }; + Ok(r) + } + + /// Serialize to a file in the target root. + pub(crate) fn write_to(&self, root: &Dir) -> Result<()> { + root.atomic_replace_with(BOOTC_ALEPH_PATH, |f| { + anyhow::Ok(self.to_canon_json_writer(f)?) + }) + .context("Writing aleph version") + } +} diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs new file mode 100644 index 000000000..40a537e11 --- /dev/null +++ b/crates/lib/src/install/baseline.rs @@ -0,0 +1,499 @@ +//! # The baseline installer +//! +//! This module handles creation of simple root filesystem setups. At the current time +//! it's very simple - just a direct filesystem (e.g. xfs, ext4, btrfs etc.). It is +//! intended to add opinionated handling of TPM2-bound LUKS too. But that's about it; +//! other more complex flows should set things up externally and use `bootc install to-filesystem`. + +use std::borrow::Cow; +use std::fmt::Display; +use std::fmt::Write as _; +use std::io::Write; +use std::process::Command; +use std::process::Stdio; + +use anyhow::Ok; +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use camino::Utf8PathBuf; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use clap::ValueEnum; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; + +use super::config::Filesystem; +use super::MountSpec; +use super::RootSetup; +use super::State; +use super::RUN_BOOTC; +use super::RW_KARG; +use crate::task::Task; +use bootc_kernel_cmdline::utf8::Cmdline; +#[cfg(feature = "install-to-disk")] +use bootc_mount::is_mounted_in_pid1_mountns; + +// This ensures we end up under 512 to be small-sized. +pub(crate) const BOOTPN_SIZE_MB: u32 = 510; +pub(crate) const EFIPN_SIZE_MB: u32 = 512; +/// EFI Partition size for composefs installations +/// We need more space than ostree as we have UKIs and UKI addons +/// We might also need to store UKIs for pinned deployments +pub(crate) const CFS_EFIPN_SIZE_MB: u32 = 1024; +#[cfg(feature = "install-to-disk")] +pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; +#[cfg(feature = "install-to-disk")] +pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot"; + +#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum BlockSetup { + #[default] + Direct, + Tpm2Luks, +} + +impl Display for BlockSetup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +/// Options for installing to a block device +#[derive(Debug, Clone, clap::Args, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct InstallBlockDeviceOpts { + /// Target block device for installation. The entire device will be wiped. + pub(crate) device: Utf8PathBuf, + + /// Automatically wipe all existing data on device + #[clap(long)] + #[serde(default)] + pub(crate) wipe: bool, + + /// Target root block device setup. + /// + /// direct: Filesystem written directly to block device + /// tpm2-luks: Bind unlock of filesystem to presence of the default tpm2 device. + #[clap(long, value_enum)] + pub(crate) block_setup: Option, + + /// Target root filesystem type. + #[clap(long, value_enum)] + pub(crate) filesystem: Option, + + /// Size of the root partition (default specifier: M). Allowed specifiers: M (mebibytes), G (gibibytes), T (tebibytes). + /// + /// By default, all remaining space on the disk will be used. + #[clap(long)] + pub(crate) root_size: Option, +} + +impl BlockSetup { + /// Returns true if the block setup requires a separate /boot aka XBOOTLDR partition. + pub(crate) fn requires_bootpart(&self) -> bool { + match self { + BlockSetup::Direct => false, + BlockSetup::Tpm2Luks => true, + } + } +} + +#[cfg(feature = "install-to-disk")] +fn mkfs<'a>( + dev: &str, + fs: Filesystem, + label: &str, + wipe: bool, + opts: impl IntoIterator, +) -> Result { + let devinfo = bootc_blockdev::list_dev(dev.into())?; + let size = ostree_ext::glib::format_size(devinfo.size); + + // Generate a random UUID for the filesystem + let u = uuid::Uuid::new_v4(); + + let mut t = Task::new( + &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"), + format!("mkfs.{fs}"), + ); + match fs { + Filesystem::Xfs => { + if wipe { + t.cmd.arg("-f"); + } + t.cmd.arg("-m"); + t.cmd.arg(format!("uuid={u}")); + } + Filesystem::Btrfs | Filesystem::Ext4 => { + t.cmd.arg("-U"); + t.cmd.arg(u.to_string()); + } + }; + // Today all the above mkfs commands take -L + t.cmd.args(["-L", label]); + t.cmd.args(opts); + t.cmd.arg(dev); + // All the mkfs commands are unnecessarily noisy by default + t.cmd.stdout(Stdio::null()); + // But this one is notable so let's print the whole thing with verbose() + t.verbose().run()?; + Ok(u) +} + +pub(crate) fn wipefs(dev: &Utf8Path) -> Result<()> { + println!("Wiping device {dev}"); + Command::new("wipefs") + .args(["-a", dev.as_str()]) + .run_inherited_with_cmd_context() +} + +pub(crate) fn udev_settle() -> Result<()> { + // There's a potential window after rereading the partition table where + // udevd hasn't yet received updates from the kernel, settle will return + // immediately, and lsblk won't pick up partition labels. Try to sleep + // our way out of this. + std::thread::sleep(std::time::Duration::from_millis(200)); + + let st = super::run_in_host_mountns("udevadm")? + .arg("settle") + .status()?; + if !st.success() { + anyhow::bail!("Failed to run udevadm settle: {st:?}"); + } + Ok(()) +} + +#[context("Creating rootfs")] +#[cfg(feature = "install-to-disk")] +pub(crate) fn install_create_rootfs( + state: &State, + opts: InstallBlockDeviceOpts, +) -> Result { + let install_config = state.install_config.as_ref(); + let luks_name = "root"; + // Ensure we have a root filesystem upfront + let root_filesystem = opts + .filesystem + .or(install_config + .and_then(|c| c.filesystem_root()) + .and_then(|r| r.fstype)) + .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; + // Verify that the target is empty (if not already wiped in particular, but it's + // also good to verify that the wipe worked) + let device = bootc_blockdev::list_dev(&opts.device)?; + // Canonicalize devpath + let devpath: Utf8PathBuf = device.path().into(); + + // Always disallow writing to mounted device + if is_mounted_in_pid1_mountns(&device.path())? { + anyhow::bail!("Device {} is mounted", device.path()) + } + + // Handle wiping any existing data + if opts.wipe { + let dev = &opts.device; + for child in device.children.iter().flatten() { + let child = child.path(); + println!("Wiping {child}"); + wipefs(Utf8Path::new(&child))?; + } + println!("Wiping {dev}"); + wipefs(dev)?; + } else if device.has_children() { + anyhow::bail!( + "Detected existing partitions on {}; use e.g. `wipefs` or --wipe if you intend to overwrite", + opts.device + ); + } + + let run_bootc = Utf8Path::new(RUN_BOOTC); + let mntdir = run_bootc.join("mounts"); + if mntdir.exists() { + std::fs::remove_dir_all(&mntdir)?; + } + + // Use the install configuration to find the block setup, if we have one + let block_setup = if let Some(config) = install_config { + config.get_block_setup(opts.block_setup.as_ref().copied())? + } else if opts.filesystem.is_some() { + // Otherwise, if a filesystem is specified then we default to whatever was + // specified via --block-setup, or the default + opts.block_setup.unwrap_or_default() + } else { + // If there was no default filesystem, then there's no default block setup, + // and we need to error out. + anyhow::bail!("No install configuration found, and no filesystem specified") + }; + let serial = device.serial.as_deref().unwrap_or(""); + let model = device.model.as_deref().unwrap_or(""); + println!("Block setup: {block_setup}"); + println!(" Size: {}", device.size); + println!(" Serial: {serial}"); + println!(" Model: {model}"); + + let root_size = opts + .root_size + .as_deref() + .map(bootc_blockdev::parse_size_mib) + .transpose() + .context("Parsing root size")?; + + // Load the policy from the container root, which also must be our install root + let sepolicy = state.load_policy()?; + let sepolicy = sepolicy.as_ref(); + + // Create a temporary directory to use for mount points. Note that we're + // in a mount namespace, so these should not be visible on the host. + let physical_root_path = mntdir.join("rootfs"); + std::fs::create_dir_all(&physical_root_path)?; + let bootfs = mntdir.join("boot"); + std::fs::create_dir_all(bootfs)?; + + // Generate partitioning spec as input to sfdisk + let mut partno = 0; + let mut partitioning_buf = String::new(); + writeln!(partitioning_buf, "label: gpt")?; + let random_label = uuid::Uuid::new_v4(); + writeln!(&mut partitioning_buf, "label-id: {random_label}")?; + if cfg!(target_arch = "x86_64") { + partno += 1; + writeln!( + &mut partitioning_buf, + r#"size=1MiB, bootable, type=21686148-6449-6E6F-744E-656564454649, name="BIOS-BOOT""# + )?; + } else if cfg!(target_arch = "powerpc64") { + // PowerPC-PReP-boot + partno += 1; + let label = PREPBOOT_LABEL; + let uuid = PREPBOOT_GUID; + writeln!( + &mut partitioning_buf, + r#"size=4MiB, bootable, type={uuid}, name="{label}""# + )?; + } else if cfg!(any(target_arch = "aarch64", target_arch = "s390x")) { + // No bootloader partition is necessary + } else { + anyhow::bail!("Unsupported architecture: {}", std::env::consts::ARCH); + } + + let esp_partno = if super::ARCH_USES_EFI { + let esp_guid = crate::discoverable_partition_specification::ESP; + partno += 1; + + let esp_size = if state.composefs_options.composefs_backend { + CFS_EFIPN_SIZE_MB + } else { + EFIPN_SIZE_MB + }; + + writeln!( + &mut partitioning_buf, + r#"size={esp_size}MiB, type={esp_guid}, name="EFI-SYSTEM""# + )?; + Some(partno) + } else { + None + }; + + // Initialize the /boot filesystem. Note that in the future, we may match + // what systemd/uapi-group encourages and make /boot be FAT32 as well, as + // it would aid systemd-boot. + let boot_partno = if block_setup.requires_bootpart() { + partno += 1; + writeln!( + &mut partitioning_buf, + r#"size={BOOTPN_SIZE_MB}MiB, name="boot""# + )?; + Some(partno) + } else { + None + }; + let rootpn = partno + 1; + let root_size = root_size + .map(|v| Cow::Owned(format!("size={v}MiB, "))) + .unwrap_or_else(|| Cow::Borrowed("")); + let rootpart_uuid = + uuid::Uuid::parse_str(crate::discoverable_partition_specification::this_arch_root())?; + writeln!( + &mut partitioning_buf, + r#"{root_size}type={rootpart_uuid}, name="root""# + )?; + tracing::debug!("Partitioning: {partitioning_buf}"); + Task::new("Initializing partitions", "sfdisk") + .arg("--wipe=always") + .arg(device.path()) + .quiet() + .run_with_stdin_buf(Some(partitioning_buf.as_bytes())) + .context("Failed to run sfdisk")?; + tracing::debug!("Created partition table"); + + // Full udev sync; it'd obviously be better to await just the devices + // we're targeting, but this is a simple coarse hammer. + udev_settle()?; + + // Re-read what we wrote into structured information + let base_partitions = &bootc_blockdev::partitions_of(&devpath)?; + + let root_partition = base_partitions.find_partno(rootpn)?; + // Verify the partition type matches the DPS root partition type for this architecture + let expected_parttype = crate::discoverable_partition_specification::this_arch_root(); + if !root_partition + .parttype + .eq_ignore_ascii_case(expected_parttype) + { + anyhow::bail!( + "root partition {rootpn} has type {}; expected {expected_parttype}", + root_partition.parttype.as_str() + ); + } + let (rootdev, root_blockdev_kargs) = match block_setup { + BlockSetup::Direct => (root_partition.node.to_owned(), None), + BlockSetup::Tpm2Luks => { + let uuid = uuid::Uuid::new_v4().to_string(); + // This will be replaced via --wipe-slot=all when binding to tpm below + let dummy_passphrase = uuid::Uuid::new_v4().to_string(); + let mut tmp_keyfile = tempfile::NamedTempFile::new()?; + tmp_keyfile.write_all(dummy_passphrase.as_bytes())?; + tmp_keyfile.flush()?; + let tmp_keyfile = tmp_keyfile.path(); + let dummy_passphrase_input = Some(dummy_passphrase.as_bytes()); + + let root_devpath = root_partition.path(); + + Task::new("Initializing LUKS for root", "cryptsetup") + .args(["luksFormat", "--uuid", uuid.as_str(), "--key-file"]) + .args([tmp_keyfile]) + .args([root_devpath]) + .run()?; + // The --wipe-slot=all removes our temporary passphrase, and binds to the local TPM device. + // We also use .verbose() here as the details are important/notable. + Task::new("Enrolling root device with TPM", "systemd-cryptenroll") + .args(["--wipe-slot=all", "--tpm2-device=auto", "--unlock-key-file"]) + .args([tmp_keyfile]) + .args([root_devpath]) + .verbose() + .run_with_stdin_buf(dummy_passphrase_input)?; + Task::new("Opening root LUKS device", "cryptsetup") + .args(["luksOpen", root_devpath.as_str(), luks_name]) + .run()?; + let rootdev = format!("/dev/mapper/{luks_name}"); + let kargs = vec![ + format!("luks.uuid={uuid}"), + format!("luks.options=tpm2-device=auto,headless=true"), + ]; + (rootdev, Some(kargs)) + } + }; + + // Initialize the /boot filesystem + let bootdev = if let Some(bootpn) = boot_partno { + Some(base_partitions.find_partno(bootpn)?) + } else { + None + }; + let boot_uuid = if let Some(bootdev) = bootdev { + Some( + mkfs( + bootdev.node.as_str(), + root_filesystem, + "boot", + opts.wipe, + [], + ) + .context("Initializing /boot")?, + ) + } else { + None + }; + + // Unconditionally enable fsverity for ext4 + let mkfs_options = match root_filesystem { + Filesystem::Ext4 => ["-O", "verity"].as_slice(), + _ => [].as_slice(), + }; + + // Initialize rootfs + let root_uuid = mkfs( + &rootdev, + root_filesystem, + "root", + opts.wipe, + mkfs_options.iter().copied(), + )?; + let rootarg = format!("root=UUID={root_uuid}"); + let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); + let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}")); + let boot = bootsrc.map(|bootsrc| MountSpec { + source: bootsrc, + target: "/boot".into(), + fstype: MountSpec::AUTO.into(), + options: Some("ro".into()), + }); + + let mut kargs = Cmdline::new(); + + // Add root blockdev kargs (e.g., LUKS parameters) + if let Some(root_blockdev_kargs) = root_blockdev_kargs { + for karg in root_blockdev_kargs { + kargs.extend(&Cmdline::from(karg.as_str())); + } + } + + // Add root= and rw argument + kargs.extend(&Cmdline::from(format!("{rootarg} {RW_KARG}"))); + + // Add boot= argument if present + if let Some(bootarg) = bootarg { + kargs.extend(&Cmdline::from(bootarg.as_str())); + } + + // Add CLI kargs + if let Some(cli_kargs) = state.config_opts.karg.as_ref() { + for karg in cli_kargs { + kargs.extend(karg); + } + } + + bootc_mount::mount(&rootdev, &physical_root_path)?; + let target_rootfs = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?; + crate::lsm::ensure_dir_labeled(&target_rootfs, "", Some("/".into()), 0o755.into(), sepolicy)?; + let physical_root = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?; + let bootfs = physical_root_path.join("boot"); + // Create the underlying mount point directory, which should be labeled + crate::lsm::ensure_dir_labeled(&target_rootfs, "boot", None, 0o755.into(), sepolicy)?; + if let Some(bootdev) = bootdev { + bootc_mount::mount(bootdev.node.as_str(), &bootfs)?; + } + // And we want to label the root mount of /boot + crate::lsm::ensure_dir_labeled(&target_rootfs, "boot", None, 0o755.into(), sepolicy)?; + + // Create the EFI system partition, if applicable + if let Some(esp_partno) = esp_partno { + let espdev = base_partitions.find_partno(esp_partno)?; + Task::new("Creating ESP filesystem", "mkfs.fat") + .args([espdev.node.as_str(), "-n", "EFI-SYSTEM"]) + .verbose() + .quiet_output() + .run()?; + let efifs_path = bootfs.join(crate::bootloader::EFI_DIR); + std::fs::create_dir(&efifs_path).context("Creating efi dir")?; + } + + let luks_device = match block_setup { + BlockSetup::Direct => None, + BlockSetup::Tpm2Luks => Some(luks_name.to_string()), + }; + let device_info = bootc_blockdev::partitions_of(&devpath)?; + Ok(RootSetup { + luks_device, + device_info, + physical_root_path, + physical_root, + rootfs_uuid: Some(root_uuid.to_string()), + boot, + kargs, + skip_finalize: false, + }) +} diff --git a/crates/lib/src/install/completion.rs b/crates/lib/src/install/completion.rs new file mode 100644 index 000000000..32362e354 --- /dev/null +++ b/crates/lib/src/install/completion.rs @@ -0,0 +1,332 @@ +//! This module handles finishing/completion after an ostree-based +//! install from e.g. Anaconda. + +use std::io; +use std::os::fd::AsFd; +use std::process::Command; + +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use ostree_ext::{gio, ostree}; +use rustix::fs::Mode; +use rustix::fs::OFlags; + +use crate::podstorage::CStorage; +use crate::utils::deployment_fd; + +use super::config; + +/// An environment variable set by anaconda that hints +/// we are running as part of that environment. +const ANACONDA_ENV_HINT: &str = "ANA_INSTALL_PATH"; +/// The path where Anaconda sets up the target. +/// +const ANACONDA_SYSROOT: &str = "mnt/sysroot"; +/// Global flag to signal we're in a booted ostree system +const OSTREE_BOOTED: &str = "run/ostree-booted"; +/// The very well-known DNS resolution file +const RESOLVCONF: &str = "etc/resolv.conf"; +/// A renamed file +const RESOLVCONF_ORIG: &str = "etc/resolv.conf.bootc-original"; +/// The root filesystem for pid 1 +const PROC1_ROOT: &str = "proc/1/root"; +/// The cgroupfs mount point, which we may propagate from the host if needed +const CGROUPFS: &str = "sys/fs/cgroup"; +/// The path to the temporary global ostree pull secret +const RUN_OSTREE_AUTH: &str = "run/ostree/auth.json"; +/// A sub path of /run which is used to ensure idempotency +pub(crate) const RUN_BOOTC_INSTALL_RECONCILED: &str = "run/bootc-install-reconciled"; + +/// Assuming that the current root is an ostree deployment, pull kargs +/// from it and inject them. +fn reconcile_kargs(sysroot: &ostree::Sysroot, deployment: &ostree::Deployment) -> Result<()> { + let deployment_root = &crate::utils::deployment_fd(sysroot, deployment)?; + let cancellable = gio::Cancellable::NONE; + + let current_kargs = deployment + .bootconfig() + .expect("bootconfig for deployment") + .get("options"); + let current_kargs = current_kargs + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(); + tracing::debug!("current_kargs={current_kargs}"); + let current_kargs = ostree::KernelArgs::from_string(¤t_kargs); + + // Keep this in sync with install_container + let install_config = config::load_config()?; + let install_config_kargs = install_config + .as_ref() + .and_then(|c| c.kargs.as_ref()) + .into_iter() + .flatten() + .map(|s| s.as_str()) + .collect::>(); + let kargsd = crate::bootc_kargs::get_kargs_in_root(deployment_root, std::env::consts::ARCH)?; + let kargsd_strs = kargsd.iter_str().collect::>(); + + current_kargs.append_argv(&install_config_kargs); + current_kargs.append_argv(&kargsd_strs); + let new_kargs = current_kargs.to_string(); + tracing::debug!("new_kargs={new_kargs}"); + + sysroot.deployment_set_kargs_in_place(deployment, Some(&new_kargs), cancellable)?; + Ok(()) +} + +/// A little helper struct which on drop renames a file. Used for putting back /etc/resolv.conf. +#[must_use] +struct Renamer<'d> { + dir: &'d Dir, + from: &'static Utf8Path, + to: &'static Utf8Path, +} + +impl Renamer<'_> { + fn _impl_drop(&mut self) -> Result<()> { + self.dir + .rename(self.from, self.dir, self.to) + .map_err(Into::into) + } + + fn consume(mut self) -> Result<()> { + self._impl_drop() + } +} + +impl Drop for Renamer<'_> { + fn drop(&mut self) { + let _ = self._impl_drop(); + } +} +/// Work around https://github.com/containers/buildah/issues/4242#issuecomment-2492480586 +/// among other things. We unconditionally replace the contents of `/etc/resolv.conf` +/// in the target root with whatever the host uses (in Fedora 41+, that's systemd-resolved for Anaconda). +#[context("Copying host resolv.conf")] +fn ensure_resolvconf<'d>(rootfs: &'d Dir, proc1_root: &Dir) -> Result>> { + // Now check the state of etc/resolv.conf in the target root + let meta = rootfs + .symlink_metadata_optional(RESOLVCONF) + .context("stat")?; + let renamer = if meta.is_some() { + rootfs + .rename(RESOLVCONF, &rootfs, RESOLVCONF_ORIG) + .context("Renaming")?; + Some(Renamer { + dir: &rootfs, + from: RESOLVCONF_ORIG.into(), + to: RESOLVCONF.into(), + }) + } else { + None + }; + // If we got here, /etc/resolv.conf either didn't exist or we removed it. + // Copy the host data into it (note this will follow symlinks; e.g. + // Anaconda in Fedora 41+ defaults to systemd-resolved) + proc1_root + .copy(RESOLVCONF, rootfs, RESOLVCONF) + .context("Copying new resolv.conf")?; + Ok(renamer) +} + +/// Bind a mount point from the host namespace into our root +fn bind_from_host( + rootfs: &Dir, + src: impl AsRef, + target: impl AsRef, +) -> Result<()> { + fn bind_from_host_impl(rootfs: &Dir, src: &Utf8Path, target: &Utf8Path) -> Result<()> { + rootfs.create_dir_all(target)?; + if rootfs.is_mountpoint(target)?.unwrap_or_default() { + return Ok(()); + } + let target = format!("/{ANACONDA_SYSROOT}/{target}"); + tracing::debug!("Binding {src} to {target}"); + // We're run in a mount namespace, but not a pid namespace; use nsenter + // via the pid namespace to escape to the host's mount namespace and + // perform a mount there. + Command::new("nsenter") + .args(["-m", "-t", "1", "--", "mount", "--bind"]) + .arg(src) + .arg(&target) + .run_capture_stderr()?; + Ok(()) + } + + bind_from_host_impl(rootfs, src.as_ref(), target.as_ref()) +} + +/// Anaconda doesn't mount /sys/fs/cgroup in /mnt/sysroot +#[context("Ensuring cgroupfs")] +fn ensure_cgroupfs(rootfs: &Dir) -> Result<()> { + bind_from_host(rootfs, CGROUPFS, CGROUPFS) +} + +/// If we have /etc/ostree/auth.json in the Anaconda environment then propagate +/// it into /run/ostree/auth.json +#[context("Propagating ostree auth")] +fn ensure_ostree_auth(rootfs: &Dir, host_root: &Dir) -> Result<()> { + let Some((authpath, authfd)) = + ostree_ext::globals::get_global_authfile(&host_root).context("Querying authfiles")? + else { + tracing::debug!("No auth found in host"); + return Ok(()); + }; + tracing::debug!("Discovered auth in host: {authpath}"); + let mut authfd = io::BufReader::new(authfd); + let run_ostree_auth = Utf8Path::new(RUN_OSTREE_AUTH); + rootfs.create_dir_all(run_ostree_auth.parent().unwrap())?; + rootfs.atomic_replace_with(run_ostree_auth, |w| std::io::copy(&mut authfd, w))?; + Ok(()) +} + +#[context("Opening {PROC1_ROOT}")] +fn open_proc1_root(rootfs: &Dir) -> Result { + let proc1_root = rustix::fs::openat( + &rootfs.as_fd(), + PROC1_ROOT, + OFlags::CLOEXEC | OFlags::DIRECTORY, + Mode::empty(), + )?; + Dir::reopen_dir(&proc1_root.as_fd()).map_err(Into::into) +} + +/// Core entrypoint invoked when we are likely being invoked from inside Anaconda as a `%post`. +pub(crate) async fn run_from_anaconda(rootfs: &Dir) -> Result<()> { + // unshare our mount namespace, so any *further* mounts aren't leaked. + // Note that because this does a re-exec, anything *before* this point + // should be idempotent. + crate::cli::require_root(false)?; + crate::cli::ensure_self_unshared_mount_namespace()?; + + if std::env::var_os(ANACONDA_ENV_HINT).is_none() { + anyhow::bail!("Missing environment variable {ANACONDA_ENV_HINT}"); + } else { + // In the way Anaconda sets up the bind mounts today, this doesn't exist. Later + // code expects it to exist, so do so. + if !rootfs.try_exists(OSTREE_BOOTED)? { + tracing::debug!("Writing {OSTREE_BOOTED}"); + rootfs.atomic_write(OSTREE_BOOTED, b"")?; + } + } + + // Get access to the real root by opening /proc/1/root + let proc1_root = &open_proc1_root(rootfs)?; + + if proc1_root + .try_exists(RUN_BOOTC_INSTALL_RECONCILED) + .context("Querying reconciliation")? + { + println!("Reconciliation already completed."); + return Ok(()); + } + + ensure_cgroupfs(rootfs)?; + // Sometimes Anaconda may not initialize networking in the target root? + let resolvconf = ensure_resolvconf(rootfs, proc1_root)?; + // Propagate an injected authfile for pulling logically bound images + ensure_ostree_auth(rootfs, proc1_root)?; + + let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path("/"))); + sysroot + .load(gio::Cancellable::NONE) + .context("Loading sysroot")?; + impl_completion(rootfs, &sysroot, None).await?; + + proc1_root + .write(RUN_BOOTC_INSTALL_RECONCILED, b"") + .with_context(|| format!("Writing {RUN_BOOTC_INSTALL_RECONCILED}"))?; + if let Some(resolvconf) = resolvconf { + resolvconf.consume()?; + } + Ok(()) +} + +/// From ostree-rs-ext, run through the rest of bootc install functionality +pub async fn run_from_ostree(rootfs: &Dir, sysroot: &Utf8Path, stateroot: &str) -> Result<()> { + crate::cli::require_root(false)?; + // Load sysroot from the provided path + let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(sysroot))); + sysroot.load(gio::Cancellable::NONE)?; + + impl_completion(rootfs, &sysroot, Some(stateroot)).await?; + + // In this case we write the completion directly to /run as we're running from + // the host context. + rootfs + .write(RUN_BOOTC_INSTALL_RECONCILED, b"") + .with_context(|| format!("Writing {RUN_BOOTC_INSTALL_RECONCILED}"))?; + Ok(()) +} + +/// Core entrypoint for completion of an ostree-based install to a bootc one: +/// +/// - kernel argument handling +/// - logically bound images +/// +/// We could also do other things here, such as write an aleph file or +/// ensure the repo config is synchronized, but these two are the most important +/// for now. +pub(crate) async fn impl_completion( + rootfs: &Dir, + sysroot: &ostree::Sysroot, + stateroot: Option<&str>, +) -> Result<()> { + // Log the completion operation to systemd journal + const COMPLETION_JOURNAL_ID: &str = "0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4"; + tracing::info!( + message_id = COMPLETION_JOURNAL_ID, + bootc.stateroot = stateroot.unwrap_or("default"), + "Starting bootc installation completion" + ); + + let deployment = &sysroot + .merge_deployment(stateroot) + .ok_or_else(|| anyhow::anyhow!("Failed to find deployment (stateroot={stateroot:?})"))?; + let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; + + // Create a subdir in /run + let rundir = "run/bootc-install"; + rootfs.create_dir_all(rundir)?; + let rundir = &rootfs.open_dir(rundir)?; + + // ostree-ext doesn't do kargs, so handle that now + reconcile_kargs(&sysroot, deployment)?; + + // ostree-ext doesn't do logically bound images + let bound_images = crate::boundimage::query_bound_images_for_deployment(sysroot, deployment)?; + + if !bound_images.is_empty() { + // Log bound images found + tracing::info!( + message_id = COMPLETION_JOURNAL_ID, + bootc.bound_images_count = bound_images.len(), + "Found {} bound images for completion", + bound_images.len() + ); + + // load the selinux policy from the target ostree deployment + let deployment_fd = deployment_fd(sysroot, deployment)?; + let sepolicy = crate::lsm::new_sepolicy_at(deployment_fd)?; + + // When we're run through ostree, we only lazily initialize the podman storage to avoid + // having a hard dependency on it. + let imgstorage = &CStorage::create(&sysroot_dir, &rundir, sepolicy.as_ref())?; + crate::boundimage::pull_images_impl(imgstorage, bound_images) + .await + .context("pulling bound images")?; + } + + // Log completion success + tracing::info!( + message_id = COMPLETION_JOURNAL_ID, + bootc.stateroot = stateroot.unwrap_or("default"), + "Successfully completed bootc installation" + ); + + Ok(()) +} diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs new file mode 100644 index 000000000..bdeecb459 --- /dev/null +++ b/crates/lib/src/install/config.rs @@ -0,0 +1,575 @@ +//! # Configuration for `bootc install` +//! +//! This module handles the TOML configuration file for `bootc install`. + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "install-to-disk")] +use super::baseline::BlockSetup; + +/// Properties of the environment, such as the system architecture +/// Left open for future properties such as `platform.id` +pub(crate) struct EnvProperties { + pub(crate) sys_arch: String, +} + +/// A well known filesystem type. +#[derive(clap::ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Filesystem { + Xfs, + Ext4, + Btrfs, +} + +impl std::fmt::Display for Filesystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +/// The toplevel config entry for installation configs stored +/// in bootc/install (e.g. /etc/bootc/install/05-custom.toml) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub(crate) struct InstallConfigurationToplevel { + pub(crate) install: Option, +} + +/// Configuration for a filesystem +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub(crate) struct RootFS { + #[serde(rename = "type")] + pub(crate) fstype: Option, +} + +/// This structure should only define "system" or "basic" filesystems; we are +/// not trying to generalize this into e.g. supporting `/var` or other ones. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub(crate) struct BasicFilesystems { + pub(crate) root: Option, + // TODO allow configuration of these other filesystems too + // pub(crate) xbootldr: Option, + // pub(crate) esp: Option, +} + +/// The serialized [install] section +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename = "install", rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct InstallConfiguration { + /// Root filesystem type + pub(crate) root_fs_type: Option, + /// Enabled block storage configurations + #[cfg(feature = "install-to-disk")] + pub(crate) block: Option>, + pub(crate) filesystem: Option, + /// Kernel arguments, applied at installation time + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) kargs: Option>, + /// Supported architectures for this configuration + pub(crate) match_architectures: Option>, +} + +fn merge_basic(s: &mut Option, o: Option, _env: &EnvProperties) { + if let Some(o) = o { + *s = Some(o); + } +} + +trait Mergeable { + fn merge(&mut self, other: Self, env: &EnvProperties) + where + Self: Sized; +} + +impl Mergeable for Option +where + T: Mergeable, +{ + fn merge(&mut self, other: Self, env: &EnvProperties) + where + Self: Sized, + { + if let Some(other) = other { + if let Some(s) = self.as_mut() { + s.merge(other, env) + } else { + *self = Some(other); + } + } + } +} + +impl Mergeable for RootFS { + /// Apply any values in other, overriding any existing values in `self`. + fn merge(&mut self, other: Self, env: &EnvProperties) { + merge_basic(&mut self.fstype, other.fstype, env) + } +} + +impl Mergeable for BasicFilesystems { + /// Apply any values in other, overriding any existing values in `self`. + fn merge(&mut self, other: Self, env: &EnvProperties) { + self.root.merge(other.root, env) + } +} + +impl Mergeable for InstallConfiguration { + /// Apply any values in other, overriding any existing values in `self`. + fn merge(&mut self, other: Self, env: &EnvProperties) { + // if arch is specified, only merge config if it matches the current arch + // if arch is not specified, merge config unconditionally + if other + .match_architectures + .map(|a| a.contains(&env.sys_arch)) + .unwrap_or(true) + { + merge_basic(&mut self.root_fs_type, other.root_fs_type, env); + #[cfg(feature = "install-to-disk")] + merge_basic(&mut self.block, other.block, env); + self.filesystem.merge(other.filesystem, env); + if let Some(other_kargs) = other.kargs { + self.kargs + .get_or_insert_with(Default::default) + .extend(other_kargs) + } + } + } +} + +impl InstallConfiguration { + /// Set defaults (e.g. `block`), and also handle fields that can be specified multiple ways + /// by synchronizing the values of the fields to ensure they're the same. + /// + /// - install.root-fs-type is synchronized with install.filesystems.root.type; if + /// both are set, then the latter takes precedence + pub(crate) fn canonicalize(&mut self) { + // New canonical form wins. + if let Some(rootfs_type) = self.filesystem_root().and_then(|f| f.fstype.as_ref()) { + self.root_fs_type = Some(*rootfs_type) + } else if let Some(rootfs) = self.root_fs_type.as_ref() { + let fs = self.filesystem.get_or_insert_with(Default::default); + let root = fs.root.get_or_insert_with(Default::default); + root.fstype = Some(*rootfs); + } + + #[cfg(feature = "install-to-disk")] + if self.block.is_none() { + self.block = Some(vec![BlockSetup::Direct]); + } + } + + /// Convenience helper to access the root filesystem + pub(crate) fn filesystem_root(&self) -> Option<&RootFS> { + self.filesystem.as_ref().and_then(|fs| fs.root.as_ref()) + } + + // Remove all configuration which is handled by `install to-filesystem`. + pub(crate) fn filter_to_external(&mut self) { + self.kargs.take(); + } + + #[cfg(feature = "install-to-disk")] + pub(crate) fn get_block_setup(&self, default: Option) -> Result { + let valid_block_setups = self.block.as_deref().unwrap_or_default(); + let default_block = valid_block_setups.iter().next().ok_or_else(|| { + anyhow::anyhow!("Empty block storage configuration in install configuration") + })?; + let block_setup = default.as_ref().unwrap_or(default_block); + if !valid_block_setups.contains(block_setup) { + anyhow::bail!("Block setup {block_setup:?} is not enabled in installation config"); + } + Ok(*block_setup) + } +} + +#[context("Loading configuration")] +/// Load the install configuration, merging all found configuration files. +pub(crate) fn load_config() -> Result> { + let env = EnvProperties { + sys_arch: std::env::consts::ARCH.to_string(), + }; + const SYSTEMD_CONVENTIONAL_BASES: &[&str] = &["/usr/lib", "/usr/local/lib", "/etc", "/run"]; + let fragments = liboverdrop::scan(SYSTEMD_CONVENTIONAL_BASES, "bootc/install", &["toml"], true); + let mut config: Option = None; + for (_name, path) in fragments { + let buf = std::fs::read_to_string(&path)?; + let mut unused = std::collections::HashSet::new(); + let de = toml::Deserializer::parse(&buf).with_context(|| format!("Parsing {path:?}"))?; + let mut c: InstallConfigurationToplevel = serde_ignored::deserialize(de, |path| { + unused.insert(path.to_string()); + }) + .with_context(|| format!("Parsing {path:?}"))?; + for key in unused { + eprintln!("warning: {path:?}: Unknown key {key}"); + } + if let Some(config) = config.as_mut() { + if let Some(install) = c.install { + tracing::debug!("Merging install config: {install:?}"); + config.merge(install, &env); + } + } else { + // Only set the config if it matches the current arch + // If no arch is specified, set the config unconditionally + if let Some(ref mut install) = c.install { + if install + .match_architectures + .as_ref() + .map(|a| a.contains(&env.sys_arch)) + .unwrap_or(true) + { + config = c.install; + } + } + } + } + if let Some(config) = config.as_mut() { + config.canonicalize(); + } + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + /// Verify that we can parse our default config file + fn test_parse_config() { + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + assert_eq!(install.root_fs_type.unwrap(), Filesystem::Xfs); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + root_fs_type: Some(Filesystem::Ext4), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.root_fs_type.as_ref().copied().unwrap(), + Filesystem::Ext4 + ); + // This one shouldn't have been set + assert!(install.filesystem_root().is_none()); + install.canonicalize(); + assert_eq!(install.root_fs_type.as_ref().unwrap(), &Filesystem::Ext4); + assert_eq!( + install.filesystem_root().unwrap().fstype.unwrap(), + Filesystem::Ext4 + ); + + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "ext4" +kargs = ["console=ttyS0", "foo=bar"] +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4); + assert_eq!( + install.kargs, + Some( + ["console=ttyS0", "foo=bar", "console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ) + } + + #[test] + fn test_parse_filesystems() { + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install.filesystem.root] +type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + assert_eq!( + install.filesystem_root().unwrap().fstype.unwrap(), + Filesystem::Xfs + ); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + filesystem: Some(BasicFilesystems { + root: Some(RootFS { + fstype: Some(Filesystem::Ext4), + }), + }), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.filesystem_root().unwrap().fstype.unwrap(), + Filesystem::Ext4 + ); + } + + #[test] + fn test_parse_block() { + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install.filesystem.root] +type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + // Verify the default (but note canonicalization mutates) + { + let mut install = install.clone(); + install.canonicalize(); + assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Direct); + } + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + block: Some(vec![]), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + // Should be set, but zero length + assert_eq!(install.block.as_ref().unwrap().len(), 0); + assert!(install.get_block_setup(None).is_err()); + + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +block = ["tpm2-luks"]"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + install.canonicalize(); + assert_eq!(install.block.as_ref().unwrap().len(), 1); + assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Tpm2Luks); + + // And verify passing a disallowed config is an error + assert!(install.get_block_setup(Some(BlockSetup::Direct)).is_err()); + } + + #[test] + /// Verify that kargs are only applied to supported architectures + fn test_arch() { + // no arch specified, kargs ensure that kargs are applied unconditionally + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.kargs, + Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ); + let env = EnvProperties { + sys_arch: "aarch64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.kargs, + Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ); + + // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch + let env = EnvProperties { + sys_arch: "aarch64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=ttyS0", "foo=bar"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + match_architectures: Some(["x86_64"].into_iter().map(ToOwned::to_owned).collect()), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!(install.kargs, None); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + match_architectures: Some(["aarch64"].into_iter().map(ToOwned::to_owned).collect()), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.kargs, + Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ); + + // multiple arch specified, ensure that kargs are applied to both archs + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + match_architectures: Some( + ["x86_64", "aarch64"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.kargs, + Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ); + let env = EnvProperties { + sys_arch: "aarch64".to_string(), + }; + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + let mut install = c.install.unwrap(); + let other = InstallConfigurationToplevel { + install: Some(InstallConfiguration { + kargs: Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + match_architectures: Some( + ["x86_64", "aarch64"] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ), + ..Default::default() + }), + }; + install.merge(other.install.unwrap(), &env); + assert_eq!( + install.kargs, + Some( + ["console=tty0", "nosmt"] + .into_iter() + .map(ToOwned::to_owned) + .collect() + ) + ); + } +} diff --git a/crates/lib/src/install/osbuild.rs b/crates/lib/src/install/osbuild.rs new file mode 100644 index 000000000..a6cd0cf95 --- /dev/null +++ b/crates/lib/src/install/osbuild.rs @@ -0,0 +1,73 @@ +//! # Helper APIs for interacting with bootc-image-builder +//! +//! See +//! + +use std::process::Command; + +use anyhow::Result; +use bootc_utils::CommandRunExt as _; +use camino::Utf8Path; +use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt}; +use fn_error_context::context; + +/// Handle /etc/containers readonly mount. +/// +/// Ufortunately today podman requires that /etc be writable for +/// `/etc/containers/networks`. bib today creates this as a readonly mount: +/// https://github.com/osbuild/osbuild/blob/4edbe227d41c767441b9bf4390398afc6dc8f901/osbuild/buildroot.py#L243 +/// +/// Work around that by adding a transient, writable overlayfs. +fn adjust_etc_containers(tempdir: &Dir) -> Result<()> { + let etc_containers = Utf8Path::new("/etc/containers"); + // If there's no /etc/containers, nothing to do + if !etc_containers.try_exists()? { + return Ok(()); + } + if rustix::fs::access(etc_containers.as_std_path(), rustix::fs::Access::WRITE_OK).is_ok() { + return Ok(()); + } + // Create dirs for the overlayfs upper and work in the install-global tmpdir. + tempdir.create_dir_all("etc-ovl/upper")?; + tempdir.create_dir("etc-ovl/work")?; + let opts = format!("lowerdir={etc_containers},workdir=etc-ovl/work,upperdir=etc-ovl/upper"); + Command::new("mount") + .log_debug() + .args(["-t", "overlay", "overlay", "-o", opts.as_str()]) + .arg(etc_containers) + .cwd_dir(tempdir.try_clone()?) + .run_capture_stderr()?; + Ok(()) +} + +/// osbuild mounts the host's /var/lib/containers at /run/osbuild/containers; mount +/// it back to /var/lib/containers where the default container stack expects to find it. +fn propagate_run_osbuild_containers(root: &Dir) -> Result<()> { + let osbuild_run_containers = Utf8Path::new("run/osbuild/containers"); + // If we're not apparently running under osbuild, then we no-op. + if !root.try_exists(osbuild_run_containers)? { + return Ok(()); + } + // If we do seem to have a valid container store though, use that + if crate::podman::storage_exists_default(root)? { + return Ok(()); + } + let relative_storage = Utf8Path::new(crate::podman::CONTAINER_STORAGE.trim_start_matches('/')); + root.create_dir_all(relative_storage)?; + Command::new("mount") + .log_debug() + .arg("--rbind") + .args([osbuild_run_containers, relative_storage]) + .cwd_dir(root.try_clone()?) + .run_capture_stderr()?; + Ok(()) +} + +/// bootc-image-builder today does a few things that we need to +/// deal with. +#[context("bootc-image-builder adjustments")] +pub(crate) fn adjust_for_bootc_image_builder(root: &Dir, tempdir: &Dir) -> Result<()> { + adjust_etc_containers(tempdir)?; + propagate_run_osbuild_containers(root)?; + Ok(()) +} diff --git a/crates/lib/src/install/osconfig.rs b/crates/lib/src/install/osconfig.rs new file mode 100644 index 000000000..f282905d4 --- /dev/null +++ b/crates/lib/src/install/osconfig.rs @@ -0,0 +1,91 @@ +use std::borrow::Cow; +use std::io::Write; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use ostree_ext::ostree; + +const ETC_TMPFILES: &str = "etc/tmpfiles.d"; +const ROOT_SSH_TMPFILE: &str = "bootc-root-ssh.conf"; + +#[context("Injecting root authorized_keys")] +pub(crate) fn inject_root_ssh_authorized_keys( + root: &Dir, + sepolicy: Option<&ostree::SePolicy>, + contents: &str, +) -> Result<()> { + // While not documented right now, this one looks like it does not newline wrap + let b64_encoded = ostree_ext::glib::base64_encode(contents.as_bytes()); + + // Eagerly resolve the path of /root in order to avoid tmpfiles.d clashes/problems. + // If it's local state (i.e. /root -> /var/roothome) then we resolve that symlink now. + let roothome_meta = root.symlink_metadata_optional("root")?; + let root_path = if roothome_meta.as_ref().filter(|m| m.is_symlink()).is_some() { + let path = root.read_link("root")?; + Utf8PathBuf::try_from(path) + .context("Reading /root symlink") + .map(Cow::Owned)? + } else { + Cow::Borrowed(Utf8Path::new("root")) + }; + + // See the example in https://systemd.io/CREDENTIALS/ + let tmpfiles_content = + format!("f~ /{root_path}/.ssh/authorized_keys 600 root root - {b64_encoded}\n"); + + crate::lsm::ensure_dir_labeled(root, ETC_TMPFILES, None, 0o755.into(), sepolicy)?; + let tmpfiles_dir = root.open_dir(ETC_TMPFILES)?; + crate::lsm::atomic_replace_labeled( + &tmpfiles_dir, + ROOT_SSH_TMPFILE, + 0o644.into(), + sepolicy, + |w| w.write_all(tmpfiles_content.as_bytes()).map_err(Into::into), + )?; + + println!("Injected: {ETC_TMPFILES}/{ROOT_SSH_TMPFILE}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inject_root_ssh_symlinked() -> Result<()> { + let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + // The code expects this to exist, reasonably so + root.create_dir("etc")?; + // Test with a symlink + root.symlink("var/roothome", "root")?; + inject_root_ssh_authorized_keys(root, None, "ssh-ed25519 ABCDE example@demo\n").unwrap(); + + let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; + assert_eq!( + content, + "f~ /var/roothome/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n" + ); + + Ok(()) + } + + #[test] + fn test_inject_root_ssh_dir() -> Result<()> { + let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + root.create_dir("etc")?; + root.create_dir("root")?; + inject_root_ssh_authorized_keys(root, None, "ssh-ed25519 ABCDE example@demo\n").unwrap(); + + let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; + assert_eq!( + content, + "f~ /root/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n" + ); + Ok(()) + } +} diff --git a/crates/lib/src/journal.rs b/crates/lib/src/journal.rs new file mode 100644 index 000000000..bed50ec62 --- /dev/null +++ b/crates/lib/src/journal.rs @@ -0,0 +1,36 @@ +//! Thin wrapper for systemd journaling; these APIs are no-ops +//! when not running under systemd. Only use them when + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Set to true if we failed to write to the journal once +static EMITTED_JOURNAL_ERROR: AtomicBool = AtomicBool::new(false); + +/// Wrapper for structured logging which is an explicit no-op +/// when systemd is not in use (e.g. in a container). +pub(crate) fn journal_send( + priority: libsystemd::logging::Priority, + msg: &str, + vars: impl Iterator, +) where + K: AsRef, + V: AsRef, +{ + if !libsystemd::daemon::booted() { + return; + } + if let Err(e) = libsystemd::logging::journal_send(priority, msg, vars) { + if !EMITTED_JOURNAL_ERROR.swap(true, Ordering::SeqCst) { + eprintln!("failed to write to journal: {e}"); + } + } +} + +/// Wrapper for writing to systemd journal which is an explicit no-op +/// when systemd is not in use (e.g. in a container). +#[allow(dead_code)] +pub(crate) fn journal_print(priority: libsystemd::logging::Priority, msg: &str) { + let vars: HashMap<&str, &str> = HashMap::new(); + journal_send(priority, msg, vars.into_iter()) +} diff --git a/crates/lib/src/k8sapitypes.rs b/crates/lib/src/k8sapitypes.rs new file mode 100644 index 000000000..8bc8a9566 --- /dev/null +++ b/crates/lib/src/k8sapitypes.rs @@ -0,0 +1,29 @@ +//! Subset of API definitions for selected Kubernetes API types. +//! We avoid dragging in all of k8s-openapi because it's *huge*. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + pub api_version: String, + pub kind: String, + #[serde(default)] + pub metadata: ObjectMeta, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ObjectMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs new file mode 100644 index 000000000..905f167ad --- /dev/null +++ b/crates/lib/src/lib.rs @@ -0,0 +1,45 @@ +//! # Bootable container tool +//! +//! This crate builds on top of ostree's container functionality +//! to provide a fully "container native" tool for using +//! bootable container images. + +mod bootc_composefs; +pub(crate) mod bootc_kargs; +mod bootloader; +mod boundimage; +mod cfsctl; +pub mod cli; +mod composefs_consts; +mod containerenv; +pub(crate) mod deploy; +mod discoverable_partition_specification; +pub(crate) mod fsck; +pub(crate) mod generator; +mod glyph; +mod image; +mod install; +pub(crate) mod journal; +mod k8sapitypes; +mod lints; +mod lsm; +pub(crate) mod metadata; +mod parsers; +mod podman; +mod podstorage; +mod progress_jsonl; +mod reboot; +pub mod spec; +mod status; +mod store; +mod task; +mod utils; + +#[cfg(feature = "docgen")] +mod cli_json; + +#[cfg(feature = "rhsm")] +mod rhsm; + +// Re-export blockdev crate for internal use +pub(crate) use bootc_blockdev as blockdev; diff --git a/crates/lib/src/lints.rs b/crates/lib/src/lints.rs new file mode 100644 index 000000000..96446fff2 --- /dev/null +++ b/crates/lib/src/lints.rs @@ -0,0 +1,1275 @@ +//! # Implementation of container build lints +//! +//! This module implements `bootc container lint`. + +// Unfortunately needed here to work with linkme +#![allow(unsafe_code)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::env::consts::ARCH; +use std::fmt::{Display, Write as WriteFmt}; +use std::num::NonZeroUsize; +use std::ops::ControlFlow; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use anyhow::Result; +use bootc_utils::PathQuotedDisplay; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::MetadataExt; +use cap_std_ext::dirext::WalkConfiguration; +use cap_std_ext::dirext::{CapStdExtDirExt as _, WalkComponent}; +use fn_error_context::context; +use indoc::indoc; +use linkme::distributed_slice; +use ostree_ext::ostree_prepareroot; +use serde::Serialize; + +use crate::bootc_composefs::boot::EFI_LINUX; + +/// Reference to embedded default baseimage content that should exist. +const BASEIMAGE_REF: &str = "usr/share/doc/bootc/baseimage/base"; +// https://systemd.io/API_FILE_SYSTEMS/ with /var added for us +const API_DIRS: &[&str] = &["dev", "proc", "sys", "run", "tmp", "var"]; + +/// Only output this many items by default +const DEFAULT_TRUNCATED_OUTPUT: NonZeroUsize = const { NonZeroUsize::new(5).unwrap() }; + +/// A lint check has failed. +#[derive(thiserror::Error, Debug)] +struct LintError(String); + +/// The outer error is for unexpected fatal runtime problems; the +/// inner error is for the lint failing in an expected way. +type LintResult = Result>; + +/// Everything is OK - we didn't encounter a runtime error, and +/// the targeted check passed. +fn lint_ok() -> LintResult { + Ok(Ok(())) +} + +/// We successfully found a lint failure. +fn lint_err(msg: impl AsRef) -> LintResult { + Ok(Err(LintError::new(msg))) +} + +impl std::fmt::Display for LintError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl LintError { + fn new(msg: impl AsRef) -> Self { + Self(msg.as_ref().to_owned()) + } +} + +#[derive(Debug, Default)] +struct LintExecutionConfig { + no_truncate: bool, +} + +type LintFn = fn(&Dir, config: &LintExecutionConfig) -> LintResult; +type LintRecursiveResult = LintResult; +type LintRecursiveFn = fn(&WalkComponent, config: &LintExecutionConfig) -> LintRecursiveResult; +/// A lint can either operate as it pleases on a target root, or it +/// can be recursive. +#[derive(Debug)] +enum LintFnTy { + /// A lint that doesn't traverse the whole filesystem + Regular(LintFn), + /// A recursive lint + Recursive(LintRecursiveFn), +} +#[distributed_slice] +pub(crate) static LINTS: [Lint]; + +/// The classification of a lint type. +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum LintType { + /// If this fails, it is known to be fatal - the system will not install or + /// is effectively guaranteed to fail at runtime. + Fatal, + /// This is not a fatal problem, but something you likely want to fix. + Warning, +} + +#[derive(Debug, Copy, Clone)] +pub(crate) enum WarningDisposition { + AllowWarnings, + FatalWarnings, +} + +#[derive(Debug, Copy, Clone, Serialize, PartialEq, Eq)] +pub(crate) enum RootType { + Running, + Alternative, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +struct Lint { + name: &'static str, + #[serde(rename = "type")] + ty: LintType, + #[serde(skip)] + f: LintFnTy, + description: &'static str, + // Set if this only applies to a specific root type. + #[serde(skip_serializing_if = "Option::is_none")] + root_type: Option, +} + +// We require lint names to be unique, so we can just compare based on those. +impl PartialEq for Lint { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} +impl Eq for Lint {} + +impl std::hash::Hash for Lint { + fn hash(&self, state: &mut H) { + self.name.hash(state); + } +} + +impl PartialOrd for Lint { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Lint { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(other.name) + } +} + +impl Lint { + pub(crate) const fn new_fatal( + name: &'static str, + description: &'static str, + f: LintFn, + ) -> Self { + Lint { + name, + ty: LintType::Fatal, + f: LintFnTy::Regular(f), + description, + root_type: None, + } + } + + pub(crate) const fn new_warning( + name: &'static str, + description: &'static str, + f: LintFn, + ) -> Self { + Lint { + name, + ty: LintType::Warning, + f: LintFnTy::Regular(f), + description, + root_type: None, + } + } + + const fn set_root_type(mut self, v: RootType) -> Self { + self.root_type = Some(v); + self + } +} + +pub(crate) fn lint_list(output: impl std::io::Write) -> Result<()> { + // Dump in yaml format by default, it's readable enough + serde_yaml::to_writer(output, &*LINTS)?; + Ok(()) +} + +#[derive(Debug)] +struct LintExecutionResult { + warnings: usize, + passed: usize, + skipped: usize, + fatal: usize, +} + +// Helper function to format items with optional truncation +fn format_items( + config: &LintExecutionConfig, + header: &str, + items: impl Iterator, + o: &mut String, +) -> Result<()> +where + T: Display, +{ + let mut items = items.into_iter(); + if config.no_truncate { + let Some(first) = items.next() else { + return Ok(()); + }; + writeln!(o, "{header}:")?; + writeln!(o, " {first}")?; + for item in items { + writeln!(o, " {item}")?; + } + return Ok(()); + } else { + let Some((samples, rest)) = bootc_utils::collect_until(items, DEFAULT_TRUNCATED_OUTPUT) + else { + return Ok(()); + }; + writeln!(o, "{header}:")?; + for item in samples { + writeln!(o, " {item}")?; + } + if rest > 0 { + writeln!(o, " ...and {rest} more")?; + } + } + Ok(()) +} + +// Helper to build a lint error message from multiple sections. +// The closure `build_message_fn` is responsible for calling `format_items` +// to populate the message buffer. +fn format_lint_err_from_items( + config: &LintExecutionConfig, + header: &str, + items: impl Iterator, +) -> LintResult +where + T: Display, +{ + let mut msg = String::new(); + // SAFETY: Writing to a string can't fail + format_items(config, header, items, &mut msg).unwrap(); + lint_err(msg) +} + +fn lint_inner<'skip>( + root: &Dir, + root_type: RootType, + config: &LintExecutionConfig, + skip: impl IntoIterator, + mut output: impl std::io::Write, +) -> Result { + let mut fatal = 0usize; + let mut warnings = 0usize; + let mut passed = 0usize; + let skip: std::collections::HashSet<_> = skip.into_iter().collect(); + let (mut applicable_lints, skipped_lints): (Vec<_>, Vec<_>) = LINTS.iter().partition(|lint| { + if skip.contains(lint.name) { + return false; + } + if let Some(lint_root_type) = lint.root_type { + if lint_root_type != root_type { + return false; + } + } + true + }); + // SAFETY: Length must be smaller. + let skipped = skipped_lints.len(); + // Default to predictablility here + applicable_lints.sort_by(|a, b| a.name.cmp(b.name)); + // Split the lints by type + let (nonrec_lints, recursive_lints): (Vec<_>, Vec<_>) = applicable_lints + .into_iter() + .partition(|lint| matches!(lint.f, LintFnTy::Regular(_))); + let mut results = Vec::new(); + for lint in nonrec_lints { + let f = match lint.f { + LintFnTy::Regular(f) => f, + LintFnTy::Recursive(_) => unreachable!(), + }; + results.push((lint, f(&root, &config))); + } + + let mut recursive_lints = BTreeSet::from_iter(recursive_lints); + let mut recursive_errors = BTreeMap::new(); + root.walk( + &WalkConfiguration::default() + .noxdev() + .path_base(Path::new("/")), + |e| -> std::io::Result<_> { + // If there's no recursive lints, we're done! + if recursive_lints.is_empty() { + return Ok(ControlFlow::Break(())); + } + // Keep track of any errors we caught while iterating over + // the recursive lints. + let mut this_iteration_errors = Vec::new(); + // Call each recursive lint on this directory entry. + for &lint in recursive_lints.iter() { + let f = match &lint.f { + // SAFETY: We know this set only holds recursive lints + LintFnTy::Regular(_) => unreachable!(), + LintFnTy::Recursive(f) => f, + }; + // Keep track of the error if we found one + match f(e, &config) { + Ok(Ok(())) => {} + o => this_iteration_errors.push((lint, o)), + } + } + // For each recursive lint that errored, remove it from + // the set that we will continue running. + for (lint, err) in this_iteration_errors { + recursive_lints.remove(lint); + recursive_errors.insert(lint, err); + } + Ok(ControlFlow::Continue(())) + }, + )?; + // Extend our overall result set with the recursive-lint errors. + results.extend(recursive_errors); + // Any recursive lint still in this list succeeded. + results.extend(recursive_lints.into_iter().map(|lint| (lint, lint_ok()))); + for (lint, r) in results { + let name = lint.name; + let r = match r { + Ok(r) => r, + Err(e) => anyhow::bail!("Unexpected runtime error running lint {name}: {e}"), + }; + + if let Err(e) = r { + match lint.ty { + LintType::Fatal => { + writeln!(output, "Failed lint: {name}: {e}")?; + fatal += 1; + } + LintType::Warning => { + writeln!(output, "Lint warning: {name}: {e}")?; + warnings += 1; + } + } + } else { + // We'll be quiet for now + tracing::debug!("OK {name} (type={:?})", lint.ty); + passed += 1; + } + } + + Ok(LintExecutionResult { + passed, + skipped, + warnings, + fatal, + }) +} + +#[context("Linting")] +pub(crate) fn lint<'skip>( + root: &Dir, + warning_disposition: WarningDisposition, + root_type: RootType, + skip: impl IntoIterator, + mut output: impl std::io::Write, + no_truncate: bool, +) -> Result<()> { + let config = LintExecutionConfig { no_truncate }; + let r = lint_inner(root, root_type, &config, skip, &mut output)?; + writeln!(output, "Checks passed: {}", r.passed)?; + if r.skipped > 0 { + writeln!(output, "Checks skipped: {}", r.skipped)?; + } + let fatal = if matches!(warning_disposition, WarningDisposition::FatalWarnings) { + r.fatal + r.warnings + } else { + r.fatal + }; + if r.warnings > 0 { + writeln!(output, "Warnings: {}", r.warnings)?; + } + if fatal > 0 { + anyhow::bail!("Checks failed: {}", fatal) + } + Ok(()) +} + +/// check for the existence of the /var/run directory +/// if it exists we need to check that it links to /run if not error +#[distributed_slice(LINTS)] +static LINT_VAR_RUN: Lint = Lint::new_fatal( + "var-run", + "Check for /var/run being a physical directory; this is always a bug.", + check_var_run, +); +fn check_var_run(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + if let Some(meta) = root.symlink_metadata_optional("var/run")? { + if !meta.is_symlink() { + return lint_err("Not a symlink: var/run"); + } + } + lint_ok() +} + +#[distributed_slice(LINTS)] +static LINT_BUILDAH_INJECTED: Lint = Lint::new_warning( + "buildah-injected", + indoc::indoc! { " + Check for an invalid /etc/hostname or /etc/resolv.conf that may have been injected by + a container build system." }, + check_buildah_injected, +) +// This one doesn't make sense to run looking at the running root, +// because we do expect /etc/hostname to be injected as +.set_root_type(RootType::Alternative); +fn check_buildah_injected(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + const RUNTIME_INJECTED: &[&str] = &["etc/hostname", "etc/resolv.conf"]; + for ent in RUNTIME_INJECTED { + if let Some(meta) = root.symlink_metadata_optional(ent)? { + if meta.is_file() && meta.size() == 0 { + return lint_err(format!("/{ent} is an empty file; this may have been synthesized by a container runtime.")); + } + } + } + lint_ok() +} + +#[distributed_slice(LINTS)] +static LINT_ETC_USRUSETC: Lint = Lint::new_fatal( + "etc-usretc", + indoc! { r#" +Verify that only one of /etc or /usr/etc exist. You should only have /etc +in a container image. It will cause undefined behavior to have both /etc +and /usr/etc. +"# }, + check_usretc, +); +fn check_usretc(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + let etc_exists = root.symlink_metadata_optional("etc")?.is_some(); + // For compatibility/conservatism don't bomb out if there's no /etc. + if !etc_exists { + return lint_ok(); + } + // But having both /etc and /usr/etc is not something we want to support. + if root.symlink_metadata_optional("usr/etc")?.is_some() { + return lint_err( + "Found /usr/etc - this is a bootc implementation detail and not supported to use in containers" + ); + } + lint_ok() +} + +/// Validate that we can parse the /usr/lib/bootc/kargs.d files. +#[distributed_slice(LINTS)] +static LINT_KARGS: Lint = Lint::new_fatal( + "bootc-kargs", + "Verify syntax of /usr/lib/bootc/kargs.d.", + check_parse_kargs, +); +fn check_parse_kargs(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + let args = crate::bootc_kargs::get_kargs_in_root(root, ARCH)?; + tracing::debug!("found kargs: {args:?}"); + lint_ok() +} + +#[distributed_slice(LINTS)] +static LINT_KERNEL: Lint = Lint::new_fatal( + "kernel", + indoc! { r#" + Check for multiple kernels, i.e. multiple directories of the form /usr/lib/modules/$kver. + Only one kernel is supported in an image. + "# }, + check_kernel, +); +fn check_kernel(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + let result = ostree_ext::bootabletree::find_kernel_dir_fs(&root)?; + tracing::debug!("Found kernel: {:?}", result); + lint_ok() +} + +// This one can be lifted in the future, see https://github.com/bootc-dev/bootc/issues/975 +#[distributed_slice(LINTS)] +static LINT_UTF8: Lint = Lint { + name: "utf8", + description: indoc! { r#" +Check for non-UTF8 filenames. Currently, the ostree backend of bootc only supports +UTF-8 filenames. Non-UTF8 filenames will cause a fatal error. +"#}, + ty: LintType::Fatal, + root_type: None, + f: LintFnTy::Recursive(check_utf8), +}; +fn check_utf8(e: &WalkComponent, _config: &LintExecutionConfig) -> LintRecursiveResult { + let path = e.path; + let filename = e.filename; + let dirname = path.parent().unwrap_or(Path::new("/")); + if filename.to_str().is_none() { + // This escapes like "abc\xFFdéf" + return lint_err(format!( + "{}: Found non-utf8 filename {filename:?}", + PathQuotedDisplay::new(&dirname) + )); + }; + + if e.file_type.is_symlink() { + let target = e.dir.read_link_contents(filename)?; + if target.to_str().is_none() { + return lint_err(format!( + "{}: Found non-utf8 symlink target", + PathQuotedDisplay::new(&path) + )); + } + } + lint_ok() +} + +fn check_prepareroot_composefs_norecurse(dir: &Dir) -> LintResult { + let path = ostree_ext::ostree_prepareroot::CONF_PATH; + let Some(config) = ostree_prepareroot::load_config_from_root(dir)? else { + return lint_err(format!("{path} is not present to enable composefs")); + }; + if !ostree_prepareroot::overlayfs_enabled_in_config(&config)? { + return lint_err(format!("{path} does not have composefs enabled")); + } + lint_ok() +} + +#[distributed_slice(LINTS)] +static LINT_API_DIRS: Lint = Lint::new_fatal( + "api-base-directories", + indoc! { r#" +Verify that expected base API directories exist. For more information +on these, see . + +Note that in addition, bootc requires that `/var` exist as a directory. +"#}, + check_api_dirs, +); +fn check_api_dirs(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + for d in API_DIRS { + let Some(meta) = root.symlink_metadata_optional(d)? else { + return lint_err(format!("Missing API filesystem base directory: /{d}")); + }; + if !meta.is_dir() { + return lint_err(format!( + "Expected directory for API filesystem base directory: /{d}" + )); + } + } + lint_ok() +} + +#[distributed_slice(LINTS)] +static LINT_COMPOSEFS: Lint = Lint::new_warning( + "baseimage-composefs", + indoc! { r#" +Check that composefs is enabled for ostree. More in +. +"#}, + check_composefs, +); +fn check_composefs(dir: &Dir, _config: &LintExecutionConfig) -> LintResult { + if let Err(e) = check_prepareroot_composefs_norecurse(dir)? { + return Ok(Err(e)); + } + // If we have our own documentation with the expected root contents + // embedded, then check that too! Mostly just because recursion is fun. + if let Some(dir) = dir.open_dir_optional(BASEIMAGE_REF)? { + if let Err(e) = check_prepareroot_composefs_norecurse(&dir)? { + return Ok(Err(e)); + } + } + lint_ok() +} + +/// Check for a few files and directories we expect in the base image. +fn check_baseimage_root_norecurse(dir: &Dir, _config: &LintExecutionConfig) -> LintResult { + // Check /sysroot + let meta = dir.symlink_metadata_optional("sysroot")?; + match meta { + Some(meta) if !meta.is_dir() => return lint_err("Expected a directory for /sysroot"), + None => return lint_err("Missing /sysroot"), + _ => {} + } + + // Check /ostree -> sysroot/ostree + let Some(meta) = dir.symlink_metadata_optional("ostree")? else { + return lint_err("Missing ostree -> sysroot/ostree link"); + }; + if !meta.is_symlink() { + return lint_err("/ostree should be a symlink"); + } + let link = dir.read_link_contents("ostree")?; + let expected = "sysroot/ostree"; + if link.as_os_str().as_bytes() != expected.as_bytes() { + return lint_err(format!("Expected /ostree -> {expected}, not {link:?}")); + } + + lint_ok() +} + +/// Check ostree-related base image content. +#[distributed_slice(LINTS)] +static LINT_BASEIMAGE_ROOT: Lint = Lint::new_fatal( + "baseimage-root", + indoc! { r#" +Check that expected files are present in the root of the filesystem; such +as /sysroot and a composefs configuration for ostree. More in +. +"#}, + check_baseimage_root, +); +fn check_baseimage_root(dir: &Dir, config: &LintExecutionConfig) -> LintResult { + if let Err(e) = check_baseimage_root_norecurse(dir, config)? { + return Ok(Err(e)); + } + // If we have our own documentation with the expected root contents + // embedded, then check that too! Mostly just because recursion is fun. + if let Some(dir) = dir.open_dir_optional(BASEIMAGE_REF)? { + if let Err(e) = check_baseimage_root_norecurse(&dir, config)? { + return Ok(Err(e)); + } + } + lint_ok() +} + +fn collect_nonempty_regfiles( + root: &Dir, + path: &Utf8Path, + out: &mut BTreeSet, +) -> Result<()> { + for entry in root.entries_utf8()? { + let entry = entry?; + let ty = entry.file_type()?; + let path = path.join(entry.file_name()?); + if ty.is_file() { + let meta = entry.metadata()?; + if meta.size() > 0 { + out.insert(path); + } + } else if ty.is_dir() { + let d = entry.open_dir()?; + collect_nonempty_regfiles(d.as_cap_std(), &path, out)?; + } + } + Ok(()) +} + +#[distributed_slice(LINTS)] +static LINT_VARLOG: Lint = Lint::new_warning( + "var-log", + indoc! { r#" +Check for non-empty regular files in `/var/log`. It is often undesired +to ship log files in container images. Log files in general are usually +per-machine state in `/var`. Additionally, log files often include +timestamps, causing unreproducible container images, and may contain +sensitive build system information. +"#}, + check_varlog, +); +fn check_varlog(root: &Dir, config: &LintExecutionConfig) -> LintResult { + let Some(d) = root.open_dir_optional("var/log")? else { + return lint_ok(); + }; + let mut nonempty_regfiles = BTreeSet::new(); + collect_nonempty_regfiles(&d, "/var/log".into(), &mut nonempty_regfiles)?; + + if nonempty_regfiles.is_empty() { + return lint_ok(); + } + + let header = "Found non-empty logfiles"; + let items = nonempty_regfiles.iter().map(PathQuotedDisplay::new); + format_lint_err_from_items(config, header, items) +} + +#[distributed_slice(LINTS)] +static LINT_VAR_TMPFILES: Lint = Lint::new_warning( + "var-tmpfiles", + indoc! { r#" +Check for content in /var that does not have corresponding systemd tmpfiles.d entries. +This can cause a problem across upgrades because content in /var from the container +image will only be applied on the initial provisioning. + +Instead, it's recommended to have /var effectively empty in the container image, +and use systemd tmpfiles.d to generate empty directories and compatibility symbolic links +as part of each boot. +"#}, + check_var_tmpfiles, +) +.set_root_type(RootType::Running); + +fn check_var_tmpfiles(_root: &Dir, config: &LintExecutionConfig) -> LintResult { + let r = bootc_tmpfiles::find_missing_tmpfiles_current_root()?; + if r.tmpfiles.is_empty() && r.unsupported.is_empty() { + return lint_ok(); + } + let mut msg = String::new(); + let header = "Found content in /var missing systemd tmpfiles.d entries"; + format_items(config, header, r.tmpfiles.iter().map(|v| v as &_), &mut msg)?; + let header = "Found non-directory/non-symlink files in /var"; + let items = r.unsupported.iter().map(PathQuotedDisplay::new); + format_items(config, header, items, &mut msg)?; + lint_err(msg) +} + +#[distributed_slice(LINTS)] +static LINT_SYSUSERS: Lint = Lint::new_warning( + "sysusers", + indoc! { r#" +Check for users in /etc/passwd and groups in /etc/group that do not have corresponding +systemd sysusers.d entries in /usr/lib/sysusers.d. +This can cause a problem across upgrades because if /etc is not transient and is locally +modified (commonly due to local user additions), then the contents of /etc/passwd in the new container +image may not be visible. + +Using systemd-sysusers to allocate users and groups will ensure that these are allocated +on system startup alongside other users. + +More on this topic in +"# }, + check_sysusers, +); +fn check_sysusers(rootfs: &Dir, config: &LintExecutionConfig) -> LintResult { + let r = bootc_sysusers::analyze(rootfs)?; + if r.is_empty() { + return lint_ok(); + } + let mut msg = String::new(); + let header = "Found /etc/passwd entry without corresponding systemd sysusers.d"; + let items = r.missing_users.iter().map(|v| v as &dyn std::fmt::Display); + format_items(config, header, items, &mut msg)?; + let header = "Found /etc/group entry without corresponding systemd sysusers.d"; + format_items(config, header, r.missing_groups.into_iter(), &mut msg)?; + lint_err(msg) +} + +#[distributed_slice(LINTS)] +static LINT_NONEMPTY_BOOT: Lint = Lint::new_warning( + "nonempty-boot", + indoc! { r#" +The `/boot` directory should be present, but empty. The kernel +content should be in /usr/lib/modules instead in the container image. +Any content here in the container image will be masked at runtime. +"#}, + check_boot, +); +fn check_boot(root: &Dir, config: &LintExecutionConfig) -> LintResult { + let Some(d) = root.open_dir_optional("boot")? else { + return lint_err("Missing /boot directory"); + }; + + // First collect all entries to determine if the directory is empty + let entries: Result, _> = d + .entries()? + .into_iter() + .map(|v| { + let v = v?; + anyhow::Ok(v.file_name()) + }) + .collect(); + let mut entries = entries?; + { + // Work around https://github.com/containers/composefs-rs/issues/131 + let efidir = Utf8Path::new(EFI_LINUX) + .parent() + .map(|b| b.as_std_path()) + .unwrap(); + entries.remove(efidir.as_os_str()); + } + if entries.is_empty() { + return lint_ok(); + } + + let header = "Found non-empty /boot"; + let items = entries.iter().map(PathQuotedDisplay::new); + format_lint_err_from_items(config, header, items) +} + +#[cfg(test)] +mod tests { + use std::sync::LazyLock; + + use super::*; + + static ALTROOT_LINTS: LazyLock = LazyLock::new(|| { + LINTS + .iter() + .filter(|lint| lint.root_type != Some(RootType::Running)) + .count() + }); + + fn fixture() -> Result { + // Create a new temporary directory for test fixtures. + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + Ok(tempdir) + } + + fn passing_fixture() -> Result { + // Create a temporary directory fixture that is expected to pass most lints. + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + for d in API_DIRS { + root.create_dir(d)?; + } + root.create_dir_all("usr/lib/modules/5.7.2")?; + root.write("usr/lib/modules/5.7.2/vmlinuz", "vmlinuz")?; + + root.create_dir("boot")?; + root.create_dir("sysroot")?; + root.symlink_contents("sysroot/ostree", "ostree")?; + + const PREPAREROOT_PATH: &str = "usr/lib/ostree/prepare-root.conf"; + const PREPAREROOT: &str = + include_str!("../../../baseimage/base/usr/lib/ostree/prepare-root.conf"); + root.create_dir_all(Utf8Path::new(PREPAREROOT_PATH).parent().unwrap())?; + root.atomic_write(PREPAREROOT_PATH, PREPAREROOT)?; + + Ok(root) + } + + #[test] + fn test_var_run() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + // This one should pass + check_var_run(root, config).unwrap().unwrap(); + root.create_dir_all("var/run/foo")?; + assert!(check_var_run(root, config).unwrap().is_err()); + root.remove_dir_all("var/run")?; + // Now we should pass again + check_var_run(root, config).unwrap().unwrap(); + Ok(()) + } + + #[test] + fn test_api() -> Result<()> { + let root = &passing_fixture()?; + let config = &LintExecutionConfig::default(); + // This one should pass + check_api_dirs(root, config).unwrap().unwrap(); + root.remove_dir("var")?; + assert!(check_api_dirs(root, config).unwrap().is_err()); + root.write("var", "a file for var")?; + assert!(check_api_dirs(root, config).unwrap().is_err()); + Ok(()) + } + + #[test] + fn test_lint_main() -> Result<()> { + let root = &passing_fixture()?; + let config = &LintExecutionConfig::default(); + let mut out = Vec::new(); + let warnings = WarningDisposition::FatalWarnings; + let root_type = RootType::Alternative; + lint(root, warnings, root_type, [], &mut out, config.no_truncate).unwrap(); + root.create_dir_all("var/run/foo")?; + let mut out = Vec::new(); + assert!(lint(root, warnings, root_type, [], &mut out, config.no_truncate).is_err()); + Ok(()) + } + + #[test] + fn test_lint_inner() -> Result<()> { + let root = &passing_fixture()?; + let config = &LintExecutionConfig::default(); + + // Verify that all lints run + let mut out = Vec::new(); + let root_type = RootType::Alternative; + let r = lint_inner(root, root_type, config, [], &mut out).unwrap(); + let running_only_lints = LINTS.len().checked_sub(*ALTROOT_LINTS).unwrap(); + assert_eq!(r.warnings, 0); + assert_eq!(r.fatal, 0); + assert_eq!(r.skipped, running_only_lints); + assert_eq!(r.passed, *ALTROOT_LINTS); + + let r = lint_inner(root, root_type, config, ["var-log"], &mut out).unwrap(); + // Trigger a failure in var-log by creating a non-empty log file. + root.create_dir_all("var/log/dnf")?; + root.write("var/log/dnf/dnf.log", b"dummy dnf log")?; + assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap()); + assert_eq!(r.fatal, 0); + assert_eq!(r.skipped, running_only_lints + 1); + assert_eq!(r.warnings, 0); + + // But verify that not skipping it results in a warning + let mut out = Vec::new(); + let r = lint_inner(root, root_type, config, [], &mut out).unwrap(); + assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap()); + assert_eq!(r.fatal, 0); + assert_eq!(r.skipped, running_only_lints); + assert_eq!(r.warnings, 1); + Ok(()) + } + + #[test] + fn test_kernel_lint() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + // This one should pass + check_kernel(root, config).unwrap().unwrap(); + root.create_dir_all("usr/lib/modules/5.7.2")?; + root.write("usr/lib/modules/5.7.2/vmlinuz", "old vmlinuz")?; + root.create_dir_all("usr/lib/modules/6.3.1")?; + root.write("usr/lib/modules/6.3.1/vmlinuz", "new vmlinuz")?; + assert!(check_kernel(root, config).is_err()); + root.remove_dir_all("usr/lib/modules/5.7.2")?; + // Now we should pass again + check_kernel(root, config).unwrap().unwrap(); + Ok(()) + } + + #[test] + fn test_kargs() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + check_parse_kargs(root, config).unwrap().unwrap(); + root.create_dir_all("usr/lib/bootc")?; + root.write("usr/lib/bootc/kargs.d", "not a directory")?; + assert!(check_parse_kargs(root, config).is_err()); + Ok(()) + } + + #[test] + fn test_usr_etc() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + // This one should pass + check_usretc(root, config).unwrap().unwrap(); + root.create_dir_all("etc")?; + root.create_dir_all("usr/etc")?; + assert!(check_usretc(root, config).unwrap().is_err()); + root.remove_dir_all("etc")?; + // Now we should pass again + check_usretc(root, config).unwrap().unwrap(); + Ok(()) + } + + #[test] + fn test_varlog() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + check_varlog(root, config).unwrap().unwrap(); + root.create_dir_all("var/log")?; + check_varlog(root, config).unwrap().unwrap(); + root.symlink_contents("../../usr/share/doc/systemd/README.logs", "var/log/README")?; + check_varlog(root, config).unwrap().unwrap(); + + root.atomic_write("var/log/somefile.log", "log contents")?; + let Err(e) = check_varlog(root, config).unwrap() else { + unreachable!() + }; + similar_asserts::assert_eq!( + e.to_string(), + "Found non-empty logfiles:\n /var/log/somefile.log\n" + ); + root.create_dir_all("var/log/someproject")?; + root.atomic_write("var/log/someproject/audit.log", "audit log")?; + root.atomic_write("var/log/someproject/info.log", "info")?; + let Err(e) = check_varlog(root, config).unwrap() else { + unreachable!() + }; + similar_asserts::assert_eq!( + e.to_string(), + indoc! { r#" + Found non-empty logfiles: + /var/log/somefile.log + /var/log/someproject/audit.log + /var/log/someproject/info.log + "# } + ); + + Ok(()) + } + + #[test] + fn test_boot() -> Result<()> { + let root = &passing_fixture()?; + let config = &LintExecutionConfig::default(); + check_boot(&root, config).unwrap().unwrap(); + + // Verify creating EFI doesn't error + root.create_dir_all("EFI/Linux")?; + root.write("EFI/Linux/foo.efi", b"some dummy efi")?; + check_boot(&root, config).unwrap().unwrap(); + + root.create_dir("boot/somesubdir")?; + let Err(e) = check_boot(&root, config).unwrap() else { + unreachable!() + }; + assert!(e.to_string().contains("somesubdir")); + + Ok(()) + } + + fn run_recursive_lint( + root: &Dir, + f: LintRecursiveFn, + config: &LintExecutionConfig, + ) -> LintResult { + // Helper function to execute a recursive lint function over a directory. + let mut result = lint_ok(); + root.walk( + &WalkConfiguration::default() + .noxdev() + .path_base(Path::new("/")), + |e| -> Result<_> { + let r = f(e, config)?; + match r { + Ok(()) => Ok(ControlFlow::Continue(())), + Err(e) => { + result = Ok(Err(e)); + Ok(ControlFlow::Break(())) + } + } + }, + )?; + result + } + + #[test] + fn test_non_utf8() { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + + let root = &fixture().unwrap(); + let config = &LintExecutionConfig::default(); + + // Try to create some adversarial symlink situations to ensure the walk doesn't crash + root.create_dir("subdir").unwrap(); + // Self-referential symlinks + root.symlink("self", "self").unwrap(); + // Infinitely looping dir symlinks + root.symlink("..", "subdir/parent").unwrap(); + // Broken symlinks + root.symlink("does-not-exist", "broken").unwrap(); + // Out-of-scope symlinks + root.symlink("../../x", "escape").unwrap(); + // Should be fine + run_recursive_lint(root, check_utf8, config) + .unwrap() + .unwrap(); + + // But this will cause an issue + let baddir = OsStr::from_bytes(b"subdir/2/bad\xffdir"); + root.create_dir("subdir/2").unwrap(); + root.create_dir(baddir).unwrap(); + let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else { + unreachable!("Didn't fail"); + }; + assert_eq!( + err.to_string(), + r#"/subdir/2: Found non-utf8 filename "bad\xFFdir""# + ); + root.remove_dir(baddir).unwrap(); // Get rid of the problem + run_recursive_lint(root, check_utf8, config) + .unwrap() + .unwrap(); // Check it + + // Create a new problem in the form of a regular file + let badfile = OsStr::from_bytes(b"regular\xff"); + root.write(badfile, b"Hello, world!\n").unwrap(); + let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else { + unreachable!("Didn't fail"); + }; + assert_eq!( + err.to_string(), + r#"/: Found non-utf8 filename "regular\xFF""# + ); + root.remove_file(badfile).unwrap(); // Get rid of the problem + run_recursive_lint(root, check_utf8, config) + .unwrap() + .unwrap(); // Check it + + // And now test invalid symlink targets + root.symlink(badfile, "subdir/good-name").unwrap(); + let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else { + unreachable!("Didn't fail"); + }; + assert_eq!( + err.to_string(), + r#"/subdir/good-name: Found non-utf8 symlink target"# + ); + root.remove_file("subdir/good-name").unwrap(); // Get rid of the problem + run_recursive_lint(root, check_utf8, config) + .unwrap() + .unwrap(); // Check it + + // Finally, test a self-referential symlink with an invalid name. + // We should spot the invalid name before we check the target. + root.symlink(badfile, badfile).unwrap(); + let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else { + unreachable!("Didn't fail"); + }; + assert_eq!( + err.to_string(), + r#"/: Found non-utf8 filename "regular\xFF""# + ); + root.remove_file(badfile).unwrap(); // Get rid of the problem + run_recursive_lint(root, check_utf8, config) + .unwrap() + .unwrap(); // Check it + } + + #[test] + fn test_baseimage_root() -> Result<()> { + let td = fixture()?; + let config = &LintExecutionConfig::default(); + + // An empty root should fail our test + assert!(check_baseimage_root(&td, config).unwrap().is_err()); + + drop(td); + let td = passing_fixture()?; + check_baseimage_root(&td, config).unwrap().unwrap(); + Ok(()) + } + + #[test] + fn test_composefs() -> Result<()> { + let td = fixture()?; + let config = &LintExecutionConfig::default(); + + // An empty root should fail our test + assert!(check_composefs(&td, config).unwrap().is_err()); + + drop(td); + let td = passing_fixture()?; + // This should pass as the fixture includes a valid composefs config. + check_composefs(&td, config).unwrap().unwrap(); + + td.write( + "usr/lib/ostree/prepare-root.conf", + b"[composefs]\nenabled = false", + )?; + // Now it should fail because composefs is explicitly disabled. + assert!(check_composefs(&td, config).unwrap().is_err()); + + Ok(()) + } + + #[test] + fn test_buildah_injected() -> Result<()> { + let td = fixture()?; + let config = &LintExecutionConfig::default(); + td.create_dir("etc")?; + assert!(check_buildah_injected(&td, config).unwrap().is_ok()); + td.write("etc/hostname", b"")?; + assert!(check_buildah_injected(&td, config).unwrap().is_err()); + td.write("etc/hostname", b"some static hostname")?; + assert!(check_buildah_injected(&td, config).unwrap().is_ok()); + Ok(()) + } + + #[test] + fn test_list() { + let mut r = Vec::new(); + lint_list(&mut r).unwrap(); + let lints: Vec = serde_yaml::from_slice(&r).unwrap(); + assert_eq!(lints.len(), LINTS.len()); + } + + #[test] + fn test_format_items_no_truncate() -> Result<()> { + let config = LintExecutionConfig { no_truncate: true }; + let header = "Test Header"; + let mut output_str = String::new(); + + // Test case 1: Empty iterator + let items_empty: Vec = vec![]; + format_items(&config, header, items_empty.iter(), &mut output_str)?; + assert_eq!(output_str, ""); + output_str.clear(); + + // Test case 2: Iterator with one item + let items_one = ["item1"]; + format_items(&config, header, items_one.iter(), &mut output_str)?; + assert_eq!(output_str, "Test Header:\n item1\n"); + output_str.clear(); + + // Test case 3: Iterator with multiple items + let items_multiple = (1..=3).map(|v| format!("item{v}")).collect::>(); + format_items(&config, header, items_multiple.iter(), &mut output_str)?; + assert_eq!(output_str, "Test Header:\n item1\n item2\n item3\n"); + output_str.clear(); + + // Test case 4: Iterator with items > DEFAULT_TRUNCATED_OUTPUT + let items_multiple = (1..=8).map(|v| format!("item{v}")).collect::>(); + format_items(&config, header, items_multiple.iter(), &mut output_str)?; + assert_eq!(output_str, "Test Header:\n item1\n item2\n item3\n item4\n item5\n item6\n item7\n item8\n"); + output_str.clear(); + + Ok(()) + } + + #[test] + fn test_format_items_truncate() -> Result<()> { + let config = LintExecutionConfig::default(); + let header = "Test Header"; + let mut output_str = String::new(); + + // Test case 1: Empty iterator + let items_empty: Vec = vec![]; + format_items(&config, header, items_empty.iter(), &mut output_str)?; + assert_eq!(output_str, ""); + output_str.clear(); + + // Test case 2: Iterator with fewer items than DEFAULT_TRUNCATED_OUTPUT + let items_few = ["item1", "item2"]; + format_items(&config, header, items_few.iter(), &mut output_str)?; + assert_eq!(output_str, "Test Header:\n item1\n item2\n"); + output_str.clear(); + + // Test case 3: Iterator with exactly DEFAULT_TRUNCATED_OUTPUT items + let items_exact: Vec<_> = (0..DEFAULT_TRUNCATED_OUTPUT.get()) + .map(|i| format!("item{}", i + 1)) + .collect(); + format_items(&config, header, items_exact.iter(), &mut output_str)?; + let mut expected_output = String::from("Test Header:\n"); + for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() { + writeln!(expected_output, " item{}", i + 1)?; + } + assert_eq!(output_str, expected_output); + output_str.clear(); + + // Test case 4: Iterator with more items than DEFAULT_TRUNCATED_OUTPUT + let items_many: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 2)) + .map(|i| format!("item{}", i + 1)) + .collect(); + format_items(&config, header, items_many.iter(), &mut output_str)?; + let mut expected_output = String::from("Test Header:\n"); + for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() { + writeln!(expected_output, " item{}", i + 1)?; + } + writeln!(expected_output, " ...and 2 more")?; + assert_eq!(output_str, expected_output); + output_str.clear(); + + // Test case 5: Iterator with one more item than DEFAULT_TRUNCATED_OUTPUT + let items_one_more: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 1)) + .map(|i| format!("item{}", i + 1)) + .collect(); + format_items(&config, header, items_one_more.iter(), &mut output_str)?; + let mut expected_output = String::from("Test Header:\n"); + for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() { + writeln!(expected_output, " item{}", i + 1)?; + } + writeln!(expected_output, " ...and 1 more")?; + assert_eq!(output_str, expected_output); + output_str.clear(); + + Ok(()) + } + + #[test] + fn test_format_items_display_impl() -> Result<()> { + let config = LintExecutionConfig::default(); + let header = "Numbers"; + let mut output_str = String::new(); + + let items_numbers = [1, 2, 3]; + format_items(&config, header, items_numbers.iter(), &mut output_str)?; + similar_asserts::assert_eq!(output_str, "Numbers:\n 1\n 2\n 3\n"); + + Ok(()) + } +} diff --git a/crates/lib/src/lsm.rs b/crates/lib/src/lsm.rs new file mode 100644 index 000000000..817857ad4 --- /dev/null +++ b/crates/lib/src/lsm.rs @@ -0,0 +1,549 @@ +use std::borrow::Cow; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std::fs::{DirBuilder, OpenOptions}; +use cap_std::io_lifetimes::AsFilelike; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::{Metadata, MetadataExt}; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::gio; +use ostree_ext::ostree; +use rustix::fd::AsFd; + +/// The mount path for selinux +const SELINUXFS: &str = "/sys/fs/selinux"; +/// The SELinux xattr +const SELINUX_XATTR: &[u8] = b"security.selinux\0"; +const SELF_CURRENT: &str = "/proc/self/attr/current"; + +#[context("Querying selinux availability")] +pub(crate) fn selinux_enabled() -> Result { + Path::new("/proc/1/root/sys/fs/selinux/enforce") + .try_exists() + .map_err(Into::into) +} + +/// Get the current process SELinux security context +fn get_current_security_context() -> Result { + std::fs::read_to_string(SELF_CURRENT).with_context(|| format!("Reading {SELF_CURRENT}")) +} + +/// Check if the current process has the capability to write SELinux security +/// contexts unknown to the current policy. In SELinux terms this capability is +/// gated under `mac_admin` (admin control over SELinux state), and in the Fedora +/// policy at least it's part of `install_t`. +#[context("Testing install_t")] +fn test_install_t() -> Result { + let tmpf = tempfile::NamedTempFile::new()?; + // Our implementation here writes a label which is always unknown to the current policy + // to verify that we have the capability to do so. + let st = Command::new("chcon") + .args(["-t", "invalid_bootcinstall_testlabel_t"]) + .arg(tmpf.path()) + .stderr(std::process::Stdio::null()) + .status()?; + Ok(st.success()) +} + +/// Ensure that the current process has the capability to write SELinux security +/// contexts unknown to the current policy. +/// +/// See [`test_install_t`] above for how we check for that capability. +/// +/// In the general case of both upgrade or install, we may e.g. jump major versions +/// or even operating systems, and we need the ability to write arbitrary labels. +/// If the current process doesn't already have `mac_admin/install_t` then we +/// make a new temporary copy of our binary, and give it the same label as /usr/bin/ostree, +/// which in Fedora derivatives at least was already historically labeled with +/// the correct install_t label. +/// +/// However, if you maintain a bootc operating system with SELinux, you should from +/// the start ensure that /usr/bin/bootc has the correct capabilities. +#[context("Ensuring selinux install_t type")] +pub(crate) fn selinux_ensure_install() -> Result { + let guardenv = "_bootc_selinuxfs_mounted"; + let current = get_current_security_context()?; + tracing::debug!("Current security context is {current}"); + if let Some(p) = std::env::var_os(guardenv) { + let p = Path::new(&p); + if p.exists() { + tracing::debug!("Removing temporary file"); + std::fs::remove_file(p).context("Removing {p:?}")?; + } else { + tracing::debug!("Assuming we now have a privileged (e.g. install_t) label"); + } + return test_install_t(); + } + if test_install_t()? { + tracing::debug!("We have install_t"); + return Ok(true); + } + tracing::debug!("Lacking install_t capabilities; copying self to temporary file for re-exec"); + // OK now, we always copy our binary to a tempfile, set its security context + // to match that of /usr/bin/ostree, and then re-exec. This is really a gross + // hack; we can't always rely on https://github.com/fedora-selinux/selinux-policy/pull/1500/commits/67eb283c46d35a722636d749e5b339615fe5e7f5 + let mut tmpf = tempfile::NamedTempFile::new()?; + let srcpath = std::env::current_exe()?; + let mut src = std::fs::File::open(&srcpath)?; + let meta = src.metadata()?; + std::io::copy(&mut src, &mut tmpf).context("Copying self to tempfile for selinux re-exec")?; + tmpf.as_file_mut() + .set_permissions(meta.permissions()) + .context("Setting permissions of tempfile")?; + let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let policy = ostree::SePolicy::new_at(container_root.as_raw_fd(), gio::Cancellable::NONE)?; + let label = require_label(&policy, "/usr/bin/ostree".into(), libc::S_IFREG | 0o755)?; + set_security_selinux(tmpf.as_fd(), label.as_bytes())?; + let tmpf: Utf8PathBuf = tmpf.keep()?.1.try_into().unwrap(); + tracing::debug!("Created {tmpf:?}"); + + let mut cmd = Command::new(&tmpf); + cmd.env(guardenv, tmpf); + cmd.env(bootc_utils::reexec::ORIG, srcpath); + cmd.args(std::env::args_os().skip(1)); + cmd.arg0(bootc_utils::NAME); + cmd.log_debug(); + Err(anyhow::Error::msg(cmd.exec()).context("execve")) +} + +/// Query whether SELinux is apparently enabled in the target root +pub(crate) fn have_selinux_policy(root: &Dir) -> Result { + // TODO use ostree::SePolicy and query policy name + root.try_exists("etc/selinux/config").map_err(Into::into) +} + +/// A type which will reset SELinux back to enforcing mode when dropped. +/// This is a workaround for the deep difficulties in trying to reliably +/// gain the `mac_admin` permission (install_t). +#[must_use] +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) struct SetEnforceGuard(Option<()>); + +impl SetEnforceGuard { + pub(crate) fn new() -> Self { + SetEnforceGuard(Some(())) + } + + #[allow(dead_code)] + pub(crate) fn consume(mut self) -> Result<()> { + // SAFETY: The option cannot have been consumed until now + self.0.take().unwrap(); + // This returns errors + selinux_set_permissive(false) + } +} + +impl Drop for SetEnforceGuard { + fn drop(&mut self) { + // A best-effort attempt to re-enable enforcement on drop (installation failure) + if let Some(()) = self.0.take() { + let _ = selinux_set_permissive(false); + } + } +} + +/// Try to enter the install_t domain, but if we can't do that, then +/// just setenforce 0. +#[context("Ensuring selinux install_t type")] +pub(crate) fn selinux_ensure_install_or_setenforce() -> Result> { + // If the process already has install_t, exit early + // Note that this may re-exec the entire process + if selinux_ensure_install()? { + return Ok(None); + } + let g = if std::env::var_os("BOOTC_SETENFORCE0_FALLBACK").is_some() { + tracing::warn!("Failed to enter install_t; temporarily setting permissive mode"); + selinux_set_permissive(true)?; + Some(SetEnforceGuard::new()) + } else { + let current = get_current_security_context()?; + anyhow::bail!("Failed to enter install_t (running as {current}) - use BOOTC_SETENFORCE0_FALLBACK=1 to override"); + }; + Ok(g) +} + +/// A thin wrapper for loading a SELinux policy that maps "policy nonexistent" to None. +pub(crate) fn new_sepolicy_at(fd: impl AsFd) -> Result> { + let fd = fd.as_fd(); + let cancellable = gio::Cancellable::NONE; + let sepolicy = ostree::SePolicy::new_at(fd.as_raw_fd(), cancellable)?; + let r = if sepolicy.csum().is_none() { + None + } else { + Some(sepolicy) + }; + Ok(r) +} + +#[context("Setting SELinux permissive mode")] +#[allow(dead_code)] +pub(crate) fn selinux_set_permissive(permissive: bool) -> Result<()> { + let enforce_path = &Utf8Path::new(SELINUXFS).join("enforce"); + if !enforce_path.exists() { + return Ok(()); + } + let mut f = std::fs::File::options().write(true).open(enforce_path)?; + f.write_all(if permissive { b"0" } else { b"1" })?; + tracing::debug!( + "Set SELinux mode: {}", + if permissive { + "permissive" + } else { + "enforcing" + } + ); + Ok(()) +} + +/// Check if the ostree-formatted extended attributes include a security.selinux value. +pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool { + let n = xattrs.n_children(); + for i in 0..n { + let child = xattrs.child_value(i); + let key = child.child_value(0); + let key = key.data_as_bytes(); + if key == SELINUX_XATTR { + return true; + } + } + false +} + +/// Look up the label for a path in a policy, and error if one is not found. +pub(crate) fn require_label( + policy: &ostree::SePolicy, + destname: &Utf8Path, + mode: u32, +) -> Result { + policy + .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)? + .ok_or_else(|| { + anyhow::anyhow!( + "No label found in policy '{:?}' for {destname})", + policy.csum() + ) + }) +} + +/// A thin wrapper for invoking fsetxattr(security.selinux) +pub(crate) fn set_security_selinux(fd: std::os::fd::BorrowedFd, label: &[u8]) -> Result<()> { + rustix::fs::fsetxattr( + fd, + "security.selinux", + label, + rustix::fs::XattrFlags::empty(), + ) + .context("fsetxattr(security.selinux)") +} + +/// The labeling state; "unsupported" is distinct as we need to handle +/// cases like the ESP which don't support labeling. +pub(crate) enum SELinuxLabelState { + Unlabeled, + Unsupported, + Labeled, +} + +/// Query the SELinux labeling for a particular path +pub(crate) fn has_security_selinux(root: &Dir, path: &Utf8Path) -> Result { + // TODO: avoid hardcoding a max size here + let mut buf = [0u8; 2048]; + let fdpath = format!("/proc/self/fd/{}/{path}", root.as_raw_fd()); + match rustix::fs::lgetxattr(fdpath, "security.selinux", &mut buf) { + Ok(_) => Ok(SELinuxLabelState::Labeled), + Err(rustix::io::Errno::OPNOTSUPP) => Ok(SELinuxLabelState::Unsupported), + Err(rustix::io::Errno::NODATA) => Ok(SELinuxLabelState::Unlabeled), + Err(e) => Err(e).with_context(|| format!("Failed to look up context for {path:?}")), + } +} + +/// Directly set the `security.selinux` extended attribute on the target +/// path. Symbolic links are not followed for the target. +/// +/// Note that this API will work even if SELinux is disabled. +pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8]) -> Result<()> { + let fdpath = format!("/proc/self/fd/{}/", root.as_raw_fd()); + let fdpath = &Path::new(&fdpath).join(path); + rustix::fs::lsetxattr( + fdpath, + "security.selinux", + label, + rustix::fs::XattrFlags::empty(), + )?; + Ok(()) +} + +/// Given a policy, ensure the target file path has a security.selinux label. +/// If the path already is labeled, this function is a no-op, even if +/// the policy would default to a different label. +pub(crate) fn ensure_labeled( + root: &Dir, + path: &Utf8Path, + metadata: &Metadata, + policy: &ostree::SePolicy, +) -> Result { + let r = has_security_selinux(root, path)?; + if matches!(r, SELinuxLabelState::Unlabeled) { + relabel(root, metadata, path, None, policy)?; + } + Ok(r) +} + +/// Given the policy, relabel the target file or directory. +/// Optionally, an override for the path can be provided +/// to set the label as if the target has that filename. +pub(crate) fn relabel( + root: &Dir, + metadata: &Metadata, + path: &Utf8Path, + as_path: Option<&Utf8Path>, + policy: &ostree::SePolicy, +) -> Result<()> { + assert!(!path.starts_with("/")); + let as_path = as_path + .map(Cow::Borrowed) + .unwrap_or_else(|| Utf8Path::new("/").join(path).into()); + let label = require_label(policy, &as_path, metadata.mode())?; + tracing::trace!("Setting label for {path} to {label}"); + set_security_selinux_path(root, &path, label.as_bytes()) +} + +pub(crate) fn relabel_recurse_inner( + root: &Dir, + path: &mut Utf8PathBuf, + mut as_path: Option<&mut Utf8PathBuf>, + policy: &ostree::SePolicy, +) -> Result<()> { + // Relabel this directory + let self_meta = root.dir_metadata()?; + relabel( + root, + &self_meta, + path, + as_path.as_ref().map(|p| p.as_path()), + policy, + )?; + + // Relabel all children + for ent in root.read_dir(&path)? { + let ent = ent?; + let metadata = ent.metadata()?; + let name = ent.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?; + // Extend both copies of the path + path.push(name); + if let Some(p) = as_path.as_mut() { + p.push(name); + } + + if metadata.is_dir() { + let as_path = as_path.as_deref_mut(); + relabel_recurse_inner(root, path, as_path, policy)?; + } else { + let as_path = as_path.as_ref().map(|p| p.as_path()); + relabel(root, &metadata, &path, as_path, policy)? + } + // Trim what we added to the path + let r = path.pop(); + assert!(r); + if let Some(p) = as_path.as_mut() { + let r = p.pop(); + assert!(r); + } + } + + Ok(()) +} + +/// Recursively relabel the target directory. +pub(crate) fn relabel_recurse( + root: &Dir, + path: impl AsRef, + as_path: Option<&Utf8Path>, + policy: &ostree::SePolicy, +) -> Result<()> { + let mut path = path.as_ref().to_owned(); + // This path must be relative, as we access via cap-std + assert!(!path.starts_with("/")); + let mut as_path = as_path.map(|v| v.to_owned()); + // But the as_path must be absolute, if provided + if let Some(as_path) = as_path.as_deref() { + assert!(as_path.starts_with("/")); + } + relabel_recurse_inner(root, &mut path, as_path.as_mut(), policy) +} + +/// A wrapper for creating a directory, also optionally setting a SELinux label. +/// The provided `skip` parameter is a device/inode that we will ignore (and not traverse). +pub(crate) fn ensure_dir_labeled_recurse( + root: &Dir, + path: &mut Utf8PathBuf, + policy: &ostree::SePolicy, + skip: Option<(libc::dev_t, libc::ino64_t)>, +) -> Result<()> { + // Juggle the cap-std requirement for relative paths vs the libselinux + // requirement for absolute paths by special casing the empty string "" as "." + // just for the initial directory enumeration. + let path_for_read = if path.as_str().is_empty() { + Utf8Path::new(".") + } else { + &*path + }; + + let mut n = 0u64; + + let metadata = root.symlink_metadata(path_for_read)?; + match ensure_labeled(root, path, &metadata, policy)? { + SELinuxLabelState::Unlabeled => { + n += 1; + } + SELinuxLabelState::Unsupported => return Ok(()), + SELinuxLabelState::Labeled => {} + } + + for ent in root.read_dir(path_for_read)? { + let ent = ent?; + let metadata = ent.metadata()?; + if let Some((skip_dev, skip_ino)) = skip.as_ref().copied() { + if (metadata.dev(), metadata.ino()) == (skip_dev, skip_ino) { + tracing::debug!("Skipping dev={skip_dev} inode={skip_ino}"); + continue; + } + } + let name = ent.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?; + path.push(name); + + if metadata.is_dir() { + ensure_dir_labeled_recurse(root, path, policy, skip)?; + } else { + match ensure_labeled(root, path, &metadata, policy)? { + SELinuxLabelState::Unlabeled => { + n += 1; + } + SELinuxLabelState::Unsupported => break, + SELinuxLabelState::Labeled => {} + } + } + path.pop(); + } + + if n > 0 { + tracing::debug!("Relabeled {n} objects in {path}"); + } + Ok(()) +} + +/// A wrapper for creating a directory, also optionally setting a SELinux label. +pub(crate) fn ensure_dir_labeled( + root: &Dir, + destname: impl AsRef, + as_path: Option<&Utf8Path>, + mode: rustix::fs::Mode, + policy: Option<&ostree::SePolicy>, +) -> Result<()> { + use std::borrow::Cow; + + let destname = destname.as_ref(); + // Special case the empty string + let local_destname = if destname.as_str().is_empty() { + ".".into() + } else { + destname + }; + tracing::debug!("Labeling {local_destname}"); + let label = policy + .map(|policy| { + let as_path = as_path + .map(Cow::Borrowed) + .unwrap_or_else(|| Utf8Path::new("/").join(destname).into()); + require_label(policy, &as_path, libc::S_IFDIR | mode.as_raw_mode()) + }) + .transpose() + .with_context(|| format!("Labeling {local_destname}"))?; + tracing::trace!("Label for {local_destname} is {label:?}"); + + root.ensure_dir_with(local_destname, &DirBuilder::new()) + .with_context(|| format!("Opening {local_destname}"))?; + let dirfd = cap_std_ext::cap_primitives::fs::open( + &root.as_filelike_view(), + local_destname.as_std_path(), + OpenOptions::new().read(true), + ) + .context("opendir")?; + let dirfd = dirfd.as_fd(); + rustix::fs::fchmod(dirfd, mode).context("fchmod")?; + if let Some(label) = label { + set_security_selinux(dirfd, label.as_bytes())?; + } + + Ok(()) +} + +/// A wrapper for atomically writing a file, also optionally setting a SELinux label. +pub(crate) fn atomic_replace_labeled( + root: &Dir, + destname: impl AsRef, + mode: rustix::fs::Mode, + policy: Option<&ostree::SePolicy>, + f: F, +) -> Result<()> +where + F: FnOnce(&mut std::io::BufWriter) -> Result<()>, +{ + let destname = destname.as_ref(); + let label = policy + .map(|policy| { + let abs_destname = Utf8Path::new("/").join(destname); + require_label(policy, &abs_destname, libc::S_IFREG | mode.as_raw_mode()) + }) + .transpose()?; + + root.atomic_replace_with(destname, |w| { + // Peel through the bufwriter to get the fd + let fd = w.get_mut(); + let fd = fd.as_file_mut(); + let fd = fd.as_fd(); + // Apply the target mode bits + rustix::fs::fchmod(fd, mode).context("fchmod")?; + // If we have a label, apply it + if let Some(label) = label { + tracing::debug!("Setting label for {destname} to {label}"); + set_security_selinux(fd, label.as_bytes())?; + } else { + tracing::debug!("No label for {destname}"); + } + // Finally call the underlying writer function + f(w) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gio::glib::Variant; + + #[test] + fn test_selinux_xattr() { + let notfound: &[&[(&[u8], &[u8])]] = &[&[], &[(b"foo", b"bar")]]; + for case in notfound { + assert!(!xattrs_have_selinux(&Variant::from(case))); + } + let found: &[(&[u8], &[u8])] = &[(b"foo", b"bar"), (SELINUX_XATTR, b"foo_t")]; + assert!(xattrs_have_selinux(&Variant::from(found))); + } +} diff --git a/crates/lib/src/metadata.rs b/crates/lib/src/metadata.rs new file mode 100644 index 000000000..4db5758bf --- /dev/null +++ b/crates/lib/src/metadata.rs @@ -0,0 +1,4 @@ +/// This label is expected to be present on compatible base images. +pub(crate) const BOOTC_COMPAT_LABEL: &str = "containers.bootc"; +/// The current single well-known value for the label. +pub(crate) const COMPAT_LABEL_V1: &str = "1"; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs new file mode 100644 index 000000000..606b990c7 --- /dev/null +++ b/crates/lib/src/parsers/bls_config.rs @@ -0,0 +1,559 @@ +//! See +//! +//! This module parses the config files for the spec. + +#![allow(dead_code)] + +use anyhow::{anyhow, Result}; +use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned}; +use camino::Utf8PathBuf; +use composefs_boot::bootloader::EFI_EXT; +use core::fmt; +use std::collections::HashMap; +use std::fmt::Display; +use uapi_version::Version; + +use crate::composefs_consts::COMPOSEFS_CMDLINE; + +#[derive(Debug, PartialEq, Eq, Default)] +pub enum BLSConfigType { + EFI { + /// The path to the EFI binary, usually a UKI + efi: Utf8PathBuf, + }, + NonEFI { + /// The path to the linux kernel to boot. + linux: Utf8PathBuf, + /// The paths to the initrd images. + initrd: Vec, + /// Kernel command line options. + options: Option, + }, + #[default] + Unknown, +} + +/// Represents a single Boot Loader Specification config file. +/// +/// The boot loader should present the available boot menu entries to the user in a sorted list. +/// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. +/// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. +#[derive(Debug, Eq, PartialEq, Default)] +#[non_exhaustive] +pub(crate) struct BLSConfig { + /// The title of the boot entry, to be displayed in the boot menu. + pub(crate) title: Option, + /// The version of the boot entry. + /// See + /// + /// This is hidden and must be accessed via [`Self::version()`]; + version: String, + + pub(crate) cfg_type: BLSConfigType, + + /// The machine ID of the OS. + pub(crate) machine_id: Option, + /// The sort key for the boot menu. + pub(crate) sort_key: Option, + + /// Any extra fields not defined in the spec. + pub(crate) extra: HashMap, +} + +impl PartialOrd for BLSConfig { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for BLSConfig { + /// This implements the sorting logic from the Boot Loader Specification. + /// + /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. + /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // If both configs have a sort key, compare them. + if let (Some(key1), Some(key2)) = (&self.sort_key, &other.sort_key) { + let ord = key1.cmp(key2); + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + + // If both configs have a machine ID, compare them. + if let (Some(id1), Some(id2)) = (&self.machine_id, &other.machine_id) { + let ord = id1.cmp(id2); + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + + // Finally, sort by version in descending order. + self.version().cmp(&other.version()).reverse() + } +} + +impl Display for BLSConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(title) = &self.title { + writeln!(f, "title {}", title)?; + } + + writeln!(f, "version {}", self.version)?; + + match &self.cfg_type { + BLSConfigType::EFI { efi } => { + writeln!(f, "efi {}", efi)?; + } + + BLSConfigType::NonEFI { + linux, + initrd, + options, + } => { + writeln!(f, "linux {}", linux)?; + for initrd in initrd.iter() { + writeln!(f, "initrd {}", initrd)?; + } + + if let Some(options) = options.as_deref() { + writeln!(f, "options {}", options)?; + } + } + + BLSConfigType::Unknown => return Err(fmt::Error), + } + + if let Some(machine_id) = self.machine_id.as_deref() { + writeln!(f, "machine-id {}", machine_id)?; + } + if let Some(sort_key) = self.sort_key.as_deref() { + writeln!(f, "sort-key {}", sort_key)?; + } + + for (key, value) in &self.extra { + writeln!(f, "{} {}", key, value)?; + } + + Ok(()) + } +} + +impl BLSConfig { + pub(crate) fn version(&self) -> Version { + Version::from(&self.version) + } + + pub(crate) fn with_title(&mut self, new_val: String) -> &mut Self { + self.title = Some(new_val); + self + } + pub(crate) fn with_version(&mut self, new_val: String) -> &mut Self { + self.version = new_val; + self + } + pub(crate) fn with_cfg(&mut self, config: BLSConfigType) -> &mut Self { + self.cfg_type = config; + self + } + #[allow(dead_code)] + pub(crate) fn with_machine_id(&mut self, new_val: String) -> &mut Self { + self.machine_id = Some(new_val); + self + } + pub(crate) fn with_sort_key(&mut self, new_val: String) -> &mut Self { + self.sort_key = Some(new_val); + self + } + #[allow(dead_code)] + pub(crate) fn with_extra(&mut self, new_val: HashMap) -> &mut Self { + self.extra = new_val; + self + } + + pub(crate) fn get_verity(&self) -> Result { + match &self.cfg_type { + BLSConfigType::EFI { efi } => Ok(efi + .components() + .last() + .ok_or(anyhow::anyhow!("Empty efi field"))? + .to_string() + .strip_suffix(EFI_EXT) + .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))? + .to_string()), + + BLSConfigType::NonEFI { options, .. } => { + let options = options.as_ref().ok_or(anyhow::anyhow!("No options"))?; + + let cmdline = Cmdline::from(&options); + + let kv = cmdline + .find(COMPOSEFS_CMDLINE) + .ok_or(anyhow::anyhow!("No composefs= param"))?; + + let value = kv + .value() + .ok_or(anyhow::anyhow!("Empty composefs= param"))?; + + let value = value.to_owned(); + + Ok(value) + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown config type"), + } + } +} + +pub(crate) fn parse_bls_config(input: &str) -> Result { + let mut title = None; + let mut version = None; + let mut linux = None; + let mut efi = None; + let mut initrd = Vec::new(); + let mut options = None; + let mut machine_id = None; + let mut sort_key = None; + let mut extra = HashMap::new(); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + let value = value.trim().to_string(); + match key { + "title" => title = Some(value), + "version" => version = Some(value), + "linux" => linux = Some(Utf8PathBuf::from(value)), + "initrd" => initrd.push(Utf8PathBuf::from(value)), + "options" => options = Some(CmdlineOwned::from(value)), + "machine-id" => machine_id = Some(value), + "sort-key" => sort_key = Some(value), + "efi" => efi = Some(Utf8PathBuf::from(value)), + _ => { + extra.insert(key.to_string(), value); + } + } + } + } + + let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?; + + let cfg_type = match (linux, efi) { + (None, Some(efi)) => BLSConfigType::EFI { efi }, + + (Some(linux), None) => BLSConfigType::NonEFI { + linux, + initrd, + options, + }, + + // The spec makes no mention of whether both can be present or not + // Fow now, for us, we won't have both at the same time + (Some(_), Some(_)) => anyhow::bail!("'linux' and 'efi' values present"), + (None, None) => anyhow::bail!("Missing 'linux' or 'efi' value"), + }; + + Ok(BLSConfig { + title, + version, + cfg_type, + machine_id, + sort_key, + extra, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_bls_config() -> Result<()> { + let input = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 2 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + custom1 value1 + custom2 value2 + "#; + + let config = parse_bls_config(input)?; + + let BLSConfigType::NonEFI { + linux, + initrd, + options, + } = config.cfg_type + else { + panic!("Expected non EFI variant"); + }; + + assert_eq!( + config.title, + Some("Fedora 42.20250623.3.1 (CoreOS)".to_string()) + ); + assert_eq!(config.version, "2"); + assert_eq!(linux, "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"); + assert_eq!(initrd, vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"]); + assert_eq!(&*options.unwrap(), "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"); + assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string())); + assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string())); + + Ok(()) + } + + #[test] + fn test_parse_multiple_initrd() -> Result<()> { + let input = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 2 + linux /boot/vmlinuz + initrd /boot/initramfs-1.img + initrd /boot/initramfs-2.img + options root=UUID=abc123 rw + "#; + + let config = parse_bls_config(input)?; + + let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else { + panic!("Expected non EFI variant"); + }; + + assert_eq!( + initrd, + vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"] + ); + + Ok(()) + } + + #[test] + fn test_parse_missing_version() { + let input = r#" + title Fedora + linux /vmlinuz + initrd /initramfs.img + options root=UUID=xyz ro quiet + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_parse_missing_linux() { + let input = r#" + title Fedora + version 1 + initrd /initramfs.img + options root=UUID=xyz ro quiet + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_display_output() -> Result<()> { + let input = r#" + title Test OS + version 10 + linux /boot/vmlinuz + initrd /boot/initrd.img + initrd /boot/initrd-extra.img + options root=UUID=abc composefs=some-uuid + foo bar + "#; + + let config = parse_bls_config(input)?; + let output = format!("{}", config); + let mut output_lines = output.lines(); + + assert_eq!(output_lines.next().unwrap(), "title Test OS"); + assert_eq!(output_lines.next().unwrap(), "version 10"); + assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz"); + assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img"); + assert_eq!( + output_lines.next().unwrap(), + "initrd /boot/initrd-extra.img" + ); + assert_eq!( + output_lines.next().unwrap(), + "options root=UUID=abc composefs=some-uuid" + ); + assert_eq!(output_lines.next().unwrap(), "foo bar"); + + Ok(()) + } + + #[test] + fn test_ordering_by_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_sort_key() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + sort-key a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + sort-key b + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 < config2); + Ok(()) + } + + #[test] + fn test_ordering_by_sort_key_and_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + sort-key a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + sort-key a + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_machine_id() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + machine-id a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + machine-id b + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 < config2); + Ok(()) + } + + #[test] + fn test_ordering_by_machine_id_and_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + machine-id a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + machine-id a + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_nontrivial_version() -> Result<()> { + let config_final = parse_bls_config( + r#" + title Entry 1 + version 1.0 + linux /vmlinuz-1 + initrd /initrd-1 + "#, + )?; + + let config_rc1 = parse_bls_config( + r#" + title Entry 2 + version 1.0~rc1 + linux /vmlinuz-2 + initrd /initrd-2 + "#, + )?; + + // In a sorted list, we want 1.0 to appear before 1.0~rc1 because + // versions are sorted descending. This means that in Rust's sort order, + // config_final should be "less than" config_rc1. + assert!(config_final < config_rc1); + Ok(()) + } +} diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs new file mode 100644 index 000000000..41e25554c --- /dev/null +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -0,0 +1,541 @@ +//! Parser for GRUB menuentry configuration files using nom combinators. + +#![allow(dead_code)] + +use std::fmt::Display; + +use anyhow::Result; +use camino::Utf8PathBuf; +use composefs_boot::bootloader::EFI_EXT; +use nom::{ + bytes::complete::{escaped, tag, take_until}, + character::complete::{multispace0, multispace1, none_of}, + error::{Error, ErrorKind, ParseError}, + sequence::delimited, + Err, IResult, Parser, +}; + +/// Body content of a GRUB menuentry containing parsed commands. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct MenuentryBody<'a> { + /// Kernel modules to load + pub(crate) insmod: Vec<&'a str>, + /// Chainloader path (optional) + pub(crate) chainloader: String, + /// Search command (optional) + pub(crate) search: &'a str, + /// The version + pub(crate) version: u8, + /// Additional commands + pub(crate) extra: Vec<(&'a str, &'a str)>, +} + +impl<'a> Display for MenuentryBody<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for insmod in &self.insmod { + writeln!(f, "insmod {}", insmod)?; + } + + writeln!(f, "search {}", self.search)?; + writeln!(f, "chainloader {}", self.chainloader)?; + + for (k, v) in &self.extra { + writeln!(f, "{k} {v}")?; + } + + Ok(()) + } +} + +impl<'a> From> for MenuentryBody<'a> { + fn from(vec: Vec<(&'a str, &'a str)>) -> Self { + let mut entry = Self { + insmod: vec![], + chainloader: "".into(), + search: "", + version: 0, + extra: vec![], + }; + + for (key, value) in vec { + match key { + "insmod" => entry.insmod.push(value), + "chainloader" => entry.chainloader = value.into(), + "search" => entry.search = value, + "set" => {} + _ => entry.extra.push((key, value)), + } + } + + entry + } +} + +/// A complete GRUB menuentry with title and body commands. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct MenuEntry<'a> { + /// Display title (supports escaped quotes) + pub(crate) title: String, + /// Commands within the menuentry block + pub(crate) body: MenuentryBody<'a>, +} + +impl<'a> Display for MenuEntry<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "menuentry \"{}\" {{", self.title)?; + write!(f, "{}", self.body)?; + writeln!(f, "}}") + } +} + +impl<'a> MenuEntry<'a> { + #[allow(dead_code)] + pub(crate) fn new(boot_label: &str, uki_id: &str) -> Self { + Self { + title: format!("{boot_label}: ({uki_id})"), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: format!("/EFI/Linux/{uki_id}.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + } + } + + pub(crate) fn get_verity(&self) -> Result { + let to_path = Utf8PathBuf::from(self.body.chainloader.clone()); + + Ok(to_path + .components() + .last() + .ok_or(anyhow::anyhow!("Empty efi field"))? + .to_string() + .strip_suffix(EFI_EXT) + .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))? + .to_string()) + } +} + +/// Parser that takes content until balanced brackets, handling nested brackets and escapes. +fn take_until_balanced_allow_nested( + opening_bracket: char, + closing_bracket: char, +) -> impl Fn(&str) -> IResult<&str, &str> { + move |i: &str| { + let mut index = 0; + let mut bracket_counter = 0; + + while let Some(n) = &i[index..].find(&[opening_bracket, closing_bracket, '\\'][..]) { + index += n; + let mut characters = i[index..].chars(); + + match characters.next().unwrap_or_default() { + c if c == '\\' => { + // Skip '\' + index += '\\'.len_utf8(); + // Skip char following '\' + let c = characters.next().unwrap_or_default(); + index += c.len_utf8(); + } + + c if c == opening_bracket => { + bracket_counter += 1; + index += opening_bracket.len_utf8(); + } + + c if c == closing_bracket => { + bracket_counter -= 1; + index += closing_bracket.len_utf8(); + } + + // Should not happen + _ => unreachable!(), + }; + + // We found the unmatched closing bracket. + if bracket_counter == -1 { + // Don't consume it as we'll "tag" it afterwards + index -= closing_bracket.len_utf8(); + return Ok((&i[index..], &i[0..index])); + }; + } + + if bracket_counter == 0 { + Ok(("", i)) + } else { + Err(Err::Error(Error::from_error_kind(i, ErrorKind::TakeUntil))) + } + } +} + +/// Parses a single menuentry with title and body commands. +fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> { + let (input, _) = tag("menuentry").parse(input)?; + + // Require at least one space after "menuentry" + let (input, _) = multispace1.parse(input)?; + // Eat up the title, handling escaped quotes + let (input, title) = delimited( + tag("\""), + escaped(none_of("\\\""), '\\', none_of("")), + tag("\""), + ) + .parse(input)?; + + // Skip any whitespace after title + let (input, _) = multispace0.parse(input)?; + + // Eat up everything insde { .. } + let (input, body) = delimited( + tag("{"), + take_until_balanced_allow_nested('{', '}'), + tag("}"), + ) + .parse(input)?; + + let mut map = vec![]; + + for line in body.lines() { + let line = line.trim(); + + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + map.push((key, value.trim())); + } + } + + Ok(( + input, + MenuEntry { + title: title.to_string(), + body: MenuentryBody::from(map), + }, + )) +} + +/// Skips content until finding "menuentry" keyword or end of input. +fn skip_to_menuentry(input: &str) -> IResult<&str, ()> { + let (input, _) = take_until("menuentry")(input)?; + Ok((input, ())) +} + +/// Parses all menuentries from a GRUB configuration file. +fn parse_all(input: &str) -> IResult<&str, Vec>> { + let mut remaining = input; + let mut entries = Vec::new(); + + // Skip any content before the first menuentry + let Ok((new_input, _)) = skip_to_menuentry(remaining) else { + return Ok(("", Default::default())); + }; + remaining = new_input; + + while !remaining.trim().is_empty() { + let (new_input, entry) = parse_menuentry(remaining)?; + entries.push(entry); + remaining = new_input; + + // Skip whitespace and try to find next menuentry + let (ws_input, _) = multispace0(remaining)?; + remaining = ws_input; + + if let Ok((next_input, _)) = skip_to_menuentry(remaining) { + remaining = next_input; + } else if !remaining.trim().is_empty() { + // No more menuentries found, but content remains + break; + } + } + + Ok((remaining, entries)) +} + +/// Main entry point for parsing GRUB menuentry files. +pub(crate) fn parse_grub_menuentry_file(contents: &str) -> anyhow::Result>> { + let (_, entries) = parse_all(&contents) + .map_err(|e| anyhow::anyhow!("Failed to parse GRUB menuentries: {e}"))?; + // Validate that entries have reasonable structure + for entry in &entries { + if entry.title.is_empty() { + anyhow::bail!("Found menuentry with empty title"); + } + } + + Ok(entries) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_menuconfig_parser() { + let menuentry = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + # Skip this comment + + menuentry "Fedora 42: (Verity-42)" { + insmod fat + insmod chain + # This should also be skipped + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + + menuentry "Fedora 43: (Verity-43)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/uki.efi + extra_field1 this is extra + extra_field2 this is also extra + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + let expected = vec![ + MenuEntry { + title: "Fedora 42: (Verity-42)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + version: 0, + extra: vec![], + }, + }, + MenuEntry { + title: "Fedora 43: (Verity-43)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/uki.efi".into(), + version: 0, + extra: vec![ + ("extra_field1", "this is extra"), + ("extra_field2", "this is also extra") + ] + }, + }, + ]; + + println!("{}", expected[0]); + + assert_eq!(result, expected); + } + + #[test] + fn test_escaped_quotes_in_title() { + let menuentry = r#" + menuentry "Title with \"escaped quotes\" inside" { + insmod fat + chainloader /EFI/Linux/test.efi + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].title, "Title with \\\"escaped quotes\\\" inside"); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); + } + + #[test] + fn test_multiple_escaped_quotes() { + let menuentry = r#" + menuentry "Test \"first\" and \"second\" quotes" { + insmod fat + chainloader /EFI/Linux/test.efi + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].title, + "Test \\\"first\\\" and \\\"second\\\" quotes" + ); + } + + #[test] + fn test_escaped_backslash_in_title() { + let menuentry = r#" + menuentry "Path with \\ backslash" { + insmod fat + chainloader /EFI/Linux/test.efi + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].title, "Path with \\\\ backslash"); + } + + #[test] + fn test_minimal_menuentry() { + let menuentry = r#" + menuentry "Minimal Entry" { + # Just a comment + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].title, "Minimal Entry"); + assert_eq!(result[0].body.insmod.len(), 0); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); + assert_eq!(result[0].body.extra.len(), 0); + } + + #[test] + fn test_menuentry_with_only_insmod() { + let menuentry = r#" + menuentry "Insmod Only" { + insmod fat + insmod chain + insmod ext2 + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].body.insmod, vec!["fat", "chain", "ext2"]); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); + } + + #[test] + fn test_menuentry_with_set_commands_ignored() { + let menuentry = r#" + menuentry "With Set Commands" { + set timeout=5 + set root=(hd0,1) + insmod fat + chainloader /EFI/Linux/test.efi + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].body.insmod, vec!["fat"]); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); + // set commands should be ignored + assert!(!result[0].body.extra.iter().any(|(k, _)| k == &"set")); + } + + #[test] + fn test_nested_braces_in_body() { + let menuentry = r#" + menuentry "Nested Braces" { + if [ -f ${config_directory}/test.cfg ]; then + source ${config_directory}/test.cfg + fi + insmod fat + chainloader /EFI/Linux/test.efi + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].title, "Nested Braces"); + assert_eq!(result[0].body.insmod, vec!["fat"]); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); + // The if/fi block should be captured as extra commands + assert!(result[0].body.extra.iter().any(|(k, _)| k == &"if")); + } + + #[test] + fn test_empty_file() { + let result = parse_grub_menuentry_file("").expect("Should handle empty file"); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_file_with_no_menuentries() { + let content = r#" + # Just comments and other stuff + set timeout=10 + if [ -f /boot/grub/custom.cfg ]; then + source /boot/grub/custom.cfg + fi + "#; + + let result = + parse_grub_menuentry_file(content).expect("Should handle file with no menuentries"); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_malformed_menuentry_missing_quote() { + let menuentry = r#" + menuentry "Missing closing quote { + insmod fat + } + "#; + + let result = parse_grub_menuentry_file(menuentry); + assert!(result.is_err(), "Should fail on malformed menuentry"); + } + + #[test] + fn test_malformed_menuentry_missing_brace() { + let menuentry = r#" + menuentry "Missing Brace" { + insmod fat + chainloader /EFI/Linux/test.efi + // Missing closing brace + "#; + + let result = parse_grub_menuentry_file(menuentry); + assert!(result.is_err(), "Should fail on unbalanced braces"); + } + + #[test] + fn test_multiple_menuentries_with_content_between() { + let content = r#" + # Some initial config + set timeout=10 + + menuentry "First Entry" { + insmod fat + chainloader /EFI/Linux/first.efi + } + + # Some comments between entries + set default=0 + + menuentry "Second Entry" { + insmod ext2 + search --set=root --fs-uuid "some-uuid" + chainloader /EFI/Linux/second.efi + } + + # Trailing content + "#; + + let result = parse_grub_menuentry_file(content) + .expect("Should parse multiple entries with content between"); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].title, "First Entry"); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/first.efi"); + assert_eq!(result[1].title, "Second Entry"); + assert_eq!(result[1].body.chainloader, "/EFI/Linux/second.efi"); + assert_eq!(result[1].body.search, "--set=root --fs-uuid \"some-uuid\""); + } +} diff --git a/crates/lib/src/parsers/mod.rs b/crates/lib/src/parsers/mod.rs new file mode 100644 index 000000000..e3640c8ef --- /dev/null +++ b/crates/lib/src/parsers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod bls_config; +pub(crate) mod grub_menuconfig; diff --git a/crates/lib/src/podman.rs b/crates/lib/src/podman.rs new file mode 100644 index 000000000..7337fbc89 --- /dev/null +++ b/crates/lib/src/podman.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use serde::Deserialize; + +/// Where we look inside our container to find our own image +/// for use with `bootc install`. +pub(crate) const CONTAINER_STORAGE: &str = "/var/lib/containers"; + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct Inspect { + pub(crate) digest: String, +} + +/// This is output from `podman image list --format=json`. +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct ImageListEntry { + pub(crate) id: String, + pub(crate) names: Option>, +} + +/// Given an image ID, return its manifest digest +pub(crate) fn imageid_to_digest(imgid: &str) -> Result { + use bootc_utils::CommandRunExt; + let o: Vec = crate::install::run_in_host_mountns("podman")? + .args(["inspect", imgid]) + .run_and_parse_json()?; + let i = o + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No images returned for inspect"))?; + Ok(i.digest) +} + +/// Return true if there is apparently an active container store at the target path. +pub(crate) fn storage_exists(root: &Dir, path: impl AsRef) -> Result { + fn impl_storage_exists(root: &Dir, path: &Utf8Path) -> Result { + let lock = "storage.lock"; + root.try_exists(path.join(lock)).map_err(Into::into) + } + impl_storage_exists(root, path.as_ref()) +} + +/// Return true if there is apparently an active container store in the default path +/// for the target root. +/// +/// Note this does not attempt to parse the root filesystem's container storage configuration, +/// this uses a hardcoded default path. +pub(crate) fn storage_exists_default(root: &Dir) -> Result { + storage_exists(root, CONTAINER_STORAGE.trim_start_matches('/')) +} diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs new file mode 100644 index 000000000..eaff56572 --- /dev/null +++ b/crates/lib/src/podstorage.rs @@ -0,0 +1,399 @@ +//! # bootc-managed instance of containers-storage: +//! +//! The backend for podman and other tools is known as `container-storage:`, +//! with a canonical instance that lives in `/var/lib/containers`. +//! +//! This is a `containers-storage:` instance` which is owned by bootc and +//! is stored at `/sysroot/ostree/bootc`. +//! +//! At the current time, this is only used for Logically Bound Images. + +use std::collections::HashSet; +use std::io::Seek; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use bootc_utils::{AsyncCommandRunExt, CommandRunExt, ExitStatusExt}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::cap_tempfile::TempDir; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::ostree::{self}; +use std::os::fd::OwnedFd; +use tokio::process::Command as AsyncCommand; + +// Pass only 100 args at a time just to avoid potentially overflowing argument +// vectors; not that this should happen in reality, but just in case. +const SUBCMD_ARGV_CHUNKING: usize = 100; + +/// Global directory path which we use for podman to point +/// it at our storage. Unfortunately we can't yet use the +/// /proc/self/fd/N trick because it currently breaks due +/// to how the untar process is forked in the child. +pub(crate) const STORAGE_ALIAS_DIR: &str = "/run/bootc/storage"; +/// We pass this via /proc/self/fd to the child process. +const STORAGE_RUN_FD: i32 = 3; + +const LABELED: &str = ".bootc_labeled"; + +/// The path to the image storage, relative to the bootc root directory. +pub(crate) const SUBPATH: &str = "storage"; +/// The path to the "runroot" with transient runtime state; this is +/// relative to the /run directory +const RUNROOT: &str = "bootc/storage"; + +/// A bootc-owned instance of `containers-storage:`. +pub(crate) struct CStorage { + /// The root directory + sysroot: Dir, + /// The location of container storage + storage_root: Dir, + #[allow(dead_code)] + /// Our runtime state + run: Dir, + /// Disallow using this across multiple threads concurrently; while we + /// have internal locking in podman, in the future we may change how + /// things work here. And we don't have a use case right now for + /// concurrent operations. + _unsync: std::cell::Cell<()>, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum PullMode { + /// Pull only if the image is not present + IfNotExists, + /// Always check for an update + #[allow(dead_code)] + Always, +} + +#[allow(unsafe_code)] +#[context("Binding storage roots")] +fn bind_storage_roots(cmd: &mut Command, storage_root: &Dir, run_root: &Dir) -> Result<()> { + // podman requires an absolute path, for two reasons right now: + // - It writes the file paths into `db.sql`, a sqlite database for unknown reasons + // - It forks helper binaries, so just giving it /proc/self/fd won't work as + // those helpers may not get the fd passed. (which is also true of skopeo) + // We create a new mount namespace, which also has the helpful side effect + // of automatically cleaning up the global bind mount that the storage stack + // creates. + + let storage_root = Arc::new(storage_root.try_clone().context("Cloning storage root")?); + let run_root: Arc = Arc::new(run_root.try_clone().context("Cloning runroot")?.into()); + // SAFETY: All the APIs we call here are safe to invoke between fork and exec. + unsafe { + cmd.pre_exec(move || { + use rustix::fs::{Mode, OFlags}; + // For reasons I don't understand, we can't just `mount("/proc/self/fd/N", "/path/to/target")` + // but it *does* work to fchdir(fd) + mount(".", "/path/to/target"). + // I think it may be that mount doesn't like operating on the magic links? + // This trick only works if we set our working directory to the target *before* + // creating the new namespace too. + // + // I think we may be hitting this: + // + // " EINVAL A bind operation (MS_BIND) was requested where source referred a mount namespace magic link (i.e., a /proc/pid/ns/mnt magic link or a bind mount to such a link) and the propagation type of the parent mount of target was + // MS_SHARED, but propagation of the requested bind mount could lead to a circular dependency that might prevent the mount namespace from ever being freed." + // + // But...how did we avoid that circular dependency by using the process cwd? + // + // I tried making the mounts recursively private, but that didn't help. + let oldwd = rustix::fs::open( + ".", + OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::RDONLY, + Mode::empty(), + )?; + rustix::process::fchdir(&storage_root)?; + rustix::thread::unshare_unsafe(rustix::thread::UnshareFlags::NEWNS)?; + rustix::mount::mount_bind(".", STORAGE_ALIAS_DIR)?; + rustix::process::fchdir(&oldwd)?; + Ok(()) + }) + }; + cmd.take_fd_n(run_root, STORAGE_RUN_FD); + Ok(()) +} + +fn new_podman_cmd_in(storage_root: &Dir, run_root: &Dir) -> Result { + let mut cmd = Command::new("podman"); + bind_storage_roots(&mut cmd, storage_root, run_root)?; + let run_root = format!("/proc/self/fd/{STORAGE_RUN_FD}"); + cmd.args(["--root", STORAGE_ALIAS_DIR, "--runroot", run_root.as_str()]); + Ok(cmd) +} + +/// Adjust the provided command (skopeo or podman e.g.) to reference +/// the provided path as an additional image store. +pub fn set_additional_image_store<'c>( + cmd: &'c mut Command, + ais: impl AsRef, +) -> &'c mut Command { + let ais = ais.as_ref(); + let storage_opt = format!("additionalimagestore={ais}"); + cmd.env("STORAGE_OPTS", storage_opt) +} + +/// Ensure that "podman" is the first thing to touch the global storage +/// instance. This is a workaround for https://github.com/bootc-dev/bootc/pull/1101#issuecomment-2653862974 +/// Basically podman has special upgrade logic for when it is the first thing +/// to initialize the c/storage instance it sets the networking to netavark. +/// If it's not the first thing, then it assumes an upgrade scenario and we +/// may be using CNI. +/// +/// But this legacy path is triggered through us using skopeo, turning off netavark +/// by default. Work around this by ensuring that /usr/bin/podman is +/// always the first thing to touch c/storage (at least, when invoked by us). +/// +/// Call this function any time we're going to write to containers-storage. +pub(crate) fn ensure_floating_c_storage_initialized() { + if let Err(e) = Command::new("podman") + .args(["system", "info"]) + .stdout(Stdio::null()) + .run_capture_stderr() + { + // Out of conservatism we don't make this operation fatal right now. + // If something went wrong, then we'll probably fail on a later operation + // anyways. + tracing::warn!("Failed to query podman system info: {e}"); + } +} + +impl CStorage { + /// Create a `podman image` Command instance prepared to operate on our alternative + /// root. + pub(crate) fn new_image_cmd(&self) -> Result { + let mut r = new_podman_cmd_in(&self.storage_root, &self.run)?; + // We want to limit things to only manipulating images by default. + r.arg("image"); + Ok(r) + } + + fn init_globals() -> Result<()> { + // Ensure our global storage alias dir exists + std::fs::create_dir_all(STORAGE_ALIAS_DIR) + .with_context(|| format!("Creating {STORAGE_ALIAS_DIR}"))?; + Ok(()) + } + + /// Ensure that the LSM (SELinux) labels are set on the bootc-owned + /// containers-storage: instance. We use a `LABELED` stamp file for + /// idempotence. + #[context("Labeling imgstorage dirs")] + fn ensure_labeled(root: &Dir, sepolicy: Option<&ostree::SePolicy>) -> Result<()> { + if root.try_exists(LABELED)? { + return Ok(()); + } + let Some(sepolicy) = sepolicy else { + return Ok(()); + }; + + // recursively set the labels because they were previously set to usr_t, + // and there is no policy defined to set them to the c/storage labels + crate::lsm::relabel_recurse( + &root, + ".", + Some(Utf8Path::new("/var/lib/containers/storage")), + sepolicy, + ) + .context("labeling storage root")?; + + root.create(LABELED)?; + + Ok(()) + } + + #[context("Creating imgstorage")] + pub(crate) fn create( + sysroot: &Dir, + run: &Dir, + sepolicy: Option<&ostree::SePolicy>, + ) -> Result { + Self::init_globals()?; + let subpath = &Self::subpath(); + + // SAFETY: We know there's a parent + let parent = subpath.parent().unwrap(); + let tmp = format!("{subpath}.tmp"); + if !sysroot + .try_exists(subpath) + .with_context(|| format!("Querying {subpath}"))? + { + sysroot.remove_all_optional(&tmp).context("Removing tmp")?; + sysroot + .create_dir_all(parent) + .with_context(|| format!("Creating {parent}"))?; + sysroot.create_dir_all(&tmp).context("Creating tmpdir")?; + let storage_root = sysroot.open_dir(&tmp).context("Open tmp")?; + + // There's no explicit API to initialize a containers-storage: + // root, simply passing a path will attempt to auto-create it. + // We run "podman images" in the new root. + new_podman_cmd_in(&storage_root, &run)? + .stdout(Stdio::null()) + .arg("images") + .run_capture_stderr() + .context("Initializing images")?; + Self::ensure_labeled(&storage_root, sepolicy)?; + drop(storage_root); + sysroot + .rename(&tmp, sysroot, subpath) + .context("Renaming tmpdir")?; + tracing::debug!("Created image store"); + } else { + // the storage already exists, make sure it has selinux labels + let storage_root = sysroot.open_dir(subpath).context("opening storage dir")?; + Self::ensure_labeled(&storage_root, sepolicy)?; + } + + Self::open(sysroot, run) + } + + #[context("Opening imgstorage")] + pub(crate) fn open(sysroot: &Dir, run: &Dir) -> Result { + tracing::trace!("Opening container image store"); + Self::init_globals()?; + let subpath = &Self::subpath(); + let storage_root = sysroot + .open_dir(subpath) + .with_context(|| format!("Opening {subpath}"))?; + // Always auto-create this if missing + run.create_dir_all(RUNROOT) + .with_context(|| format!("Creating {RUNROOT}"))?; + let run = run.open_dir(RUNROOT)?; + Ok(Self { + sysroot: sysroot.try_clone()?, + storage_root, + run, + _unsync: Default::default(), + }) + } + + #[context("Listing images")] + pub(crate) async fn list_images(&self) -> Result> { + let mut cmd = self.new_image_cmd()?; + cmd.args(["list", "--format=json"]); + cmd.stdin(Stdio::null()); + // It's maximally convenient for us to just pipe the whole output to a tempfile + let mut stdout = tempfile::tempfile()?; + cmd.stdout(stdout.try_clone()?); + // Allocate stderr, which is passed to the status checker + let stderr = tempfile::tempfile()?; + cmd.stderr(stderr.try_clone()?); + + // Spawn the child and wait + AsyncCommand::from(cmd) + .status() + .await? + .check_status_with_stderr(stderr)?; + // Spawn a helper thread to avoid blocking the main thread + // parsing JSON. + tokio::task::spawn_blocking(move || -> Result<_> { + stdout.seek(std::io::SeekFrom::Start(0))?; + let stdout = std::io::BufReader::new(stdout); + let r = serde_json::from_reader(stdout)?; + Ok(r) + }) + .await? + } + + #[context("Pruning")] + pub(crate) async fn prune_except_roots(&self, roots: &HashSet<&str>) -> Result> { + let all_images = self.list_images().await?; + tracing::debug!("Images total: {}", all_images.len(),); + let mut garbage = Vec::new(); + for image in all_images { + if image + .names + .iter() + .flatten() + .any(|name| !roots.contains(name.as_str())) + { + garbage.push(image.id); + } + } + tracing::debug!("Images to prune: {}", garbage.len()); + for garbage in garbage.chunks(SUBCMD_ARGV_CHUNKING) { + let mut cmd = self.new_image_cmd()?; + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.arg("rm"); + cmd.args(garbage); + AsyncCommand::from(cmd).run().await?; + } + Ok(garbage) + } + + /// Return true if the image exists in the storage. + pub(crate) async fn exists(&self, image: &str) -> Result { + // Sadly https://docs.rs/containers-image-proxy/latest/containers_image_proxy/struct.ImageProxy.html#method.open_image_optional + // doesn't work with containers-storage yet + let mut cmd = AsyncCommand::from(self.new_image_cmd()?); + cmd.args(["exists", image]); + Ok(cmd.status().await?.success()) + } + + /// Fetch the image if it is not already present; return whether + /// or not the image was fetched. + pub(crate) async fn pull(&self, image: &str, mode: PullMode) -> Result { + match mode { + PullMode::IfNotExists => { + if self.exists(image).await? { + tracing::debug!("Image is already present: {image}"); + return Ok(false); + } + } + PullMode::Always => {} + }; + let mut cmd = self.new_image_cmd()?; + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.args(["pull", image]); + let authfile = ostree_ext::globals::get_global_authfile(&self.sysroot)? + .map(|(authfile, _fd)| authfile); + if let Some(authfile) = authfile { + cmd.args(["--authfile", authfile.as_str()]); + } + tracing::debug!("Pulling image: {image}"); + let mut cmd = AsyncCommand::from(cmd); + cmd.run().await.context("Failed to pull image")?; + Ok(true) + } + + /// Copy an image from the default container storage (/var/lib/containers/) + /// to this storage. + #[context("Pulling from host storage: {image}")] + pub(crate) async fn pull_from_host_storage(&self, image: &str) -> Result<()> { + let mut cmd = Command::new("podman"); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + // An ephemeral place for the transient state; + let temp_runroot = TempDir::new(cap_std::ambient_authority())?; + bind_storage_roots(&mut cmd, &self.storage_root, &temp_runroot)?; + + // The destination (target stateroot) + container storage dest + let storage_dest = &format!( + "containers-storage:[overlay@{STORAGE_ALIAS_DIR}+/proc/self/fd/{STORAGE_RUN_FD}]" + ); + cmd.args(["image", "push", "--remove-signatures", image]) + .arg(format!("{storage_dest}{image}")); + let mut cmd = AsyncCommand::from(cmd); + cmd.run().await?; + temp_runroot.close()?; + Ok(()) + } + + fn subpath() -> Utf8PathBuf { + Utf8Path::new(crate::store::BOOTC_ROOT).join(SUBPATH) + } +} + +#[cfg(test)] +mod tests { + use super::*; + static_assertions::assert_not_impl_any!(CStorage: Sync); +} diff --git a/crates/lib/src/progress_jsonl.rs b/crates/lib/src/progress_jsonl.rs new file mode 100644 index 000000000..4b9341d81 --- /dev/null +++ b/crates/lib/src/progress_jsonl.rs @@ -0,0 +1,344 @@ +//! Output progress data using the json-lines format. For more information +//! see . + +use anyhow::Result; +use canon_json::CanonJsonSerialize; +use schemars::JsonSchema; +use serde::Serialize; +use std::borrow::Cow; +use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::net::unix::pipe::Sender; +use tokio::sync::Mutex; + +// Maximum number of times per second that an event will be written. +const REFRESH_HZ: u16 = 5; + +/// Semantic version of the protocol. +const API_VERSION: &str = "0.1.0"; + +/// An incremental update to e.g. a container image layer download. +/// The first time a given "subtask" name is seen, a new progress bar should be created. +/// If bytes == bytes_total, then the subtask is considered complete. +#[derive( + Debug, serde::Serialize, serde::Deserialize, Default, Clone, JsonSchema, PartialEq, Eq, +)] +#[serde(rename_all = "camelCase")] +pub struct SubTaskBytes<'t> { + /// A machine readable type for the task (used for i18n). + /// (e.g., "ostree_chunk", "ostree_derived") + #[serde(borrow)] + pub subtask: Cow<'t, str>, + /// A human readable description of the task if i18n is not available. + /// (e.g., "OSTree Chunk:", "Derived Layer:") + #[serde(borrow)] + pub description: Cow<'t, str>, + /// A human and machine readable identifier for the task + /// (e.g., ostree chunk/layer hash). + #[serde(borrow)] + pub id: Cow<'t, str>, + /// The number of bytes fetched by a previous run (e.g., zstd_chunked). + pub bytes_cached: u64, + /// Updated byte level progress + pub bytes: u64, + /// Total number of bytes + pub bytes_total: u64, +} + +/// Marks the beginning and end of a dictrete step +#[derive( + Debug, serde::Serialize, serde::Deserialize, Default, Clone, JsonSchema, PartialEq, Eq, +)] +#[serde(rename_all = "camelCase")] +pub struct SubTaskStep<'t> { + /// A machine readable type for the task (used for i18n). + /// (e.g., "ostree_chunk", "ostree_derived") + #[serde(borrow)] + pub subtask: Cow<'t, str>, + /// A human readable description of the task if i18n is not available. + /// (e.g., "OSTree Chunk:", "Derived Layer:") + #[serde(borrow)] + pub description: Cow<'t, str>, + /// A human and machine readable identifier for the task + /// (e.g., ostree chunk/layer hash). + #[serde(borrow)] + pub id: Cow<'t, str>, + /// Starts as false when beginning to execute and turns true when completed. + pub completed: bool, +} + +/// An event emitted as JSON. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema, PartialEq, Eq)] +#[serde( + tag = "type", + rename_all = "PascalCase", + rename_all_fields = "camelCase" +)] +pub enum Event<'t> { + Start { + /// The semantic version of the progress protocol. + #[serde(borrow)] + version: Cow<'t, str>, + }, + /// An incremental update to a container image layer download + ProgressBytes { + /// A machine readable type (e.g., pulling) for the task (used for i18n + /// and UI customization). + #[serde(borrow)] + task: Cow<'t, str>, + /// A human readable description of the task if i18n is not available. + #[serde(borrow)] + description: Cow<'t, str>, + /// A human and machine readable unique identifier for the task + /// (e.g., the image name). For tasks that only happen once, + /// it can be set to the same value as task. + #[serde(borrow)] + id: Cow<'t, str>, + /// The number of bytes fetched by a previous run. + bytes_cached: u64, + /// The number of bytes already fetched. + bytes: u64, + /// Total number of bytes. If zero, then this should be considered "unspecified". + bytes_total: u64, + /// The number of steps fetched by a previous run. + steps_cached: u64, + /// The initial position of progress. + steps: u64, + /// The total number of steps (e.g. container image layers, RPMs) + steps_total: u64, + /// The currently running subtasks. + subtasks: Vec>, + }, + /// An incremental update with discrete steps + ProgressSteps { + /// A machine readable type (e.g., pulling) for the task (used for i18n + /// and UI customization). + #[serde(borrow)] + task: Cow<'t, str>, + /// A human readable description of the task if i18n is not available. + #[serde(borrow)] + description: Cow<'t, str>, + /// A human and machine readable unique identifier for the task + /// (e.g., the image name). For tasks that only happen once, + /// it can be set to the same value as task. + #[serde(borrow)] + id: Cow<'t, str>, + /// The number of steps fetched by a previous run. + steps_cached: u64, + /// The initial position of progress. + steps: u64, + /// The total number of steps (e.g. container image layers, RPMs) + steps_total: u64, + /// The currently running subtasks. + subtasks: Vec>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RawProgressFd(RawFd); + +impl FromStr for RawProgressFd { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let fd = s.parse::()?; + // Sanity check + if matches!(fd, 0..=2) { + anyhow::bail!("Cannot use fd {fd} for progress JSON") + } + Ok(Self(fd.try_into()?)) + } +} + +#[derive(Debug)] +struct ProgressWriterInner { + /// true if we sent the initial Start message + sent_start: bool, + last_write: Option, + fd: BufWriter, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ProgressWriter { + inner: Arc>>, +} + +impl TryFrom for ProgressWriter { + type Error = anyhow::Error; + + fn try_from(value: OwnedFd) -> Result { + let value = Sender::from_owned_fd(value)?; + Ok(Self::from(value)) + } +} + +impl From for ProgressWriter { + fn from(value: Sender) -> Self { + let inner = ProgressWriterInner { + sent_start: false, + last_write: None, + fd: BufWriter::new(value), + }; + Self { + inner: Arc::new(Some(inner).into()), + } + } +} + +impl TryFrom for ProgressWriter { + type Error = anyhow::Error; + + #[allow(unsafe_code)] + fn try_from(fd: RawProgressFd) -> Result { + unsafe { OwnedFd::from_raw_fd(fd.0) }.try_into() + } +} + +impl ProgressWriter { + /// Serialize the target value as a single line of JSON and write it. + async fn send_impl_inner(inner: &mut ProgressWriterInner, v: T) -> Result<()> { + // canon_json is guaranteed not to output newlines here + let buf = v.to_canon_json_vec()?; + inner.fd.write_all(&buf).await?; + // We always end in a newline + inner.fd.write_all(b"\n").await?; + // And flush to ensure the remote side sees updates immediately + inner.fd.flush().await?; + Ok(()) + } + + /// Serialize the target object to JSON as a single line + pub(crate) async fn send_impl(&self, v: T, required: bool) -> Result<()> { + let mut guard = self.inner.lock().await; + // Check if we have an inner value; if not, nothing to do. + let Some(inner) = guard.as_mut() else { + return Ok(()); + }; + + // If this is our first message, emit the Start message + if !inner.sent_start { + inner.sent_start = true; + let start = Event::Start { + version: API_VERSION.into(), + }; + Self::send_impl_inner(inner, &start).await?; + } + + // For messages that can be dropped, if we already sent an update within this cycle, discard this one. + // TODO: Also consider querying the pipe buffer and also dropping if we can't do this write. + let now = Instant::now(); + if !required { + const REFRESH_MS: u32 = 1000 / REFRESH_HZ as u32; + if let Some(elapsed) = inner.last_write.map(|w| now.duration_since(w)) { + if elapsed.as_millis() < REFRESH_MS.into() { + return Ok(()); + } + } + } + + Self::send_impl_inner(inner, &v).await?; + // Update the last write time + inner.last_write = Some(now); + Ok(()) + } + + /// Send an event. + pub(crate) async fn send(&self, event: Event<'_>) { + if let Err(e) = self.send_impl(event, true).await { + eprintln!("Failed to write to jsonl: {e}"); + // Stop writing to fd but let process continue + // SAFETY: Propagating panics from the mutex here is intentional + let _ = self.inner.lock().await.take(); + } + } + + /// Send an event that can be dropped. + pub(crate) async fn send_lossy(&self, event: Event<'_>) { + if let Err(e) = self.send_impl(event, false).await { + eprintln!("Failed to write to jsonl: {e}"); + // Stop writing to fd but let process continue + // SAFETY: Propagating panics from the mutex here is intentional + let _ = self.inner.lock().await.take(); + } + } + + /// Flush remaining data and return the underlying file. + #[allow(dead_code)] + pub(crate) async fn into_inner(self) -> Result> { + // SAFETY: Propagating panics from the mutex here is intentional + let mut mutex = self.inner.lock().await; + if let Some(inner) = mutex.take() { + Ok(Some(inner.fd.into_inner())) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod test { + use tokio::io::{AsyncBufReadExt, BufReader}; + + use super::*; + + #[tokio::test] + async fn test_jsonl() -> Result<()> { + let testvalues = [ + Event::ProgressSteps { + task: "sometask".into(), + description: "somedesc".into(), + id: "someid".into(), + steps_cached: 0, + steps: 0, + steps_total: 3, + subtasks: Vec::new(), + }, + Event::ProgressBytes { + task: "sometask".into(), + description: "somedesc".into(), + id: "someid".into(), + bytes_cached: 0, + bytes: 11, + bytes_total: 42, + steps_cached: 0, + steps: 0, + steps_total: 3, + subtasks: Vec::new(), + }, + ]; + let (send, recv) = tokio::net::unix::pipe::pipe()?; + let testvalues_sender = testvalues.iter().cloned(); + let sender = async move { + let w = ProgressWriter::try_from(send)?; + for value in testvalues_sender { + w.send(value).await; + } + anyhow::Ok(()) + }; + let testvalues = &testvalues; + let receiver = async move { + let tf = BufReader::new(recv); + let mut expected = testvalues.iter(); + let mut lines = tf.lines(); + let mut got_first = false; + while let Some(line) = lines.next_line().await? { + let found: Event = serde_json::from_str(&line)?; + let expected_value = if !got_first { + got_first = true; + &Event::Start { + version: API_VERSION.into(), + } + } else { + expected.next().unwrap() + }; + assert_eq!(&found, expected_value); + } + anyhow::Ok(()) + }; + tokio::try_join!(sender, receiver)?; + Ok(()) + } +} diff --git a/crates/lib/src/reboot.rs b/crates/lib/src/reboot.rs new file mode 100644 index 000000000..63d7a5fe5 --- /dev/null +++ b/crates/lib/src/reboot.rs @@ -0,0 +1,33 @@ +//! Handling of system restarts/reboot + +use std::{io::Write, process::Command}; + +use bootc_utils::CommandRunExt; +use fn_error_context::context; + +/// Initiate a system reboot. +/// This function will only return in case of error. +#[context("Initiating reboot")] +pub(crate) fn reboot() -> anyhow::Result<()> { + // Flush output streams + let _ = std::io::stdout().flush(); + let _ = std::io::stderr().flush(); + Command::new("systemd-run") + .args([ + "--quiet", + "--", + "systemctl", + "reboot", + "--message=Initiated by bootc", + ]) + .run_capture_stderr()?; + // We expect to be terminated via SIGTERM here. We sleep + // instead of exiting an exit would necessarily appear + // racy to calling processes in that sometimes we'd + // win the race to exit, other times might get killed + // via SIGTERM. + tracing::debug!("Initiated reboot, sleeping"); + loop { + std::thread::park(); + } +} diff --git a/crates/lib/src/rhsm.rs b/crates/lib/src/rhsm.rs new file mode 100644 index 000000000..cd9937207 --- /dev/null +++ b/crates/lib/src/rhsm.rs @@ -0,0 +1,134 @@ +//! Integration with Red Hat Subscription Manager + +use anyhow::{Context, Result}; +use cap_std::fs::Dir; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use serde::Serialize; + +const FACTS_PATH: &str = "etc/rhsm/facts/bootc.facts"; + +#[derive(Serialize, PartialEq, Eq, Debug, Default)] +struct RhsmFacts { + #[serde(rename = "bootc.booted.image")] + booted_image: String, + #[serde(rename = "bootc.booted.version")] + booted_version: String, + #[serde(rename = "bootc.booted.digest")] + booted_digest: String, + #[serde(rename = "bootc.staged.image")] + staged_image: String, + #[serde(rename = "bootc.staged.version")] + staged_version: String, + #[serde(rename = "bootc.staged.digest")] + staged_digest: String, + #[serde(rename = "bootc.rollback.image")] + rollback_image: String, + #[serde(rename = "bootc.rollback.version")] + rollback_version: String, + #[serde(rename = "bootc.rollback.digest")] + rollback_digest: String, + #[serde(rename = "bootc.available.image")] + available_image: String, + #[serde(rename = "bootc.available.version")] + available_version: String, + #[serde(rename = "bootc.available.digest")] + available_digest: String, +} + +/// Return the image reference, version and digest as owned strings. +/// A missing version is serialized as the empty string. +fn status_to_strings(imagestatus: &crate::spec::ImageStatus) -> (String, String, String) { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + (image, version, digest) +} + +impl From for RhsmFacts { + fn from(hoststatus: crate::spec::HostStatus) -> Self { + let (booted_image, booted_version, booted_digest) = hoststatus + .booted + .as_ref() + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) + .unwrap_or_default(); + + let (staged_image, staged_version, staged_digest) = hoststatus + .staged + .as_ref() + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) + .unwrap_or_default(); + + let (rollback_image, rollback_version, rollback_digest) = hoststatus + .rollback + .as_ref() + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) + .unwrap_or_default(); + + let (available_image, available_version, available_digest) = hoststatus + .booted + .as_ref() + .and_then(|boot_entry| boot_entry.cached_update.as_ref().map(status_to_strings)) + .unwrap_or_default(); + + Self { + booted_image, + booted_version, + booted_digest, + staged_image, + staged_version, + staged_digest, + rollback_image, + rollback_version, + rollback_digest, + available_image, + available_version, + available_digest, + } + } +} + +/// Publish facts for subscription-manager consumption +#[context("Publishing facts")] +pub(crate) async fn publish_facts(root: &Dir) -> Result<()> { + let sysroot = super::cli::get_storage().await?; + let ostree = sysroot.get_ostree()?; + let (_, _, host) = crate::status::get_status_require_booted(ostree)?; + + let facts = RhsmFacts::from(host.status); + root.atomic_replace_with(FACTS_PATH, |w| { + serde_json::to_writer_pretty(w, &facts)?; + anyhow::Ok(()) + }) + .with_context(|| format!("Writing {FACTS_PATH}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::spec::Host; + + #[test] + fn test_rhsm_facts_from_host() { + let host: Host = serde_yaml::from_str(include_str!("fixtures/spec-staged-booted.yaml")) + .expect("No spec found"); + let facts = RhsmFacts::from(host.status); + + assert_eq!( + facts, + RhsmFacts { + booted_image: "quay.io/example/someimage:latest".into(), + booted_version: "nightly".into(), + booted_digest: + "sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34".into(), + staged_image: "quay.io/example/someimage:latest".into(), + staged_version: "nightly".into(), + staged_digest: + "sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566".into(), + ..Default::default() + } + ); + } +} diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs new file mode 100644 index 000000000..83fd0b3ab --- /dev/null +++ b/crates/lib/src/spec.rs @@ -0,0 +1,660 @@ +//! The definition for host system state. + +use std::fmt::Display; +use std::str::FromStr; + +use anyhow::Result; +use ostree_ext::container::Transport; +use ostree_ext::oci_spec::distribution::Reference; +use ostree_ext::oci_spec::image::Digest; +use ostree_ext::{container::OstreeImageReference, oci_spec}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::bootc_composefs::boot::BootType; +use crate::{k8sapitypes, status::Slot}; + +const API_VERSION: &str = "org.containers.bootc/v1"; +const KIND: &str = "BootcHost"; +/// The default object name we use; there's only one. +pub(crate) const OBJECT_NAME: &str = "host"; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +/// The core host definition +pub struct Host { + /// Metadata + #[serde(flatten)] + pub resource: k8sapitypes::Resource, + /// The spec + #[serde(default)] + pub spec: HostSpec, + /// The status + #[serde(default)] + pub status: HostStatus, +} + +/// Configuration for system boot ordering. + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum BootOrder { + /// The staged or booted deployment will be booted next + #[default] + Default, + /// The rollback deployment will be booted next + Rollback, +} + +#[derive( + clap::ValueEnum, Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, Default, +)] +#[serde(rename_all = "camelCase")] +/// The container storage backend +pub enum Store { + /// Use the ostree-container storage backend. + #[default] + #[value(alias = "ostreecontainer")] // default is kebab-case + OstreeContainer, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +/// The host specification +pub struct HostSpec { + /// The host image + pub image: Option, + /// If set, and there is a rollback deployment, it will be set for the next boot. + #[serde(default)] + pub boot_order: BootOrder, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +/// An image signature +#[serde(rename_all = "camelCase")] +pub enum ImageSignature { + /// Fetches will use the named ostree remote for signature verification of the ostree commit. + OstreeRemote(String), + /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy. + ContainerPolicy, + /// No signature verification will be performed + Insecure, +} + +/// A container image reference with attached transport and signature verification +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImageReference { + /// The container image reference + pub image: String, + /// The container image transport + pub transport: String, + /// Signature verification type + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +/// If the reference is in :tag@digest form, strip the tag. +fn canonicalize_reference(reference: Reference) -> Option { + // No tag? Just pass through. + reference.tag()?; + + // No digest? Also pass through. + let digest = reference.digest()?; + // Otherwise, replace with the digest + Some(reference.clone_with_digest(digest.to_owned())) +} + +impl ImageReference { + /// Returns a canonicalized version of this image reference, preferring the digest over the tag if both are present. + pub fn canonicalize(self) -> Result { + // TODO maintain a proper transport enum in the spec here + let transport = Transport::try_from(self.transport.as_str())?; + match transport { + Transport::Registry => { + let reference: oci_spec::distribution::Reference = self.image.parse()?; + + // Check if the image reference needs canonicicalization + let Some(reference) = canonicalize_reference(reference) else { + return Ok(self); + }; + + let r = ImageReference { + image: reference.to_string(), + transport: self.transport.clone(), + signature: self.signature.clone(), + }; + Ok(r) + } + _ => { + // For other transports, we don't do any canonicalization + Ok(self) + } + } + } +} + +/// The status of the booted image +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImageStatus { + /// The currently booted image + pub image: ImageReference, + /// The version string, if any + pub version: Option, + /// The build timestamp, if any + pub timestamp: Option>, + /// The digest of the fetched image (e.g. sha256:a0...); + pub image_digest: String, + /// The hardware architecture of this image + pub architecture: String, +} + +/// A bootable entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BootEntryOstree { + /// The name of the storage for /etc and /var content + pub stateroot: String, + /// The ostree commit checksum + pub checksum: String, + /// The deployment serial + pub deploy_serial: u32, +} + +/// Bootloader type to determine whether system was booted via Grub or Systemd +#[derive( + clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, +)] +pub enum Bootloader { + /// Use Grub as the booloader + #[default] + Grub, + /// Use SystemdBoot as the bootloader + Systemd, +} + +impl Display for Bootloader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let string = match self { + Bootloader::Grub => "grub", + Bootloader::Systemd => "systemd", + }; + + write!(f, "{}", string) + } +} + +impl FromStr for Bootloader { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "grub" => Ok(Self::Grub), + "systemd" => Ok(Self::Systemd), + unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")), + } + } +} + +/// A bootable entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BootEntryComposefs { + /// The erofs verity + pub verity: String, + /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry + pub boot_type: BootType, + /// Whether we boot using systemd or grub + pub bootloader: Bootloader, + /// The sha256sum of vmlinuz + initrd + /// Only `Some` for Type1 boot entries + pub boot_digest: Option, +} + +/// A bootable entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BootEntry { + /// The image reference + pub image: Option, + /// The last fetched cached update metadata + pub cached_update: Option, + /// Whether this boot entry is not compatible (has origin changes bootc does not understand) + pub incompatible: bool, + /// Whether this entry will be subject to garbage collection + pub pinned: bool, + /// This is true if (relative to the booted system) this is a possible target for a soft reboot + #[serde(default)] + pub soft_reboot_capable: bool, + /// The container storage backend + #[serde(default)] + pub store: Option, + /// If this boot entry is ostree based, the corresponding state + pub ostree: Option, + /// If this boot entry is composefs based, the corresponding state + pub composefs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +/// The detected type of running system. Note that this is not exhaustive +/// and new variants may be added in the future. +pub enum HostType { + /// The current system is deployed in a bootc compatible way. + BootcHost, +} + +/// The status of the host system +#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HostStatus { + /// The staged image for the next boot + pub staged: Option, + /// The booted image; this will be unset if the host is not bootc compatible. + pub booted: Option, + /// The previously booted image + pub rollback: Option, + /// Other deployments (i.e. pinned) + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub other_deployments: Vec, + /// Set to true if the rollback entry is queued for the next boot. + #[serde(default)] + pub rollback_queued: bool, + + /// The detected type of system + #[serde(rename = "type")] + pub ty: Option, +} + +pub(crate) struct DeploymentEntry<'a> { + pub(crate) ty: Option, + pub(crate) deployment: &'a BootEntryComposefs, + pub(crate) pinned: bool, +} + +impl Host { + /// Create a new host + pub fn new(spec: HostSpec) -> Self { + let metadata = k8sapitypes::ObjectMeta { + name: Some(OBJECT_NAME.to_owned()), + ..Default::default() + }; + Self { + resource: k8sapitypes::Resource { + api_version: API_VERSION.to_owned(), + kind: KIND.to_owned(), + metadata, + }, + spec, + status: Default::default(), + } + } + + /// Filter out the requested slot + pub fn filter_to_slot(&mut self, slot: Slot) { + match slot { + Slot::Staged => { + self.status.booted = None; + self.status.rollback = None; + } + Slot::Booted => { + self.status.staged = None; + self.status.rollback = None; + } + Slot::Rollback => { + self.status.staged = None; + self.status.booted = None; + } + } + } + + pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> { + let cfs = self + .status + .booted + .as_ref() + .ok_or(anyhow::anyhow!("Could not find booted deployment"))? + .require_composefs()?; + + Ok(cfs) + } + + /// Returns all composefs deployments in a list + #[fn_error_context::context("Getting all composefs deployments")] + pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result>> { + let mut all_deps = vec![]; + + let booted = self.require_composefs_booted()?; + all_deps.push(DeploymentEntry { + ty: Some(Slot::Booted), + deployment: booted, + pinned: false, + }); + + if let Some(staged) = &self.status.staged { + all_deps.push(DeploymentEntry { + ty: Some(Slot::Staged), + deployment: staged.require_composefs()?, + pinned: false, + }); + } + + if let Some(rollback) = &self.status.rollback { + all_deps.push(DeploymentEntry { + ty: Some(Slot::Rollback), + deployment: rollback.require_composefs()?, + pinned: false, + }); + } + + for pinned in &self.status.other_deployments { + all_deps.push(DeploymentEntry { + ty: None, + deployment: pinned.require_composefs()?, + pinned: true, + }); + } + + Ok(all_deps) + } +} + +impl Default for Host { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl HostSpec { + /// Validate a spec state transition; some changes cannot be made simultaneously, + /// such as fetching a new image and doing a rollback. + pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> { + let rollback = self.boot_order != new.boot_order; + let image_change = self.image != new.image; + if rollback && image_change { + anyhow::bail!("Invalid state transition: rollback and image change"); + } + Ok(()) + } +} + +impl BootOrder { + pub(crate) fn swap(&self) -> Self { + match self { + BootOrder::Default => BootOrder::Rollback, + BootOrder::Rollback => BootOrder::Default, + } + } +} + +impl Display for ImageReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // For the default of fetching from a remote registry, just output the image name + if f.alternate() && self.signature.is_none() && self.transport == "registry" { + self.image.fmt(f) + } else { + let ostree_imgref = OstreeImageReference::from(self.clone()); + ostree_imgref.fmt(f) + } + } +} + +impl ImageStatus { + pub(crate) fn digest(&self) -> anyhow::Result { + use std::str::FromStr; + Ok(Digest::from_str(&self.image_digest)?) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn test_canonicalize_reference() { + // expand this + let passthrough = [ + ("quay.io/example/someimage:latest"), + ("quay.io/example/someimage"), + ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"), + ]; + let mapped = [ + ( + "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2", + "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2", + ), + ( + "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2", + "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2", + ), + ]; + for &v in passthrough.iter() { + let reference = Reference::from_str(v).unwrap(); + assert!(reference.tag().is_none() || reference.digest().is_none()); + assert!(canonicalize_reference(reference).is_none()); + } + for &(initial, expected) in mapped.iter() { + let reference = Reference::from_str(initial).unwrap(); + assert!(reference.tag().is_some()); + assert!(reference.digest().is_some()); + let canonicalized = canonicalize_reference(reference).unwrap(); + assert_eq!(canonicalized.to_string(), expected); + } + } + + #[test] + fn test_image_reference_canonicalize() { + let sample_digest = + "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"; + + let test_cases = [ + // When both a tag and digest are present, the digest should be used + ( + format!("quay.io/example/someimage:latest@{sample_digest}"), + format!("quay.io/example/someimage@{sample_digest}"), + "registry", + ), + // When only a digest is present, it should be used + ( + format!("quay.io/example/someimage@{sample_digest}"), + format!("quay.io/example/someimage@{sample_digest}"), + "registry", + ), + // When only a tag is present, it should be preserved + ( + "quay.io/example/someimage:latest".to_string(), + "quay.io/example/someimage:latest".to_string(), + "registry", + ), + // When no tag or digest is present, preserve the original image name + ( + "quay.io/example/someimage".to_string(), + "quay.io/example/someimage".to_string(), + "registry", + ), + // When used with a local image (i.e. from containers-storage), the functionality should + // be the same as previous cases + ( + "localhost/someimage:latest".to_string(), + "localhost/someimage:latest".to_string(), + "registry", + ), + ( + format!("localhost/someimage:latest@{sample_digest}"), + format!("localhost/someimage@{sample_digest}"), + "registry", + ), + // Other cases are not canonicalized + ( + format!("quay.io/example/someimage:latest@{sample_digest}"), + format!("quay.io/example/someimage:latest@{sample_digest}"), + "containers-storage", + ), + ( + "/path/to/dir:latest".to_string(), + "/path/to/dir:latest".to_string(), + "oci", + ), + ( + "/tmp/repo".to_string(), + "/tmp/repo".to_string(), + "oci-archive", + ), + ( + "/tmp/image-dir".to_string(), + "/tmp/image-dir".to_string(), + "dir", + ), + ]; + + for (initial, expected, transport) in test_cases { + let imgref = ImageReference { + image: initial.to_string(), + transport: transport.to_string(), + signature: None, + }; + + let canonicalized = imgref.canonicalize(); + if let Err(e) = canonicalized { + panic!("Failed to canonicalize {initial} with transport {transport}: {e}"); + } + let canonicalized = canonicalized.unwrap(); + assert_eq!( + canonicalized.image, expected, + "Mismatch for transport {transport}" + ); + assert_eq!(canonicalized.transport, transport); + assert_eq!(canonicalized.signature, None); + } + } + + #[test] + fn test_unimplemented_oci_tagged_digested() { + let imgref = ImageReference { + image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(), + transport: "oci".to_string(), + signature: None + }; + let canonicalized = imgref.clone().canonicalize().unwrap(); + // TODO For now this is known to incorrectly pass + assert_eq!(imgref, canonicalized); + } + + #[test] + fn test_parse_spec_v1_null() { + const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json"); + let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap(); + assert_eq!(host.resource.api_version, "org.containers.bootc/v1"); + } + + #[test] + fn test_parse_spec_v1a1_orig() { + const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml"); + let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap(); + assert_eq!( + host.spec.image.as_ref().unwrap().image.as_str(), + "quay.io/example/someimage:latest" + ); + } + + #[test] + fn test_parse_spec_v1a1() { + const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml"); + let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap(); + assert_eq!( + host.spec.image.as_ref().unwrap().image.as_str(), + "quay.io/otherexample/otherimage:latest" + ); + assert_eq!(host.spec.image.as_ref().unwrap().signature, None); + } + + #[test] + fn test_parse_ostreeremote() { + const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml"); + let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap(); + assert_eq!( + host.spec.image.as_ref().unwrap().signature, + Some(ImageSignature::OstreeRemote("fedora".into())) + ); + } + + #[test] + fn test_display_imgref() { + let src = "ostree-unverified-registry:quay.io/example/foo:sometag"; + let s = OstreeImageReference::from_str(src).unwrap(); + let s = ImageReference::from(s); + let displayed = format!("{s}"); + assert_eq!(displayed.as_str(), src); + // Alternative display should be short form + assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag"); + + let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag"; + let s = OstreeImageReference::from_str(src).unwrap(); + let s = ImageReference::from(s); + let displayed = format!("{s}"); + assert_eq!(displayed.as_str(), src); + assert_eq!(format!("{s:#}"), src); + } + + #[test] + fn test_store_from_str() { + use clap::ValueEnum; + + // should be case-insensitive, kebab-case optional + assert!(Store::from_str("Ostree-Container", true).is_ok()); + assert!(Store::from_str("OstrEeContAiner", true).is_ok()); + assert!(Store::from_str("invalid", true).is_err()); + } + + #[test] + fn test_host_filter_to_slot() { + fn create_host() -> Host { + let mut host = Host::default(); + host.status.staged = Some(default_boot_entry()); + host.status.booted = Some(default_boot_entry()); + host.status.rollback = Some(default_boot_entry()); + host + } + + fn default_boot_entry() -> BootEntry { + BootEntry { + image: None, + cached_update: None, + incompatible: false, + soft_reboot_capable: false, + pinned: false, + store: None, + ostree: None, + composefs: None, + } + } + + fn assert_host_state( + host: &Host, + staged: Option, + booted: Option, + rollback: Option, + ) { + assert_eq!(host.status.staged, staged); + assert_eq!(host.status.booted, booted); + assert_eq!(host.status.rollback, rollback); + } + + let mut host = create_host(); + host.filter_to_slot(Slot::Staged); + assert_host_state(&host, Some(default_boot_entry()), None, None); + + let mut host = create_host(); + host.filter_to_slot(Slot::Booted); + assert_host_state(&host, None, Some(default_boot_entry()), None); + + let mut host = create_host(); + host.filter_to_slot(Slot::Rollback); + assert_host_state(&host, None, None, Some(default_boot_entry())); + } +} diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs new file mode 100644 index 000000000..f35330ef4 --- /dev/null +++ b/crates/lib/src/status.rs @@ -0,0 +1,944 @@ +use std::borrow::Cow; +use std::collections::VecDeque; +use std::io::IsTerminal; +use std::io::Read; +use std::io::Write; + +use anyhow::{Context, Result}; +use canon_json::CanonJsonSerialize; +use fn_error_context::context; +use ostree::glib; +use ostree_container::OstreeImageReference; +use ostree_ext::container as ostree_container; +use ostree_ext::keyfileext::KeyFileExt; +use ostree_ext::oci_spec; +use ostree_ext::oci_spec::image::Digest; +use ostree_ext::oci_spec::image::ImageConfiguration; +use ostree_ext::sysroot::SysrootLock; + +use ostree_ext::ostree; + +use crate::cli::OutputFormat; +use crate::spec::BootEntryComposefs; +use crate::spec::ImageStatus; +use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; +use crate::spec::{ImageReference, ImageSignature}; +use crate::store::BootedStorage; +use crate::store::BootedStorageKind; +use crate::store::CachedImageStatus; + +impl From for ImageSignature { + fn from(sig: ostree_container::SignatureSource) -> Self { + use ostree_container::SignatureSource; + match sig { + SignatureSource::OstreeRemote(r) => Self::OstreeRemote(r), + SignatureSource::ContainerPolicy => Self::ContainerPolicy, + SignatureSource::ContainerPolicyAllowInsecure => Self::Insecure, + } + } +} + +impl From for ostree_container::SignatureSource { + fn from(sig: ImageSignature) -> Self { + use ostree_container::SignatureSource; + match sig { + ImageSignature::OstreeRemote(r) => SignatureSource::OstreeRemote(r), + ImageSignature::ContainerPolicy => Self::ContainerPolicy, + ImageSignature::Insecure => Self::ContainerPolicyAllowInsecure, + } + } +} + +/// Fixme lower serializability into ostree-ext +fn transport_to_string(transport: ostree_container::Transport) -> String { + match transport { + // Canonicalize to registry for our own use + ostree_container::Transport::Registry => "registry".to_string(), + o => { + let mut s = o.to_string(); + s.truncate(s.rfind(':').unwrap()); + s + } + } +} + +impl From for ImageReference { + fn from(imgref: OstreeImageReference) -> Self { + let signature = match imgref.sigverify { + ostree_container::SignatureSource::ContainerPolicyAllowInsecure => None, + v => Some(v.into()), + }; + Self { + signature, + transport: transport_to_string(imgref.imgref.transport), + image: imgref.imgref.name, + } + } +} + +impl From for OstreeImageReference { + fn from(img: ImageReference) -> Self { + let sigverify = match img.signature { + Some(v) => v.into(), + None => ostree_container::SignatureSource::ContainerPolicyAllowInsecure, + }; + Self { + sigverify, + imgref: ostree_container::ImageReference { + // SAFETY: We validated the schema in kube-rs + transport: img.transport.as_str().try_into().unwrap(), + name: img.image, + }, + } + } +} + +/// Check if SELinux policies are compatible between booted and target deployments. +/// Returns false if SELinux is enabled and the policies differ or have mismatched presence. +fn check_selinux_policy_compatible( + sysroot: &SysrootLock, + booted_deployment: &ostree::Deployment, + target_deployment: &ostree::Deployment, +) -> Result { + // Only check if SELinux is enabled + if !crate::lsm::selinux_enabled()? { + return Ok(true); + } + + let booted_fd = crate::utils::deployment_fd(sysroot, booted_deployment) + .context("Failed to get file descriptor for booted deployment")?; + let booted_policy = crate::lsm::new_sepolicy_at(&booted_fd) + .context("Failed to load SELinux policy from booted deployment")?; + let target_fd = crate::utils::deployment_fd(sysroot, target_deployment) + .context("Failed to get file descriptor for target deployment")?; + let target_policy = crate::lsm::new_sepolicy_at(&target_fd) + .context("Failed to load SELinux policy from target deployment")?; + + let booted_csum = booted_policy.and_then(|p| p.csum()); + let target_csum = target_policy.and_then(|p| p.csum()); + + match (booted_csum, target_csum) { + (None, None) => Ok(true), // Both absent, compatible + (Some(_), None) | (None, Some(_)) => { + // Incompatible: one has policy, other doesn't + Ok(false) + } + (Some(booted_csum), Some(target_csum)) => { + // Both have policies, checksums must match + Ok(booted_csum == target_csum) + } + } +} + +/// Check if a deployment has soft reboot capability +// TODO: Lower SELinux policy check into ostree's deployment_can_soft_reboot API +fn has_soft_reboot_capability(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> bool { + if !ostree_ext::systemd_has_soft_reboot() { + return false; + } + + // When the ostree version is < 2025.7 and the deployment is + // missing the ostree= karg (happens during a factory reset), + // there is a bug that causes deployment_can_soft_reboot to crash. + // So in this case default to disabling soft reboot. + let has_ostree_karg = deployment + .bootconfig() + .and_then(|bootcfg| bootcfg.get("options")) + .map(|options| options.contains("ostree=")) + .unwrap_or(false); + + if !ostree::check_version(2025, 7) && !has_ostree_karg { + return false; + } + + if !sysroot.deployment_can_soft_reboot(deployment) { + return false; + } + + // Check SELinux policy compatibility with booted deployment + // Block soft reboot if SELinux policies differ, as policy is not reloaded across soft reboots + if let Some(booted_deployment) = sysroot.booted_deployment() { + // deployment_fd should not fail for valid deployments + if !check_selinux_policy_compatible(sysroot, &booted_deployment, deployment) + .expect("deployment_fd should not fail for valid deployments") + { + return false; + } + } + + true +} + +/// Parse an ostree origin file (a keyfile) and extract the targeted +/// container image reference. +fn get_image_origin(origin: &glib::KeyFile) -> Result> { + origin + .optional_string("origin", ostree_container::deploy::ORIGIN_CONTAINER) + .context("Failed to load container image from origin")? + .map(|v| ostree_container::OstreeImageReference::try_from(v.as_str())) + .transpose() +} + +pub(crate) struct Deployments { + pub(crate) staged: Option, + pub(crate) rollback: Option, + #[allow(dead_code)] + pub(crate) other: VecDeque, +} + +pub(crate) fn labels_of_config( + config: &oci_spec::image::ImageConfiguration, +) -> Option<&std::collections::HashMap> { + config.config().as_ref().and_then(|c| c.labels().as_ref()) +} + +/// Convert between a subset of ostree-ext metadata and the exposed spec API. +fn create_imagestatus( + image: ImageReference, + manifest_digest: &Digest, + config: &ImageConfiguration, +) -> ImageStatus { + let labels = labels_of_config(config); + let timestamp = labels + .and_then(|l| { + l.get(oci_spec::image::ANNOTATION_CREATED) + .map(|s| s.as_str()) + }) + .or_else(|| config.created().as_deref()) + .and_then(bootc_utils::try_deserialize_timestamp); + + let version = ostree_container::version_for_config(config).map(ToOwned::to_owned); + let architecture = config.architecture().to_string(); + ImageStatus { + image, + version, + timestamp, + image_digest: manifest_digest.to_string(), + architecture, + } +} + +fn imagestatus( + sysroot: &SysrootLock, + deployment: &ostree::Deployment, + image: ostree_container::OstreeImageReference, +) -> Result { + let repo = &sysroot.repo(); + let imgstate = ostree_container::store::query_image_commit(repo, &deployment.csum())?; + let image = ImageReference::from(image); + let cached = imgstate + .cached_update + .map(|cached| create_imagestatus(image.clone(), &cached.manifest_digest, &cached.config)); + let imagestatus = create_imagestatus(image, &imgstate.manifest_digest, &imgstate.configuration); + + Ok(CachedImageStatus { + image: Some(imagestatus), + cached_update: cached, + }) +} + +/// Given an OSTree deployment, parse out metadata into our spec. +#[context("Reading deployment metadata")] +fn boot_entry_from_deployment( + sysroot: &SysrootLock, + deployment: &ostree::Deployment, +) -> Result { + let ( + CachedImageStatus { + image, + cached_update, + }, + incompatible, + ) = if let Some(origin) = deployment.origin().as_ref() { + let incompatible = crate::utils::origin_has_rpmostree_stuff(origin); + let cached_imagestatus = if incompatible { + // If there are local changes, we can't represent it as a bootc compatible image. + CachedImageStatus::default() + } else if let Some(image) = get_image_origin(origin)? { + imagestatus(sysroot, deployment, image)? + } else { + // The deployment isn't using a container image + CachedImageStatus::default() + }; + (cached_imagestatus, incompatible) + } else { + // The deployment has no origin at all (this generally shouldn't happen) + (CachedImageStatus::default(), false) + }; + + let soft_reboot_capable = has_soft_reboot_capability(sysroot, deployment); + let store = Some(crate::spec::Store::OstreeContainer); + let r = BootEntry { + image, + cached_update, + incompatible, + soft_reboot_capable, + store, + pinned: deployment.is_pinned(), + ostree: Some(crate::spec::BootEntryOstree { + checksum: deployment.csum().into(), + // SAFETY: The deployserial is really unsigned + deploy_serial: deployment.deployserial().try_into().unwrap(), + stateroot: deployment.stateroot().into(), + }), + composefs: None, + }; + Ok(r) +} + +impl BootEntry { + /// Given a boot entry, find its underlying ostree container image + pub(crate) fn query_image( + &self, + repo: &ostree::Repo, + ) -> Result>> { + if self.image.is_none() { + return Ok(None); + } + if let Some(checksum) = self.ostree.as_ref().map(|c| c.checksum.as_str()) { + ostree_container::store::query_image_commit(repo, checksum).map(Some) + } else { + Ok(None) + } + } + + pub(crate) fn require_composefs(&self) -> Result<&BootEntryComposefs> { + self.composefs.as_ref().ok_or(anyhow::anyhow!( + "BootEntry is not a composefs native boot entry" + )) + } +} + +/// A variant of [`get_status`] that requires a booted deployment. +pub(crate) fn get_status_require_booted( + sysroot: &SysrootLock, +) -> Result<(crate::store::BootedOstree<'_>, Deployments, Host)> { + let booted_deployment = sysroot.require_booted_deployment()?; + let booted_ostree = crate::store::BootedOstree { + sysroot, + deployment: booted_deployment, + }; + let (deployments, host) = get_status(&booted_ostree)?; + Ok((booted_ostree, deployments, host)) +} + +/// Gather the ostree deployment objects, but also extract metadata from them into +/// a more native Rust structure. +#[context("Computing status")] +pub(crate) fn get_status( + booted_ostree: &crate::store::BootedOstree<'_>, +) -> Result<(Deployments, Host)> { + let sysroot = booted_ostree.sysroot; + let booted_deployment = Some(&booted_ostree.deployment); + let stateroot = booted_deployment.as_ref().map(|d| d.osname()); + let (mut related_deployments, other_deployments) = sysroot + .deployments() + .into_iter() + .partition::, _>(|d| Some(d.osname()) == stateroot); + let staged = related_deployments + .iter() + .position(|d| d.is_staged()) + .map(|i| related_deployments.remove(i).unwrap()); + tracing::debug!("Staged: {staged:?}"); + // Filter out the booted, the caller already found that + if let Some(booted) = booted_deployment.as_ref() { + related_deployments.retain(|f| !f.equal(booted)); + } + let rollback = related_deployments.pop_front(); + let rollback_queued = match (booted_deployment.as_ref(), rollback.as_ref()) { + (Some(booted), Some(rollback)) => rollback.index() < booted.index(), + _ => false, + }; + let boot_order = if rollback_queued { + BootOrder::Rollback + } else { + BootOrder::Default + }; + tracing::debug!("Rollback queued={rollback_queued:?}"); + let other = { + related_deployments.extend(other_deployments); + related_deployments + }; + let deployments = Deployments { + staged, + rollback, + other, + }; + + let staged = deployments + .staged + .as_ref() + .map(|d| boot_entry_from_deployment(sysroot, d)) + .transpose() + .context("Staged deployment")?; + let booted = booted_deployment + .as_ref() + .map(|d| boot_entry_from_deployment(sysroot, d)) + .transpose() + .context("Booted deployment")?; + let rollback = deployments + .rollback + .as_ref() + .map(|d| boot_entry_from_deployment(sysroot, d)) + .transpose() + .context("Rollback deployment")?; + let other_deployments = deployments + .other + .iter() + .map(|d| boot_entry_from_deployment(sysroot, d)) + .collect::>>() + .context("Other deployments")?; + let spec = staged + .as_ref() + .or(booted.as_ref()) + .and_then(|entry| entry.image.as_ref()) + .map(|img| HostSpec { + image: Some(img.image.clone()), + boot_order, + }) + .unwrap_or_default(); + + let ty = if booted + .as_ref() + .map(|b| b.image.is_some()) + .unwrap_or_default() + { + // We're only of type BootcHost if we booted via container image + Some(HostType::BootcHost) + } else { + None + }; + + let mut host = Host::new(spec); + host.status = HostStatus { + staged, + booted, + rollback, + other_deployments, + rollback_queued, + ty, + }; + Ok((deployments, host)) +} + +async fn get_host() -> Result { + let env = crate::store::Environment::detect()?; + if env.needs_mount_namespace() { + crate::cli::prepare_for_write()?; + } + + let Some(storage) = BootedStorage::new(env).await? else { + // If we're not booted, then return a default. + return Ok(Host::default()); + }; + + let host = match storage.kind() { + Ok(kind) => match kind { + BootedStorageKind::Ostree(booted_ostree) => { + let (_deployments, host) = get_status(&booted_ostree)?; + host + } + BootedStorageKind::Composefs(booted_cfs) => { + crate::bootc_composefs::status::get_composefs_status(&storage, &booted_cfs).await? + } + }, + Err(_) => { + // If determining storage kind fails (e.g., no booted deployment), + // return a default host indicating the system is not deployed via bootc + Host::default() + } + }; + + Ok(host) +} + +/// Implementation of the `bootc status` CLI command. +#[context("Status")] +pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { + match opts.format_version.unwrap_or_default() { + // For historical reasons, both 0 and 1 mean "v1". + 0 | 1 => {} + o => anyhow::bail!("Unsupported format version: {o}"), + }; + let mut host = get_host().await?; + + // We could support querying the staged or rollback deployments + // here too, but it's not a common use case at the moment. + if opts.booted { + host.filter_to_slot(Slot::Booted); + } + + // If we're in JSON mode, then convert the ostree data into Rust-native + // structures that can be serialized. + // Filter to just the serializable status structures. + let out = std::io::stdout(); + let mut out = out.lock(); + let legacy_opt = if opts.json { + OutputFormat::Json + } else if std::io::stdout().is_terminal() { + OutputFormat::HumanReadable + } else { + OutputFormat::Yaml + }; + let format = opts.format.unwrap_or(legacy_opt); + match format { + OutputFormat::Json => host + .to_canon_json_writer(&mut out) + .map_err(anyhow::Error::new), + OutputFormat::Yaml => serde_yaml::to_writer(&mut out, &host).map_err(anyhow::Error::new), + OutputFormat::HumanReadable => human_readable_output(&mut out, &host, opts.verbose), + } + .context("Writing to stdout")?; + + Ok(()) +} + +#[derive(Debug)] +pub enum Slot { + Staged, + Booted, + Rollback, +} + +impl std::fmt::Display for Slot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Slot::Staged => "staged", + Slot::Booted => "booted", + Slot::Rollback => "rollback", + }; + f.write_str(s) + } +} + +/// Output a row title, prefixed by spaces +fn write_row_name(mut out: impl Write, s: &str, prefix_len: usize) -> Result<()> { + let n = prefix_len.saturating_sub(s.chars().count()); + let mut spaces = std::io::repeat(b' ').take(n as u64); + std::io::copy(&mut spaces, &mut out)?; + write!(out, "{s}: ")?; + Ok(()) +} + +/// Helper function to render verbose ostree information +fn render_verbose_ostree_info( + mut out: impl Write, + ostree: &crate::spec::BootEntryOstree, + slot: Option, + prefix_len: usize, +) -> Result<()> { + write_row_name(&mut out, "StateRoot", prefix_len)?; + writeln!(out, "{}", ostree.stateroot)?; + + // Show deployment serial (similar to Index in rpm-ostree) + write_row_name(&mut out, "Deploy serial", prefix_len)?; + writeln!(out, "{}", ostree.deploy_serial)?; + + // Show if this is staged + let is_staged = matches!(slot, Some(Slot::Staged)); + write_row_name(&mut out, "Staged", prefix_len)?; + writeln!(out, "{}", if is_staged { "yes" } else { "no" })?; + + Ok(()) +} + +/// Helper function to render if soft-reboot capable +fn write_soft_reboot( + mut out: impl Write, + entry: &crate::spec::BootEntry, + prefix_len: usize, +) -> Result<()> { + // Show soft-reboot capability + write_row_name(&mut out, "Soft-reboot", prefix_len)?; + writeln!( + out, + "{}", + if entry.soft_reboot_capable { + "yes" + } else { + "no" + } + )?; + + Ok(()) +} + +/// Write the data for a container image based status. +fn human_render_slot( + mut out: impl Write, + slot: Option, + entry: &crate::spec::BootEntry, + image: &crate::spec::ImageStatus, + verbose: bool, +) -> Result<()> { + let transport = &image.image.transport; + let imagename = &image.image.image; + // Registry is the default, so don't show that + let imageref = if transport == "registry" { + Cow::Borrowed(imagename) + } else { + // But for non-registry we include the transport + Cow::Owned(format!("{transport}:{imagename}")) + }; + let prefix = match slot { + Some(Slot::Staged) => " Staged image".into(), + Some(Slot::Booted) => format!("{} Booted image", crate::glyph::Glyph::BlackCircle), + Some(Slot::Rollback) => " Rollback image".into(), + _ => " Other image".into(), + }; + let prefix_len = prefix.chars().count(); + writeln!(out, "{prefix}: {imageref}")?; + + let arch = image.architecture.as_str(); + write_row_name(&mut out, "Digest", prefix_len)?; + let digest = &image.image_digest; + writeln!(out, "{digest} ({arch})")?; + + // Write the EROFS verity if present + if let Some(composefs) = &entry.composefs { + write_row_name(&mut out, "Verity", prefix_len)?; + writeln!(out, "{}", composefs.verity)?; + } + + // Format the timestamp without nanoseconds since those are just irrelevant noise for human + // consumption - that time scale should basically never matter for container builds. + let timestamp = image + .timestamp + .as_ref() + // This format is the same as RFC3339, just without nanos. + .map(|t| t.to_utc().format("%Y-%m-%dT%H:%M:%SZ")); + // If we have a version, combine with timestamp + if let Some(version) = image.version.as_deref() { + write_row_name(&mut out, "Version", prefix_len)?; + if let Some(timestamp) = timestamp { + writeln!(out, "{version} ({timestamp})")?; + } else { + writeln!(out, "{version}")?; + } + } else if let Some(timestamp) = timestamp { + // Otherwise just output timestamp + write_row_name(&mut out, "Timestamp", prefix_len)?; + writeln!(out, "{timestamp}")?; + } + + if entry.pinned { + write_row_name(&mut out, "Pinned", prefix_len)?; + writeln!(out, "yes")?; + } + + if verbose { + // Show additional information in verbose mode similar to rpm-ostree + if let Some(ostree) = &entry.ostree { + render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?; + + // Show the commit (equivalent to Base Commit in rpm-ostree) + write_row_name(&mut out, "Commit", prefix_len)?; + writeln!(out, "{}", ostree.checksum)?; + } + + // Show signature information if available + if let Some(signature) = &image.image.signature { + write_row_name(&mut out, "Signature", prefix_len)?; + match signature { + crate::spec::ImageSignature::OstreeRemote(remote) => { + writeln!(out, "ostree-remote:{remote}")?; + } + crate::spec::ImageSignature::ContainerPolicy => { + writeln!(out, "container-policy")?; + } + crate::spec::ImageSignature::Insecure => { + writeln!(out, "insecure")?; + } + } + } + + // Show soft-reboot capability + write_soft_reboot(&mut out, entry, prefix_len)?; + } + + tracing::debug!("pinned={}", entry.pinned); + + Ok(()) +} + +/// Output a rendering of a non-container boot entry. +fn human_render_slot_ostree( + mut out: impl Write, + slot: Option, + entry: &crate::spec::BootEntry, + ostree_commit: &str, + verbose: bool, +) -> Result<()> { + // TODO consider rendering more ostree stuff here like rpm-ostree status does + let prefix = match slot { + Some(Slot::Staged) => " Staged ostree".into(), + Some(Slot::Booted) => format!("{} Booted ostree", crate::glyph::Glyph::BlackCircle), + Some(Slot::Rollback) => " Rollback ostree".into(), + _ => " Other ostree".into(), + }; + let prefix_len = prefix.len(); + writeln!(out, "{prefix}")?; + write_row_name(&mut out, "Commit", prefix_len)?; + writeln!(out, "{ostree_commit}")?; + + if entry.pinned { + write_row_name(&mut out, "Pinned", prefix_len)?; + writeln!(out, "yes")?; + } + + if verbose { + // Show additional information in verbose mode similar to rpm-ostree + if let Some(ostree) = &entry.ostree { + render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?; + } + + // Show soft-reboot capability + write_soft_reboot(&mut out, entry, prefix_len)?; + } + + tracing::debug!("pinned={}", entry.pinned); + Ok(()) +} + +/// Output a rendering of a non-container composefs boot entry. +fn human_render_slot_composefs( + mut out: impl Write, + slot: Slot, + entry: &crate::spec::BootEntry, + erofs_verity: &str, +) -> Result<()> { + // TODO consider rendering more ostree stuff here like rpm-ostree status does + let prefix = match slot { + Slot::Staged => " Staged composefs".into(), + Slot::Booted => format!("{} Booted composefs", crate::glyph::Glyph::BlackCircle), + Slot::Rollback => " Rollback composefs".into(), + }; + let prefix_len = prefix.len(); + writeln!(out, "{prefix}")?; + write_row_name(&mut out, "Commit", prefix_len)?; + writeln!(out, "{erofs_verity}")?; + tracing::debug!("pinned={}", entry.pinned); + Ok(()) +} + +fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> { + let mut first = true; + for (slot_name, status) in [ + (Slot::Staged, &host.status.staged), + (Slot::Booted, &host.status.booted), + (Slot::Rollback, &host.status.rollback), + ] { + if let Some(host_status) = status { + if first { + first = false; + } else { + writeln!(out)?; + } + + if let Some(image) = &host_status.image { + human_render_slot(&mut out, Some(slot_name), host_status, image, verbose)?; + } else if let Some(ostree) = host_status.ostree.as_ref() { + human_render_slot_ostree( + &mut out, + Some(slot_name), + host_status, + &ostree.checksum, + verbose, + )?; + } else if let Some(composefs) = &host_status.composefs { + human_render_slot_composefs(&mut out, slot_name, host_status, &composefs.verity)?; + } else { + writeln!(out, "Current {slot_name} state is unknown")?; + } + } + } + + if !host.status.other_deployments.is_empty() { + for entry in &host.status.other_deployments { + writeln!(out)?; + + if let Some(image) = &entry.image { + human_render_slot(&mut out, None, entry, image, verbose)?; + } else if let Some(ostree) = entry.ostree.as_ref() { + human_render_slot_ostree(&mut out, None, entry, &ostree.checksum, verbose)?; + } + } + } + + Ok(()) +} + +/// Implementation of rendering our host structure in a "human readable" way. +fn human_readable_output(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> { + if host.status.booted.is_some() { + human_readable_output_booted(out, host, verbose)?; + } else { + writeln!(out, "System is not deployed via bootc.")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn human_status_from_spec_fixture(spec_fixture: &str) -> Result { + let host: Host = serde_yaml::from_str(spec_fixture).unwrap(); + let mut w = Vec::new(); + human_readable_output(&mut w, &host, false).unwrap(); + let w = String::from_utf8(w).unwrap(); + Ok(w) + } + + /// Helper function to generate human-readable status output with verbose mode enabled + /// from a YAML fixture string. Used for testing verbose output formatting. + fn human_status_from_spec_fixture_verbose(spec_fixture: &str) -> Result { + let host: Host = serde_yaml::from_str(spec_fixture).unwrap(); + let mut w = Vec::new(); + human_readable_output(&mut w, &host, true).unwrap(); + let w = String::from_utf8(w).unwrap(); + Ok(w) + } + + #[test] + fn test_human_readable_base_spec() { + // Tests Staged and Booted, null Rollback + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-staged-booted.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + Staged image: quay.io/example/someimage:latest + Digest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 (arm64) + Version: nightly (2023-10-14T19:22:15Z) + + ● Booted image: quay.io/example/someimage:latest + Digest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 (arm64) + Version: nightly (2023-09-30T19:22:16Z) + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_rfe_spec() { + // Basic rhel for edge bootc install with nothing + let w = human_status_from_spec_fixture(include_str!( + "fixtures/spec-rfe-ostree-deployment.yaml" + )) + .expect("No spec found"); + let expected = indoc::indoc! { r" + Staged ostree + Commit: 1c24260fdd1be20f72a4a97a75c582834ee3431fbb0fa8e4f482bb219d633a45 + + ● Booted ostree + Commit: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_staged_spec() { + // staged image, no boot/rollback + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-ostree-to-bootc.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + Staged image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (s390x) + Version: stream9.20240807.0 + + ● Booted ostree + Commit: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_booted_spec() { + // booted image, no staged/rollback + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-only-booted.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + ● Booted image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64) + Version: stream9.20240807.0 + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_staged_rollback_spec() { + // staged/rollback image, no booted + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-staged-rollback.yaml")) + .expect("No spec found"); + let expected = "System is not deployed via bootc.\n"; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_via_oci() { + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-via-local-oci.yaml")) + .unwrap(); + let expected = indoc::indoc! { r" + ● Booted image: oci:/var/mnt/osupdate + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (amd64) + Version: stream9.20240807.0 + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_convert_signatures() { + use std::str::FromStr; + let ir_unverified = &OstreeImageReference::from_str( + "ostree-unverified-registry:quay.io/someexample/foo:latest", + ) + .unwrap(); + let ir_ostree = &OstreeImageReference::from_str( + "ostree-remote-registry:fedora:quay.io/fedora/fedora-coreos:stable", + ) + .unwrap(); + + let ir = ImageReference::from(ir_unverified.clone()); + assert_eq!(ir.image, "quay.io/someexample/foo:latest"); + assert_eq!(ir.signature, None); + + let ir = ImageReference::from(ir_ostree.clone()); + assert_eq!(ir.image, "quay.io/fedora/fedora-coreos:stable"); + assert_eq!( + ir.signature, + Some(ImageSignature::OstreeRemote("fedora".into())) + ); + } + + #[test] + fn test_human_readable_booted_pinned_spec() { + // booted image, no staged/rollback + let w = human_status_from_spec_fixture(include_str!("fixtures/spec-booted-pinned.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + ● Booted image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64) + Version: stream9.20240807.0 + Pinned: yes + + Other image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b37 (arm64) + Version: stream9.20240807.0 + Pinned: yes + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_verbose_spec() { + // Test verbose output includes additional fields + let w = + human_status_from_spec_fixture_verbose(include_str!("fixtures/spec-only-booted.yaml")) + .expect("No spec found"); + + // Verbose output should include StateRoot, Deploy serial, Staged, and Commit + assert!(w.contains("StateRoot:")); + assert!(w.contains("Deploy serial:")); + assert!(w.contains("Staged:")); + assert!(w.contains("Commit:")); + assert!(w.contains("Soft-reboot:")); + } +} diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs new file mode 100644 index 000000000..eb65e6eb9 --- /dev/null +++ b/crates/lib/src/store/mod.rs @@ -0,0 +1,379 @@ +//! The [`Store`] holds references to three different types of +//! storage: +//! +//! # OSTree +//! +//! The default backend for the bootable container store; this +//! lives in `/ostree` in the physical root. +//! +//! # containers-storage: +//! +//! Later, bootc gained support for Logically Bound Images. +//! This is a `containers-storage:` instance that lives +//! in `/ostree/bootc/storage` +//! +//! # composefs +//! +//! This lives in `/composefs` in the physical root. + +use std::cell::OnceCell; +use std::ops::Deref; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::{Dir, DirBuilder, DirBuilderExt as _}; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; + +use ostree_ext::container_utils::ostree_booted; +use ostree_ext::sysroot::SysrootLock; +use ostree_ext::{gio, ostree}; +use rustix::fs::Mode; + +use crate::bootc_composefs::status::{composefs_booted, ComposefsCmdline}; +use crate::lsm; +use crate::podstorage::CStorage; +use crate::spec::ImageStatus; +use crate::utils::{deployment_fd, open_dir_remount_rw}; + +/// See https://github.com/containers/composefs-rs/issues/159 +pub type ComposefsRepository = + composefs::repository::Repository; +pub type ComposefsFilesystem = composefs::tree::FileSystem; + +/// Path to the physical root +pub const SYSROOT: &str = "sysroot"; + +/// The toplevel composefs directory path +pub const COMPOSEFS: &str = "composefs"; +#[allow(dead_code)] +pub const COMPOSEFS_MODE: Mode = Mode::from_raw_mode(0o700); + +/// The path to the bootc root directory, relative to the physical +/// system root +pub(crate) const BOOTC_ROOT: &str = "ostree/bootc"; + +/// Storage accessor for a booted system. +/// +/// This wraps [`Storage`] and can determine whether the system is booted +/// via ostree or composefs, providing a unified interface for both. +pub(crate) struct BootedStorage { + pub(crate) storage: Storage, +} + +impl Deref for BootedStorage { + type Target = Storage; + + fn deref(&self) -> &Self::Target { + &self.storage + } +} + +/// Represents an ostree-based boot environment +pub struct BootedOstree<'a> { + pub(crate) sysroot: &'a SysrootLock, + pub(crate) deployment: ostree::Deployment, +} + +impl<'a> BootedOstree<'a> { + /// Get the ostree repository + pub(crate) fn repo(&self) -> ostree::Repo { + self.sysroot.repo() + } + + /// Get the stateroot name + pub(crate) fn stateroot(&self) -> ostree::glib::GString { + self.deployment.osname() + } +} + +/// Represents a composefs-based boot environment +#[allow(dead_code)] +pub struct BootedComposefs { + pub repo: Arc, + pub cmdline: &'static ComposefsCmdline, +} + +/// Discriminated union representing the boot storage backend. +/// +/// The runtime environment in which bootc is executing. +pub(crate) enum Environment { + /// System booted via ostree + OstreeBooted, + /// System booted via composefs + ComposefsBooted(ComposefsCmdline), + /// Running in a container + Container, + /// Other (not booted via bootc) + Other, +} + +impl Environment { + /// Detect the current runtime environment. + pub(crate) fn detect() -> Result { + if ostree_ext::container_utils::running_in_container() { + return Ok(Self::Container); + } + + if let Some(cmdline) = composefs_booted()? { + return Ok(Self::ComposefsBooted(cmdline.clone())); + } + + if ostree_booted()? { + return Ok(Self::OstreeBooted); + } + + Ok(Self::Other) + } + + /// Returns true if this environment requires entering a mount namespace + /// before loading storage (to avoid leaving /sysroot writable). + pub(crate) fn needs_mount_namespace(&self) -> bool { + matches!(self, Self::OstreeBooted | Self::ComposefsBooted(_)) + } +} + +/// A system can boot via either ostree or composefs; this enum +/// allows code to handle both cases while maintaining type safety. +pub(crate) enum BootedStorageKind<'a> { + Ostree(BootedOstree<'a>), + Composefs(BootedComposefs), +} + +impl BootedStorage { + /// Create a new booted storage accessor for the given environment. + /// + /// The caller must have already called `prepare_for_write()` if + /// `env.needs_mount_namespace()` is true. + pub(crate) async fn new(env: Environment) -> Result> { + let physical_root = { + let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority()) + .context("Opening /sysroot")?; + // Remount /sysroot rw only if we are in a new mount ns + if env.needs_mount_namespace() { + open_dir_remount_rw(&d, ".".into())? + } else { + d + } + }; + + let run = + Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?; + + let r = match &env { + Environment::ComposefsBooted(cmdline) => { + let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; + if cmdline.insecure { + composefs.set_insecure(true); + } + let composefs = Arc::new(composefs); + + let storage = Storage { + physical_root, + run, + ostree: Default::default(), + composefs: OnceCell::from(composefs), + imgstore: Default::default(), + }; + + Some(Self { storage }) + } + Environment::OstreeBooted => { + // The caller must have entered a private mount namespace before + // calling this function. This is because ostree's sysroot.load() will + // remount /sysroot as writable, and we call set_mount_namespace_in_use() + // to indicate we're in a mount namespace. Without actually being in a + // mount namespace, this would leave the global /sysroot writable. + + let sysroot = ostree::Sysroot::new_default(); + sysroot.set_mount_namespace_in_use(); + let sysroot = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?; + sysroot.load(gio::Cancellable::NONE)?; + + let storage = Storage { + physical_root, + run, + ostree: OnceCell::from(sysroot), + composefs: Default::default(), + imgstore: Default::default(), + }; + + Some(Self { storage }) + } + Environment::Container | Environment::Other => None, + }; + Ok(r) + } + + /// Determine the boot storage backend kind. + /// + /// Returns information about whether the system booted via ostree or composefs, + /// along with the relevant sysroot/deployment or repository/cmdline data. + pub(crate) fn kind(&self) -> Result> { + if let Some(cmdline) = composefs_booted()? { + // SAFETY: This must have been set above in new() + let repo = self.composefs.get().unwrap(); + Ok(BootedStorageKind::Composefs(BootedComposefs { + repo: Arc::clone(repo), + cmdline, + })) + } else { + // SAFETY: This must have been set above in new() + let sysroot = self.ostree.get().unwrap(); + let deployment = sysroot.require_booted_deployment()?; + Ok(BootedStorageKind::Ostree(BootedOstree { + sysroot, + deployment, + })) + } + } +} + +/// A reference to a physical filesystem root, plus +/// accessors for the different types of container storage. +pub(crate) struct Storage { + /// Directory holding the physical root + pub physical_root: Dir, + /// Our runtime state + run: Dir, + + /// The OSTree storage + ostree: OnceCell, + /// The composefs storage + composefs: OnceCell>, + /// The containers-image storage used foR LBIs + imgstore: OnceCell, +} + +/// Cached image status data used for optimization. +/// +/// This stores the current image status and any cached update information +/// to avoid redundant fetches during status operations. +#[derive(Default)] +pub(crate) struct CachedImageStatus { + pub image: Option, + pub cached_update: Option, +} + +impl Storage { + /// Create a new storage accessor from an existing ostree sysroot. + /// + /// This is used for non-booted scenarios (e.g., `bootc install`) where + /// we're operating on a target filesystem rather than the running system. + pub fn new_ostree(sysroot: SysrootLock, run: &Dir) -> Result { + let run = run.try_clone()?; + + // ostree has historically always relied on + // having ostree -> sysroot/ostree as a symlink in the image to + // make it so that code doesn't need to distinguish between booted + // vs offline target. The ostree code all just looks at the ostree/ + // directory, and will follow the link in the booted case. + // + // For composefs we aren't going to do a similar thing, so here + // we need to explicitly distinguish the two and the storage + // here hence holds a reference to the physical root. + let ostree_sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; + let physical_root = if sysroot.is_booted() { + ostree_sysroot_dir.open_dir(SYSROOT)? + } else { + ostree_sysroot_dir + }; + + let ostree_cell = OnceCell::new(); + let _ = ostree_cell.set(sysroot); + + Ok(Self { + physical_root, + run, + ostree: ostree_cell, + composefs: Default::default(), + imgstore: Default::default(), + }) + } + + /// Access the underlying ostree repository + pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> { + self.ostree + .get() + .ok_or_else(|| anyhow::anyhow!("OSTree storage not initialized")) + } + + /// Get a cloned reference to the ostree sysroot. + /// + /// This is used when code needs an owned `ostree::Sysroot` rather than + /// a reference to the `SysrootLock`. + pub(crate) fn get_ostree_cloned(&self) -> Result { + let r = self.get_ostree()?; + Ok((*r).clone()) + } + + /// Access the image storage; will automatically initialize it if necessary. + pub(crate) fn get_ensure_imgstore(&self) -> Result<&CStorage> { + if let Some(imgstore) = self.imgstore.get() { + return Ok(imgstore); + } + let ostree = self.get_ostree()?; + let sysroot_dir = crate::utils::sysroot_dir(ostree)?; + + let sepolicy = if ostree.booted_deployment().is_none() { + // fallback to policy from container root + // this should only happen during cleanup of a broken install + tracing::trace!("falling back to container root's selinux policy"); + let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + lsm::new_sepolicy_at(&container_root)? + } else { + // load the sepolicy from the booted ostree deployment so the imgstorage can be + // properly labeled with /var/lib/container/storage labels + tracing::trace!("loading sepolicy from booted ostree deployment"); + let dep = ostree.booted_deployment().unwrap(); + let dep_fs = deployment_fd(ostree, &dep)?; + lsm::new_sepolicy_at(&dep_fs)? + }; + + tracing::trace!("sepolicy in get_ensure_imgstore: {sepolicy:?}"); + + let imgstore = CStorage::create(&sysroot_dir, &self.run, sepolicy.as_ref())?; + Ok(self.imgstore.get_or_init(|| imgstore)) + } + + /// Access the composefs repository; will automatically initialize it if necessary. + /// + /// This lazily opens the composefs repository, creating the directory if needed + /// and bootstrapping verity settings from the ostree configuration. + pub(crate) fn get_ensure_composefs(&self) -> Result> { + if let Some(composefs) = self.composefs.get() { + return Ok(Arc::clone(composefs)); + } + + let mut db = DirBuilder::new(); + db.mode(COMPOSEFS_MODE.as_raw_mode()); + self.physical_root.ensure_dir_with(COMPOSEFS, &db)?; + + // Bootstrap verity off of the ostree state. In practice this means disabled by + // default right now. + let ostree = self.get_ostree()?; + let ostree_repo = &ostree.repo(); + let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?; + let mut composefs = + ComposefsRepository::open_path(self.physical_root.open_dir(COMPOSEFS)?, ".")?; + if !ostree_verity.enabled { + tracing::debug!("Setting insecure mode for composefs repo"); + composefs.set_insecure(true); + } + let composefs = Arc::new(composefs); + let r = Arc::clone(self.composefs.get_or_init(|| composefs)); + Ok(r) + } + + /// Update the mtime on the storage root directory + #[context("Updating storage root mtime")] + pub(crate) fn update_mtime(&self) -> Result<()> { + let ostree = self.get_ostree()?; + let sysroot_dir = crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")?; + + sysroot_dir + .update_timestamps(std::path::Path::new(BOOTC_ROOT)) + .context("update_timestamps") + } +} diff --git a/crates/lib/src/systemglue/mod.rs b/crates/lib/src/systemglue/mod.rs new file mode 100644 index 000000000..abd9b55a4 --- /dev/null +++ b/crates/lib/src/systemglue/mod.rs @@ -0,0 +1 @@ +pub(crate) mod generator; diff --git a/crates/lib/src/task.rs b/crates/lib/src/task.rs new file mode 100644 index 000000000..5db8e95bc --- /dev/null +++ b/crates/lib/src/task.rs @@ -0,0 +1,193 @@ +use std::{ + ffi::OsStr, + io::{Seek, Write}, + process::{Command, Stdio}, +}; + +use anyhow::{Context, Result}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::prelude::CapStdExtCommandExt; + +/// How much information we output +#[derive(Debug, PartialEq, Eq, Default)] +enum CmdVerbosity { + /// Nothing is output + Quiet, + /// Only the task description is output + #[default] + Description, + /// The task description and the full command line are output + Verbose, +} + +/// Too many things in the install path are conditional +pub(crate) struct Task { + description: String, + verbosity: CmdVerbosity, + quiet_output: bool, + pub(crate) cmd: Command, +} + +#[allow(dead_code)] +impl Task { + pub(crate) fn new(description: impl AsRef, exe: impl AsRef) -> Self { + Self::new_cmd(description, Command::new(exe.as_ref())) + } + + /// This API can be used in place of Command::new() generally and just adds error + /// checking on top. + pub(crate) fn new_quiet(exe: impl AsRef) -> Self { + let exe = exe.as_ref(); + Self::new(exe, exe).quiet() + } + + /// Set the working directory for this task. + pub(crate) fn cwd(mut self, dir: &Dir) -> Result { + self.cmd.cwd_dir(dir.try_clone()?); + Ok(self) + } + + pub(crate) fn new_cmd(description: impl AsRef, mut cmd: Command) -> Self { + let description = description.as_ref().to_string(); + // Default to noninteractive + cmd.stdin(Stdio::null()); + Self { + description, + verbosity: Default::default(), + quiet_output: false, + cmd, + } + } + + /// Don't output description by default + pub(crate) fn quiet(mut self) -> Self { + self.verbosity = CmdVerbosity::Quiet; + self + } + + /// Output description and cmdline + pub(crate) fn verbose(mut self) -> Self { + self.verbosity = CmdVerbosity::Verbose; + self + } + + // Do not print stdout/stderr, unless the command fails + pub(crate) fn quiet_output(mut self) -> Self { + self.quiet_output = true; + self + } + + pub(crate) fn args>(mut self, args: impl IntoIterator) -> Self { + self.cmd.args(args); + self + } + + pub(crate) fn arg>(mut self, arg: S) -> Self { + self.cmd.args([arg]); + self + } + + /// Run the command, returning an error if the command does not exit successfully. + pub(crate) fn run(self) -> Result<()> { + self.run_with_stdin_buf(None) + } + + fn pre_run_output(&self) { + match self.verbosity { + CmdVerbosity::Quiet => {} + CmdVerbosity::Description => { + println!("{}", self.description); + } + CmdVerbosity::Verbose => { + // Output the description first + println!("{}", self.description); + + // Lock stdout so we buffer + let mut stdout = std::io::stdout().lock(); + let cmd_args = std::iter::once(self.cmd.get_program()) + .chain(self.cmd.get_args()) + .map(|arg| arg.to_string_lossy()); + // We unwrap() here to match the default for println!() even though + // arguably that's wrong + stdout.write_all(b">").unwrap(); + for s in cmd_args { + stdout.write_all(b" ").unwrap(); + stdout.write_all(s.as_bytes()).unwrap(); + } + stdout.write_all(b"\n").unwrap(); + } + } + } + + /// Run the command with optional stdin buffer, returning an error if the command does not exit successfully. + pub(crate) fn run_with_stdin_buf(self, stdin: Option<&[u8]>) -> Result<()> { + self.pre_run_output(); + let description = self.description; + let mut cmd = self.cmd; + let mut output = None; + if self.quiet_output { + let tmpf = tempfile::tempfile()?; + cmd.stdout(Stdio::from(tmpf.try_clone()?)); + cmd.stderr(Stdio::from(tmpf.try_clone()?)); + output = Some(tmpf); + } + tracing::debug!("exec: {cmd:?}"); + let st = if let Some(stdin_value) = stdin { + cmd.stdin(Stdio::piped()); + let mut child = cmd.spawn()?; + // SAFETY: We used piped for stdin + let mut stdin = child.stdin.take().unwrap(); + // If this was async, we could avoid spawning a thread here + std::thread::scope(|s| { + s.spawn(move || stdin.write_all(stdin_value)) + .join() + .map_err(|e| anyhow::anyhow!("Failed to spawn thread: {e:?}"))? + .context("Failed to write to cryptsetup stdin") + })?; + child.wait()? + } else { + cmd.status()? + }; + tracing::trace!("{st:?}"); + if !st.success() { + if let Some(mut output) = output { + output.seek(std::io::SeekFrom::Start(0))?; + let mut stderr = std::io::stderr().lock(); + std::io::copy(&mut output, &mut stderr)?; + } + anyhow::bail!("Task {description} failed: {st:?}"); + } + Ok(()) + } + + /// Like [`run()`], but return stdout. + pub(crate) fn read(self) -> Result { + self.pre_run_output(); + let description = self.description; + let mut cmd = self.cmd; + tracing::debug!("exec: {cmd:?}"); + cmd.stdout(Stdio::piped()); + let child = cmd + .spawn() + .with_context(|| format!("Spawning {description} failed"))?; + let o = child + .wait_with_output() + .with_context(|| format!("Executing {description} failed"))?; + let st = o.status; + if !st.success() { + anyhow::bail!("Task {description} failed: {st:?}"); + } + Ok(String::from_utf8(o.stdout)?) + } + + pub(crate) fn new_and_run<'a>( + description: impl AsRef, + exe: impl AsRef, + args: impl IntoIterator, + ) -> Result<()> { + let mut t = Self::new(description.as_ref(), exe); + t.cmd.args(args); + t.run() + } +} diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs new file mode 100644 index 000000000..aa7bee255 --- /dev/null +++ b/crates/lib/src/utils.rs @@ -0,0 +1,340 @@ +use std::future::Future; +use std::io::Write; +use std::os::fd::BorrowedFd; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use cap_std_ext::prelude::CapStdExtCommandExt; +use fn_error_context::context; +use indicatif::HumanDuration; +use libsystemd::logging::journal_print; +use ostree::glib; +use ostree_ext::container::SignatureSource; +use ostree_ext::ostree; + +/// Try to look for keys injected by e.g. rpm-ostree requesting machine-local +/// changes; if any are present, return `true`. +pub(crate) fn origin_has_rpmostree_stuff(kf: &glib::KeyFile) -> bool { + // These are groups set in https://github.com/coreos/rpm-ostree/blob/27f72dce4f9b5c176ad030911c12354e2498c07d/rust/src/origin.rs#L23 + // TODO: Add some notion of "owner" into origin files + for group in ["rpmostree", "packages", "overrides", "modules"] { + if kf.has_group(group) { + return true; + } + } + false +} + +// Access the file descriptor for a sysroot +#[allow(unsafe_code)] +pub(crate) fn sysroot_fd(sysroot: &ostree::Sysroot) -> BorrowedFd<'_> { + unsafe { BorrowedFd::borrow_raw(sysroot.fd()) } +} + +// Return a cap-std `Dir` type for a sysroot +pub(crate) fn sysroot_dir(sysroot: &ostree::Sysroot) -> Result { + Dir::reopen_dir(&sysroot_fd(sysroot)).map_err(Into::into) +} + +// Return a cap-std `Dir` type for a deployment. +// TODO: in the future this should perhaps actually mount via composefs +pub(crate) fn deployment_fd( + sysroot: &ostree::Sysroot, + deployment: &ostree::Deployment, +) -> Result { + let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + let dirpath = sysroot.deployment_dirpath(deployment); + sysroot_dir.open_dir(&dirpath).map_err(Into::into) +} + +/// Given an mount option string list like foo,bar=baz,something=else,ro parse it and find +/// the first entry like $optname= +/// This will not match a bare `optname` without an equals. +pub(crate) fn find_mount_option<'a>( + option_string_list: &'a str, + optname: &'_ str, +) -> Option<&'a str> { + option_string_list + .split(',') + .filter_map(|k| k.split_once('=')) + .filter_map(|(k, v)| (k == optname).then_some(v)) + .next() +} + +#[allow(dead_code)] +pub fn have_executable(name: &str) -> Result { + let Some(path) = std::env::var_os("PATH") else { + return Ok(false); + }; + for mut elt in std::env::split_paths(&path) { + elt.push(name); + if elt.try_exists()? { + return Ok(true); + } + } + Ok(false) +} + +/// Given a target directory, if it's a read-only mount, then remount it writable +#[context("Opening {target} with writable mount")] +pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result { + if matches!(root.is_mountpoint(target), Ok(Some(true))) { + tracing::debug!("Target {target} is a mountpoint, remounting rw"); + let st = Command::new("mount") + .args(["-o", "remount,rw", target.as_str()]) + .cwd_dir(root.try_clone()?) + .status()?; + + anyhow::ensure!(st.success(), "Failed to remount: {st:?}"); + } + root.open_dir(target).map_err(anyhow::Error::new) +} + +/// Given a target path, remove its immutability if present +#[context("Removing immutable flag from {target}")] +pub(crate) fn remove_immutability(root: &Dir, target: &Utf8Path) -> Result<()> { + use anyhow::ensure; + + tracing::debug!("Target {target} is a mountpoint, remounting rw"); + let st = Command::new("chattr") + .args(["-i", target.as_str()]) + .cwd_dir(root.try_clone()?) + .status()?; + + ensure!(st.success(), "Failed to remove immutability: {st:?}"); + + Ok(()) +} + +pub(crate) fn spawn_editor(tmpf: &tempfile::NamedTempFile) -> Result<()> { + let editor_variables = ["EDITOR"]; + // These roughly match https://github.com/systemd/systemd/blob/769ca9ab557b19ee9fb5c5106995506cace4c68f/src/shared/edit-util.c#L275 + let backup_editors = ["nano", "vim", "vi"]; + let editor = editor_variables.into_iter().find_map(std::env::var_os); + let editor = if let Some(e) = editor.as_ref() { + e.to_str() + } else { + backup_editors + .into_iter() + .find(|v| std::path::Path::new("/usr/bin").join(v).exists()) + }; + let editor = + editor.ok_or_else(|| anyhow::anyhow!("$EDITOR is unset, and no backup editor found"))?; + let mut editor_args = editor.split_ascii_whitespace(); + let argv0 = editor_args + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid editor: {editor}"))?; + Command::new(argv0) + .args(editor_args) + .arg(tmpf.path()) + .lifecycle_bind() + .run_inherited() + .with_context(|| format!("Invoking editor {editor} failed")) +} + +/// Convert a combination of values (likely from CLI parsing) into a signature source +pub(crate) fn sigpolicy_from_opt(enforce_container_verification: bool) -> SignatureSource { + match enforce_container_verification { + true => SignatureSource::ContainerPolicy, + false => SignatureSource::ContainerPolicyAllowInsecure, + } +} + +/// Output a warning message that we want to be quite visible. +/// The process (thread) execution will be delayed for a short time. +pub(crate) fn medium_visibility_warning(s: &str) { + anstream::eprintln!( + "{}{s}{}", + anstyle::AnsiColor::Red.render_fg(), + anstyle::Reset.render() + ); + // When warning, add a sleep to ensure it's seen + std::thread::sleep(std::time::Duration::from_secs(1)); +} + +/// Call an async task function, and write a message to stdout +/// with an automatic spinner to show that we're not blocked. +/// Note that generally the called function should not output +/// anything to stdout as this will interfere with the spinner. +pub(crate) async fn async_task_with_spinner(msg: &str, f: F) -> T +where + F: Future, +{ + let start_time = std::time::Instant::now(); + let pb = indicatif::ProgressBar::new_spinner(); + let style = indicatif::ProgressStyle::default_bar(); + pb.set_style(style.template("{spinner} {msg}").unwrap()); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(150)); + // We need to handle the case where we aren't connected to + // a tty, so indicatif would show nothing by default. + if pb.is_hidden() { + print!("{msg}..."); + std::io::stdout().flush().unwrap(); + } + let r = f.await; + let elapsed = HumanDuration(start_time.elapsed()); + let _ = journal_print( + libsystemd::logging::Priority::Info, + &format!("completed task in {elapsed}: {msg}"), + ); + if pb.is_hidden() { + println!("done ({elapsed})"); + } else { + pb.finish_with_message(format!("{msg}: done ({elapsed})")); + } + r +} + +/// Given a possibly tagged image like quay.io/foo/bar:latest and a digest 0ab32..., return +/// the digested form quay.io/foo/bar:latest@sha256:0ab32... +/// If the image already has a digest, it will be replaced. +#[allow(dead_code)] +pub(crate) fn digested_pullspec(image: &str, digest: &str) -> String { + let image = image.rsplit_once('@').map(|v| v.0).unwrap_or(image); + format!("{image}@{digest}") +} + +#[derive(Debug)] +pub enum EfiError { + SystemNotUEFI, + MissingVar, + #[allow(dead_code)] + InvalidData(&'static str), + #[allow(dead_code)] + Io(std::io::Error), +} + +impl From for EfiError { + fn from(e: std::io::Error) -> Self { + EfiError::Io(e) + } +} + +pub fn read_uefi_var(var_name: &str) -> Result { + use crate::install::EFIVARFS; + use cap_std_ext::cap_std::ambient_authority; + + let efivarfs = match Dir::open_ambient_dir(EFIVARFS, ambient_authority()) { + Ok(dir) => dir, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(EfiError::SystemNotUEFI), + Err(e) => Err(e)?, + }; + + match efivarfs.read(var_name) { + Ok(loader_bytes) => { + if loader_bytes.len() % 2 != 0 { + return Err(EfiError::InvalidData( + "EFI var length is not valid UTF-16 LE", + )); + } + + // EFI vars are UTF-16 LE + let loader_u16_bytes: Vec = loader_bytes + .chunks_exact(2) + .map(|x| u16::from_le_bytes([x[0], x[1]])) + .collect(); + + let loader = String::from_utf16(&loader_u16_bytes) + .map_err(|_| EfiError::InvalidData("EFI var is not UTF-16"))?; + + return Ok(loader); + } + + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(EfiError::MissingVar); + } + + Err(e) => Err(e)?, + } +} + +/// Computes a relative path from `from` to `to`. +/// +/// Both `from` and `to` must be absolute paths. +pub(crate) fn path_relative_to(from: &Path, to: &Path) -> Result { + if !from.is_absolute() || !to.is_absolute() { + anyhow::bail!("Paths must be absolute"); + } + + let from = from.components().collect::>(); + let to = to.components().collect::>(); + + let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + + let up = std::iter::repeat(Component::ParentDir).take(from.len() - common); + + let mut final_path = PathBuf::new(); + final_path.extend(up); + final_path.extend(&to[common..]); + + return Ok(final_path); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_digested_pullspec() { + let digest = "ebe3bdccc041864e5a485f1e755e242535c3b83d110c0357fe57f110b73b143e"; + assert_eq!( + digested_pullspec("quay.io/example/foo:bar", digest), + format!("quay.io/example/foo:bar@{digest}") + ); + assert_eq!( + digested_pullspec("quay.io/example/foo@sha256:otherdigest", digest), + format!("quay.io/example/foo@{digest}") + ); + assert_eq!( + digested_pullspec("quay.io/example/foo", digest), + format!("quay.io/example/foo@{digest}") + ); + } + + #[test] + fn test_find_mount_option() { + const V1: &str = "rw,relatime,compress=foo,subvol=blah,fast"; + assert_eq!(find_mount_option(V1, "subvol").unwrap(), "blah"); + assert_eq!(find_mount_option(V1, "rw"), None); + assert_eq!(find_mount_option(V1, "somethingelse"), None); + } + + #[test] + fn test_sigpolicy_from_opts() { + assert_eq!(sigpolicy_from_opt(true), SignatureSource::ContainerPolicy); + assert_eq!( + sigpolicy_from_opt(false), + SignatureSource::ContainerPolicyAllowInsecure + ); + } + + #[test] + fn test_relative_path() { + let from = Path::new("/sysroot/state/deploy/image_id"); + let to = Path::new("/sysroot/state/os/default/var"); + + assert_eq!( + path_relative_to(from, to).unwrap(), + PathBuf::from("../../os/default/var") + ); + assert_eq!( + path_relative_to(&Path::new("state/deploy"), to) + .unwrap_err() + .to_string(), + "Paths must be absolute" + ); + } + + #[test] + fn test_have_executable() { + assert!(have_executable("true").unwrap()); + assert!(!have_executable("someexethatdoesnotexist").unwrap()); + } +} diff --git a/crates/mount/Cargo.toml b/crates/mount/Cargo.toml new file mode 100644 index 000000000..a8a7475a3 --- /dev/null +++ b/crates/mount/Cargo.toml @@ -0,0 +1,30 @@ +[package] +description = "Internal mount code" +# Should never be published to crates.io +publish = false +edition = "2021" +license = "MIT OR Apache-2.0" +name = "bootc-mount" +repository = "https://github.com/bootc-dev/bootc" +version = "0.0.0" + +[dependencies] +# Internal crates +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anyhow = { workspace = true } +camino = { workspace = true, features = ["serde1"] } +fn-error-context = { workspace = true } +libc = { workspace = true } +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tracing = { workspace = true } +tempfile = { workspace = true } +cap-std-ext = { workspace = true } + +[dev-dependencies] +indoc = { workspace = true } + +[lib] +path = "src/mount.rs" diff --git a/crates/mount/src/mount.rs b/crates/mount/src/mount.rs new file mode 100644 index 000000000..ce77cac3a --- /dev/null +++ b/crates/mount/src/mount.rs @@ -0,0 +1,297 @@ +//! Helpers for interacting with mountpoints + +use std::{ + fs, + mem::MaybeUninit, + os::fd::{AsFd, OwnedFd}, + process::Command, +}; + +use anyhow::{anyhow, Context, Result}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt}; +use fn_error_context::context; +use rustix::{ + mount::{MoveMountFlags, OpenTreeFlags}, + net::{ + AddressFamily, RecvFlags, SendAncillaryBuffer, SendAncillaryMessage, SendFlags, + SocketFlags, SocketType, + }, + process::WaitOptions, + thread::Pid, +}; +use serde::Deserialize; + +pub mod tempmount; + +/// Well known identifier for pid 1 +pub const PID1: Pid = const { + match Pid::from_raw(1) { + Some(v) => v, + None => panic!("Expected to parse pid1"), + } +}; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +pub struct Filesystem { + // Note if you add an entry to this list, you need to change the --output invocation below too + pub source: String, + pub target: String, + #[serde(rename = "maj:min")] + pub maj_min: String, + pub fstype: String, + pub options: String, + pub uuid: Option, + pub children: Option>, +} + +#[derive(Deserialize, Debug, Default)] +pub struct Findmnt { + pub filesystems: Vec, +} + +pub fn run_findmnt(args: &[&str], cwd: Option<&Dir>, path: Option<&str>) -> Result { + let mut cmd = Command::new("findmnt"); + if let Some(cwd) = cwd { + cmd.cwd_dir(cwd.try_clone()?); + } + cmd.args([ + "-J", + "-v", + // If you change this you probably also want to change the Filesystem struct above + "--output=SOURCE,TARGET,MAJ:MIN,FSTYPE,OPTIONS,UUID", + ]) + .args(args) + .args(path); + let o: Findmnt = cmd.log_debug().run_and_parse_json()?; + Ok(o) +} + +// Retrieve a mounted filesystem from a device given a matching path +fn findmnt_filesystem(args: &[&str], cwd: Option<&Dir>, path: &str) -> Result { + let o = run_findmnt(args, cwd, Some(path))?; + o.filesystems + .into_iter() + .next() + .ok_or_else(|| anyhow!("findmnt returned no data for {path}")) +} + +#[context("Inspecting filesystem {path}")] +/// Inspect a target which must be a mountpoint root - it is an error +/// if the target is not the mount root. +pub fn inspect_filesystem(path: &Utf8Path) -> Result { + findmnt_filesystem(&["--mountpoint"], None, path.as_str()) +} + +#[context("Inspecting filesystem")] +/// Inspect a target which must be a mountpoint root - it is an error +/// if the target is not the mount root. +pub fn inspect_filesystem_of_dir(d: &Dir) -> Result { + findmnt_filesystem(&["--mountpoint"], Some(d), ".") +} + +#[context("Inspecting filesystem by UUID {uuid}")] +/// Inspect a filesystem by partition UUID +pub fn inspect_filesystem_by_uuid(uuid: &str) -> Result { + findmnt_filesystem(&["--source"], None, &(format!("UUID={uuid}"))) +} + +// Check if a specified device contains an already mounted filesystem +// in the root mount namespace +pub fn is_mounted_in_pid1_mountns(path: &str) -> Result { + let o = run_findmnt(&["-N"], None, Some("1"))?; + + let mounted = o.filesystems.iter().any(|fs| is_source_mounted(path, fs)); + + Ok(mounted) +} + +// Recursively check a given filesystem to see if it contains an already mounted source +pub fn is_source_mounted(path: &str, mounted_fs: &Filesystem) -> bool { + if mounted_fs.source.contains(path) { + return true; + } + + if let Some(ref children) = mounted_fs.children { + for child in children { + if is_source_mounted(path, child) { + return true; + } + } + } + + false +} + +/// Mount a device to the target path. +pub fn mount(dev: &str, target: &Utf8Path) -> Result<()> { + Command::new("mount") + .args([dev, target.as_str()]) + .run_inherited_with_cmd_context() +} + +/// If the fsid of the passed path matches the fsid of the same path rooted +/// at /proc/1/root, it is assumed that these are indeed the same mounted +/// filesystem between container and host. +/// Path should be absolute. +#[context("Comparing filesystems at {path} and /proc/1/root/{path}")] +pub fn is_same_as_host(path: &Utf8Path) -> Result { + // Add a leading '/' in case a relative path is passed + let path = Utf8Path::new("/").join(path); + + // Using statvfs instead of fs, since rustix will translate the fsid field + // for us. + let devstat = rustix::fs::statvfs(path.as_std_path())?; + let hostpath = Utf8Path::new("/proc/1/root").join(path.strip_prefix("/")?); + let hostdevstat = rustix::fs::statvfs(hostpath.as_std_path())?; + tracing::trace!( + "base mount id {:?}, host mount id {:?}", + devstat.f_fsid, + hostdevstat.f_fsid + ); + Ok(devstat.f_fsid == hostdevstat.f_fsid) +} + +/// Given a pid, enter its mount namespace and acquire a file descriptor +/// for a mount from that namespace. +#[allow(unsafe_code)] +#[context("Opening mount tree from pid")] +pub fn open_tree_from_pidns( + pid: rustix::process::Pid, + path: &Utf8Path, + recursive: bool, +) -> Result { + // Allocate a socket pair to use for sending file descriptors. + let (sock_parent, sock_child) = rustix::net::socketpair( + AddressFamily::UNIX, + SocketType::STREAM, + SocketFlags::CLOEXEC, + None, + ) + .context("socketpair")?; + const DUMMY_DATA: &[u8] = b"!"; + match unsafe { libc::fork() } { + 0 => { + // We're in the child. At this point we know we don't have multiple threads, so we + // can safely `setns`. + + drop(sock_parent); + + // Open up the namespace of the target process as a file descriptor, and enter it. + let pidlink = fs::File::open(format!("/proc/{}/ns/mnt", pid.as_raw_nonzero()))?; + rustix::thread::move_into_link_name_space( + pidlink.as_fd(), + Some(rustix::thread::LinkNameSpaceType::Mount), + ) + .context("setns")?; + + // Open the target mount path as a file descriptor. + let recursive = if recursive { + OpenTreeFlags::AT_RECURSIVE + } else { + OpenTreeFlags::empty() + }; + let fd = rustix::mount::open_tree( + rustix::fs::CWD, + path.as_std_path(), + OpenTreeFlags::OPEN_TREE_CLOEXEC | OpenTreeFlags::OPEN_TREE_CLONE | recursive, + ) + .context("open_tree")?; + + // And send that file descriptor via fd passing over the socketpair. + let fd = fd.as_fd(); + let fds = [fd]; + let mut buffer = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))]; + let mut control = SendAncillaryBuffer::new(&mut buffer); + let pushed = control.push(SendAncillaryMessage::ScmRights(&fds)); + assert!(pushed); + let ios = std::io::IoSlice::new(DUMMY_DATA); + rustix::net::sendmsg(sock_child, &[ios], &mut control, SendFlags::empty())?; + // Then we're done. + std::process::exit(0) + } + -1 => { + // fork failed + let e = std::io::Error::last_os_error(); + anyhow::bail!("failed to fork: {e}"); + } + n => { + // We're in the parent; create a pid (checking that n > 0). + let pid = rustix::process::Pid::from_raw(n).unwrap(); + drop(sock_child); + // Receive the mount file descriptor from the child + let mut cmsg_space = vec![MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))]; + let mut cmsg_buffer = rustix::net::RecvAncillaryBuffer::new(&mut cmsg_space); + let mut buf = [0u8; DUMMY_DATA.len()]; + let iov = std::io::IoSliceMut::new(buf.as_mut()); + let mut iov = [iov]; + let nread = rustix::net::recvmsg( + sock_parent, + &mut iov, + &mut cmsg_buffer, + RecvFlags::CMSG_CLOEXEC, + ) + .context("recvmsg")? + .bytes; + anyhow::ensure!(nread == DUMMY_DATA.len()); + assert_eq!(buf, DUMMY_DATA); + // And extract the file descriptor + let r = cmsg_buffer + .drain() + .filter_map(|m| match m { + rustix::net::RecvAncillaryMessage::ScmRights(f) => Some(f), + _ => None, + }) + .flatten() + .next() + .ok_or_else(|| anyhow::anyhow!("Did not receive a file descriptor"))?; + // SAFETY: Since we're not setting WNOHANG, this will always return Some(). + let st = rustix::process::waitpid(Some(pid), WaitOptions::empty())? + .expect("Wait status") + .1; + if let Some(0) = st.exit_status() { + Ok(r) + } else { + anyhow::bail!("forked helper failed: {st:?}"); + } + } + } +} + +/// Create a bind mount from the mount namespace of the target pid +/// into our mount namespace. +pub fn bind_mount_from_pidns( + pid: Pid, + src: &Utf8Path, + target: &Utf8Path, + recursive: bool, +) -> Result<()> { + let src = open_tree_from_pidns(pid, src, recursive)?; + rustix::mount::move_mount( + src.as_fd(), + "", + rustix::fs::CWD, + target.as_std_path(), + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("Moving mount")?; + Ok(()) +} + +// If the target path is not already mirrored from the host (e.g. via -v /dev:/dev) +// then recursively mount it. +pub fn ensure_mirrored_host_mount(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + // If we didn't have this in our filesystem already (e.g. for /var/lib/containers) + // then create it now. + std::fs::create_dir_all(path)?; + if is_same_as_host(path)? { + tracing::debug!("Already mounted from host: {path}"); + return Ok(()); + } + tracing::debug!("Propagating host mount: {path}"); + bind_mount_from_pidns(PID1, path, path, true) +} diff --git a/crates/mount/src/tempmount.rs b/crates/mount/src/tempmount.rs new file mode 100644 index 000000000..d8e6d0a1d --- /dev/null +++ b/crates/mount/src/tempmount.rs @@ -0,0 +1,81 @@ +use std::os::fd::AsFd; + +use anyhow::{Context, Result}; + +use camino::Utf8Path; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use fn_error_context::context; +use rustix::mount::{move_mount, unmount, MountFlags, MoveMountFlags, UnmountFlags}; + +pub struct TempMount { + pub dir: tempfile::TempDir, + pub fd: Dir, +} + +impl TempMount { + /// Mount device/partition on a tempdir which will be automatically unmounted on drop + #[context("Mounting {dev}")] + pub fn mount_dev( + dev: &str, + fstype: &str, + flags: MountFlags, + data: Option<&std::ffi::CStr>, + ) -> Result { + let tempdir = tempfile::TempDir::new()?; + + let utf8path = Utf8Path::from_path(tempdir.path()) + .ok_or(anyhow::anyhow!("Failed to convert path to UTF-8 Path"))?; + + rustix::mount::mount(dev, utf8path.as_std_path(), fstype, flags, data)?; + + let fd = Dir::open_ambient_dir(tempdir.path(), ambient_authority()) + .with_context(|| format!("Opening {:?}", tempdir.path())); + + let fd = match fd { + Ok(fd) => fd, + Err(e) => { + unmount(tempdir.path(), UnmountFlags::DETACH)?; + Err(e)? + } + }; + + Ok(Self { dir: tempdir, fd }) + } + + /// Mount and fd acquired with `open_tree` like syscall + #[context("Mounting fd")] + pub fn mount_fd(mnt_fd: impl AsFd) -> Result { + let tempdir = tempfile::TempDir::new()?; + + move_mount( + mnt_fd.as_fd(), + "", + rustix::fs::CWD, + tempdir.path(), + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("move_mount")?; + + let fd = Dir::open_ambient_dir(tempdir.path(), ambient_authority()) + .with_context(|| format!("Opening {:?}", tempdir.path())); + + let fd = match fd { + Ok(fd) => fd, + Err(e) => { + unmount(tempdir.path(), UnmountFlags::DETACH)?; + Err(e)? + } + }; + + Ok(Self { dir: tempdir, fd }) + } +} + +impl Drop for TempMount { + fn drop(&mut self) { + match unmount(self.dir.path(), UnmountFlags::DETACH) { + Ok(_) => {} + Err(e) => tracing::warn!("Failed to unmount tempdir: {e:?}"), + } + } +} diff --git a/crates/ostree-ext/.github/workflows/rust.yml b/crates/ostree-ext/.github/workflows/rust.yml new file mode 100644 index 000000000..efd69808b --- /dev/null +++ b/crates/ostree-ext/.github/workflows/rust.yml @@ -0,0 +1,166 @@ +# Inspired by https://github.com/rust-analyzer/rust-analyzer/blob/master/.github/workflows/ci.yaml +# but tweaked in several ways. If you make changes here, consider doing so across other +# repositories in e.g. ostreedev etc. +name: Rust + +permissions: + actions: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: {} + +env: + CARGO_TERM_COLOR: always + +jobs: + tests: + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - uses: actions/checkout@v6 + - name: Code lints + run: ./ci/lints.sh + - name: Install deps + run: ./ci/installdeps.sh + # xref containers/containers-image-proxy-rs + - name: Cache Dependencies + uses: Swatinem/rust-cache@v2 + with: + key: "tests" + - name: cargo fmt (check) + run: cargo fmt -- --check -l + - name: Build + run: cargo test --no-run + - name: Individual checks + run: (cd cli && cargo check) && (cd lib && cargo check) + - name: Run tests + run: cargo test -- --nocapture --quiet + - name: Manpage generation + run: mkdir -p target/man && cargo run --features=docgen -- man --directory target/man + - name: cargo clippy + run: cargo clippy + build: + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - uses: actions/checkout@v6 + - name: Install deps + run: ./ci/installdeps.sh + - name: Cache Dependencies + uses: Swatinem/rust-cache@v2 + with: + key: "build" + - name: Build + run: cargo build --release --features=internal-testing-api + - name: Upload binary + uses: actions/upload-artifact@v5 + with: + name: ostree-ext-cli + path: target/release/ostree-ext-cli + build-minimum-toolchain: + name: "Build using MSRV" + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Install deps + run: ./ci/installdeps.sh + - name: Detect crate MSRV + shell: bash + run: | + msrv=$(cargo metadata --format-version 1 --no-deps | \ + jq -r '.packages[1].rust_version') + echo "Crate MSRV: $msrv" + echo "ACTION_MSRV_TOOLCHAIN=$msrv" >> $GITHUB_ENV + - name: Remove system Rust toolchain + run: dnf remove -y rust cargo + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env['ACTION_MSRV_TOOLCHAIN'] }} + - name: Cache Dependencies + uses: Swatinem/rust-cache@v2 + with: + key: "min" + - name: cargo check + run: cargo check + cargo-deny: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + log-level: warn + command: check bans sources licenses + integration: + name: "Integration" + needs: build + runs-on: ubuntu-latest + container: quay.io/fedora/fedora-coreos:testing-devel + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Download ostree-ext-cli + uses: actions/download-artifact@v6.0.0 + with: + name: ostree-ext-cli + - name: Install + run: install ostree-ext-cli /usr/bin && rm -v ostree-ext-cli + - name: Integration tests + run: ./ci/integration.sh + ima: + name: "Integration (IMA)" + needs: build + runs-on: ubuntu-latest + container: quay.io/fedora/fedora-coreos:testing-devel + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Download ostree-ext-cli + uses: actions/download-artifact@v6.0.0 + with: + name: ostree-ext-cli + - name: Install + run: install ostree-ext-cli /usr/bin && rm -v ostree-ext-cli + - name: Integration tests + run: ./ci/ima.sh + privtest-cockpit: + name: "Privileged testing (cockpit)" + needs: build + runs-on: ubuntu-latest + container: + image: quay.io/fedora/fedora-bootc:41 + options: "--privileged --pid=host -v /var/tmp:/var/tmp -v /run/dbus:/run/dbus -v /run/systemd:/run/systemd -v /:/run/host" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Download + uses: actions/download-artifact@v6.0.0 + with: + name: ostree-ext-cli + - name: Install + run: install ostree-ext-cli /usr/bin && rm -v ostree-ext-cli + - name: Integration tests + run: ./ci/priv-test-cockpit-selinux.sh + container-build: + name: "Container build" + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Checkout coreos-layering-examples + uses: actions/checkout@v6 + with: + repository: coreos/coreos-layering-examples + path: coreos-layering-examples + - name: Download + uses: actions/download-artifact@v6.0.0 + with: + name: ostree-ext-cli + - name: Integration tests + run: ./ci/container-build-integration.sh diff --git a/crates/ostree-ext/.gitignore b/crates/ostree-ext/.gitignore new file mode 100644 index 000000000..b59902fdd --- /dev/null +++ b/crates/ostree-ext/.gitignore @@ -0,0 +1,7 @@ +example + + +# Added by cargo + +/target +Cargo.lock diff --git a/crates/ostree-ext/Cargo.toml b/crates/ostree-ext/Cargo.toml new file mode 100644 index 000000000..7dfc59262 --- /dev/null +++ b/crates/ostree-ext/Cargo.toml @@ -0,0 +1,76 @@ +[package] +authors = ["Colin Walters "] +description = "Extension APIs for OSTree" +edition = "2021" +license = "MIT OR Apache-2.0" +name = "ostree-ext" +repository = "https://github.com/ostreedev/ostree-rs-ext" +version = "0.15.3" + +[dependencies] +# Internal crates +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anyhow = { workspace = true } +camino = { workspace = true, features = ["serde1"] } +canon-json = { workspace = true } +cap-std-ext = { workspace = true, features = ["fs_utf8"] } +chrono = { workspace = true } +clap = { workspace = true, features = ["derive","cargo"] } +clap_mangen = { workspace = true, optional = true } +composefs = { workspace = true } +composefs-boot = { workspace = true } +composefs-oci = { workspace = true } +fn-error-context = { workspace = true } +hex = { workspace = true } +indicatif = { workspace = true } +indoc = { workspace = true, optional = true } +libc = { workspace = true } +openssl = { workspace = true } +regex = { workspace = true } +rustix = { workspace = true, features = ["fs", "process"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +similar-asserts = { workspace = true, optional = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["io-std", "time", "process", "rt", "net"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +xshell = { workspace = true, optional = true } + +# Crate-specific dependencies +comfy-table = "7.1.1" +containers-image-proxy = "0.9.0" +flate2 = { features = ["zlib"], default-features = false, version = "1.0.20" } +futures-util = "0.3.13" +gvariant = "0.5.0" +indexmap = { version = "2.2.2", features = ["serde"] } +io-lifetimes = "3" +libsystemd = "0.7.0" +ocidir = "0.6.0" +# We re-export this library too. +ostree = { features = ["v2025_3"], version = "0.20.5" } +pin-project = "1.0" +tar = "0.4.43" +tokio-stream = { features = ["sync"], version = "0.1.8" } +zstd = { version = "0.13.1", features = ["pkg-config"] } + +[dev-dependencies] +quickcheck = "1" +# https://github.com/rust-lang/cargo/issues/2911 +# https://github.com/rust-lang/rfcs/pull/1956 +ostree-ext = { path = ".", features = ["internal-testing-api"] } + +[package.metadata.docs.rs] +features = ["dox"] + +[features] +docgen = ["clap_mangen"] +dox = ["ostree/dox"] +internal-testing-api = ["xshell", "indoc", "similar-asserts"] +# Enable calling back into bootc +bootc = [] + +[lints] +workspace = true diff --git a/crates/ostree-ext/LICENSE-APACHE b/crates/ostree-ext/LICENSE-APACHE new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/crates/ostree-ext/LICENSE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/crates/ostree-ext/LICENSE-MIT b/crates/ostree-ext/LICENSE-MIT new file mode 100644 index 000000000..dbd7f6572 --- /dev/null +++ b/crates/ostree-ext/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) 2016 The openat Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ostree-ext/README.md b/crates/ostree-ext/README.md new file mode 100644 index 000000000..5b472f3d3 --- /dev/null +++ b/crates/ostree-ext/README.md @@ -0,0 +1,187 @@ +# ostree-ext + +Extension APIs for [ostree](https://github.com/ostreedev/ostree/) that are written in Rust, using the [Rust ostree bindings](https://crates.io/crates/ostree). + +If you are writing tooling that uses ostree and Rust, this crate is intended for you. +However, while the ostree core is very stable, the APIs and data models and this crate +should be considered "slushy". An effort will be made to preserve backwards compatibility +for data written by prior versions (e.g. of tar and container serialization), but +if you choose to use this crate, please [file an issue](https://github.com/ostreedev/ostree-rs-ext/issues) +to let us know. + +At the moment, the following projects are known to use this crate: + +- https://github.com/bootc-dev/bootc +- https://github.com/coreos/rpm-ostree + +The intention of this crate is to be where new high level ostree-related features +land. However, at this time it is kept separate from the core C library, which +is in turn separate from the [ostree-rs bindings](https://github.com/ostreedev/ostree-rs). + +High level features (more on this below): + +- ostree and [opencontainers/image](https://github.com/opencontainers/image-spec) bridging/integration +- Generalized tar import/export +- APIs to diff ostree commits + +```mermaid +flowchart TD + ostree-rs-ext --- ostree-rs --- ostree + ostree-rs-ext --- containers-image-proxy-rs --- skopeo --- containers/image +``` + +For more information on the container stack, see below. + +## module "tar": tar export/import + +ostree's support for exporting to a tarball is lossy because it doesn't have e.g. commit +metadata. This adds a new export format that is effectively a new custom repository mode +combined with a hardlinked checkout. + +This new export stream can be losslessly imported back into a different repository. + +### Filesystem layout + +``` +. +├── etc # content is at traditional /etc, not /usr/etc +│   └── passwd +├── sysroot +│   └── ostree # ostree object store with hardlinks to destinations +│   ├── repo +│   │   └── objects +│   │   ├── 00 +│   │   └── 8b +│   │   └── 7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52.file.xattrs +│   │   └── 7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52.file +│   └── xattrs # A new directory with extended attributes, hardlinked with .xattr files +│   └── 58d523efd29244331392770befa2f8bd55b3ef594532d3b8dbf94b70dc72e674 +└── usr + ├── bin + │   └── bash + └── lib64 + └── libc.so +``` + +Think of this like a new ostree repository mode `tar-stream` or so, although right now it only holds a single commit. + +A major distinction is the addition of special `.xattr` files; tar variants and support library differ too much for us to rely on this making it through round trips. And further, to support the webserver-in-container we need e.g. `security.selinux` to not be changed/overwritten by the container runtime. + +## module "diff": Compute the difference between two ostree commits + +```rust + let subdir: Option<&str> = None; + let refname = "fedora/coreos/x86_64/stable"; + let diff = ostree_ext::diff::diff(repo, &format!("{}^", refname), refname, subdir)?; +``` + +This is used by `rpm-ostree ex apply-live`. + +## module "container": Bridging between ostree and OCI/Docker images + + +This module contains APIs to bidirectionally map between OSTree commits and the [opencontainers](https://github.com/opencontainers) +ecosystem. + +Because container images are just layers of tarballs, this builds on the [`crate::tar`] module. + +This module builds on [containers-image-proxy-rs](https://github.com/containers/containers-image-proxy-rs) +and [skopeo](https://github.com/containers/skopeo), which in turn is ultimately a frontend +around the [containers/image](https://github.com/containers/image) ecosystem. + +In particular, the `containers/image` library is used to fetch content from remote registries, +which allows building on top of functionality in that library, including signatures, mirroring +and in general a battle tested codebase for interacting with both OCI and Docker registries. + +### Encapsulation + +For existing organizations which use ostree, APIs (and a CLI) are provided to "encapsulate" +and "unencapsulate" an OSTree commit as as an OCI image. + +``` +$ ostree-ext-cli container encapsulate --repo=/path/to/repo exampleos/x86_64/stable docker://quay.io/exampleos/exampleos:stable +``` +You can then e.g. + +``` +$ podman run --rm -ti --entrypoint bash quay.io/exampleos/exampleos:stable +``` + +Running the container directly for e.g. CI testing is one use case. But more importantly, this container image +can be pushed to any registry, and used as part of ostree-based operating system release engineering. + +However, this is a very simplistic model - it currently generates a container image with a single layer, which means +every change requires redownloading that entire layer. As of recently, the underlying APIs for generating +container images support "chunked" images. But this requires coding for a specific package/build system. + +A good reference code base for generating "chunked" images is [rpm-ostree compose container-encapsulate](https://coreos.github.io/rpm-ostree/container/#converting-ostree-commits-to-new-base-images). This is used to generate the current [Fedora CoreOS](https://quay.io/repository/fedora/fedora-coreos?tab=tags&tag=latest) +images. + +### Unencapsulate an ostree-container directly + +A primary goal of this effort is to make it fully native to an ostree-based operating system to pull a container image directly too. + +The CLI offers a method to "unencapsulate" - fetch a container image in a streaming fashion and +import the embedded OSTree commit. Here, you must use a prefix scheme which defines signature verification. + +- `ostree-remote-image:$remote:$imagereference`: This declares that the OSTree commit embedded in the image reference should be verified using the ostree remote config `$remote`. +- `ostree-image-signed:$imagereference`: Fetch via the containers/image stack, but require *some* signature verification (not via ostree). +- `ostree-unverified-image:$imagereference`: Don't do any signature verification + +``` +$ ostree-ext-cli container unencapsulate --repo=/ostree/repo ostree-remote-image:someremote:docker://quay.io/exampleos/exampleos:stable +``` + +But a project like rpm-ostree could hence support: + +``` +$ rpm-ostree rebase ostree-remote-image:someremote:quay.io/exampleos/exampleos:stable +``` + +(Along with the usual `rpm-ostree upgrade` knowing to pull that container image) + + +To emphasize this, the current high level model is that this is a one-to-one mapping - an ostree commit +can be exported (wrapped) into a container image, which will have exactly one layer. Upon import +back into an ostree repository, all container metadata except for its digested checksum will be discarded. + +#### Signatures + +OSTree supports GPG and ed25519 signatures natively, and it's expected by default that +when booting from a fetched container image, one verifies ostree-level signatures. +For ostree, a signing configuration is specified via an ostree remote. In order to +pair this configuration together, this library defines a "URL-like" string schema: +`ostree-remote-registry::` +A concrete instantiation might be e.g.: `ostree-remote-registry:fedora:quay.io/coreos/fedora-coreos:stable` +To parse and generate these strings, see [`OstreeImageReference`]. + +### Layering + +A key feature of container images is support for layering. This functionality is handled +via a separate [container/store](https://docs.rs/ostree_ext/latest/ostree_ext/container/store/) module. + +These APIs are also exposed via the CLI: + +``` +$ ostree-ext-cli container image --help +ostree-ext-cli-container-image 0.4.0-alpha.0 +Commands for working with (possibly layered, non-encapsulated) container images + +USAGE: + ostree-ext-cli container image + +FLAGS: + -h, --help Prints help information + -V, --version Prints version information + +SUBCOMMANDS: + copy Copy a pulled container image from one repo to another + deploy Perform initial deployment for a container image + help Prints this message or the help of the given subcommand(s) + list List container images + pull Pull (or update) a container image +``` + +## More details about ostree and containers + +See [ostree-and-containers.md](ostree-and-containers.md). diff --git a/crates/ostree-ext/ci/container-build-integration.sh b/crates/ostree-ext/ci/container-build-integration.sh new file mode 100755 index 000000000..4f10dac9e --- /dev/null +++ b/crates/ostree-ext/ci/container-build-integration.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Verify `ostree container commit` +set -euo pipefail + +image=quay.io/fedora/fedora-coreos:stable +example=coreos-layering-examples/tailscale +set -x + +chmod a+x ostree-ext-cli +workdir=${PWD} +cd ${example} +cp ${workdir}/ostree-ext-cli . +sed -ie 's,ostree container commit,ostree-ext-cli container commit,' Containerfile +sed -ie 's,^\(FROM .*\),\1\nADD ostree-ext-cli /usr/bin/,' Containerfile +git diff + +for runtime in podman docker; do + $runtime build -t localhost/fcos-tailscale -f Containerfile . + $runtime run --rm localhost/fcos-tailscale rpm -q tailscale +done + +cd $(mktemp -d) +cp ${workdir}/ostree-ext-cli . +cat > Containerfile << EOF +FROM $image +ADD ostree-ext-cli /usr/bin/ +RUN set -x; test \$(ostree-ext-cli internal-only-for-testing detect-env) = ostree-container +EOF +# Also verify docker buildx, which apparently doesn't have /.dockerenv +docker buildx build -t localhost/fcos-tailscale -f Containerfile . + +echo ok container image integration diff --git a/crates/ostree-ext/ci/ima.sh b/crates/ostree-ext/ci/ima.sh new file mode 100755 index 000000000..6be4dc611 --- /dev/null +++ b/crates/ostree-ext/ci/ima.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Assumes that the current environment is a mutable ostree-container +# with ostree-ext-cli installed in /usr/bin. +# Runs IMA tests. +set -xeuo pipefail + +# https://github.com/ostreedev/ostree-rs-ext/issues/417 +mkdir -p /var/tmp + +if test '!' -x /usr/bin/evmctl; then + rpm-ostree install ima-evm-utils +fi + +ostree-ext-cli internal-only-for-testing run-ima +echo ok "ima" diff --git a/crates/ostree-ext/ci/installdeps.sh b/crates/ostree-ext/ci/installdeps.sh new file mode 100755 index 000000000..cc79b8f92 --- /dev/null +++ b/crates/ostree-ext/ci/installdeps.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -xeuo pipefail + +# For some reason dnf copr enable -y says there are no builds? +cat >/etc/yum.repos.d/coreos-continuous.repo << 'EOF' +[copr:copr.fedorainfracloud.org:group_CoreOS:continuous] +name=Copr repo for continuous owned by @CoreOS +baseurl=https://download.copr.fedorainfracloud.org/results/@CoreOS/continuous/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/@CoreOS/continuous/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +EOF + +# Our tests depend on this +dnf -y install skopeo + +# Always pull ostree from updates-testing to avoid the bodhi wait +dnf -y update ostree diff --git a/crates/ostree-ext/ci/integration.sh b/crates/ostree-ext/ci/integration.sh new file mode 100755 index 000000000..342207cdf --- /dev/null +++ b/crates/ostree-ext/ci/integration.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Assumes that the current environment is a mutable ostree-container +# with ostree-ext-cli installed in /usr/bin. +# Runs integration tests. +set -xeuo pipefail + +# Output an ok message for TAP +n_tap_tests=0 +tap_ok() { + echo "ok" "$@" + n_tap_tests=$(($n_tap_tests+1)) +} + +tap_end() { + echo "1..${n_tap_tests}" +} + +env=$(ostree-ext-cli internal-only-for-testing detect-env) +test "${env}" = ostree-container +tap_ok environment + +ostree-ext-cli internal-only-for-testing run +tap_ok integrationtests + +tap_end diff --git a/crates/ostree-ext/ci/lints.sh b/crates/ostree-ext/ci/lints.sh new file mode 100755 index 000000000..4a07f6693 --- /dev/null +++ b/crates/ostree-ext/ci/lints.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -xeuo pipefail +tmpf=$(mktemp) +git grep 'dbg!' '*.rs' > ${tmpf} || true +if test -s ${tmpf}; then + echo "Found dbg!" 1>&2 + cat "${tmpf}" + exit 1 +fi \ No newline at end of file diff --git a/crates/ostree-ext/ci/priv-integration.sh b/crates/ostree-ext/ci/priv-integration.sh new file mode 100755 index 000000000..aa1d588f6 --- /dev/null +++ b/crates/ostree-ext/ci/priv-integration.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# Assumes that the current environment is a privileged container +# with the host mounted at /run/host. We can basically write +# whatever we want, however we can't actually *reboot* the host. +set -euo pipefail + +# https://github.com/ostreedev/ostree-rs-ext/issues/417 +mkdir -p /var/tmp + +sysroot=/run/host +# Current stable image fixture +image=quay.io/fedora/fedora-coreos:testing-devel +imgref=ostree-unverified-registry:${image} +stateroot=testos + +# This image was generated manually; TODO auto-generate in quay.io/coreos-assembler or better start sigstore signing our production images +FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE=quay.io/rh_ee_rsaini/coreos + +cd $(mktemp -d -p /var/tmp) + +set -x + +if test '!' -e "${sysroot}/ostree"; then + ostree admin init-fs --modern "${sysroot}" + ostree config --repo $sysroot/ostree/repo set sysroot.bootloader none +fi +if test '!' -d "${sysroot}/ostree/deploy/${stateroot}"; then + ostree admin os-init "${stateroot}" --sysroot "${sysroot}" +fi +# Should be no images pruned +ostree container image prune-images --sysroot "${sysroot}" +# Test the syntax which uses full imgrefs. +ostree container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref "${imgref}" +ostree admin --sysroot="${sysroot}" status +ostree container image metadata --repo "${sysroot}/ostree/repo" registry:"${image}" > manifest.json +jq '.schemaVersion' < manifest.json +ostree container image remove --repo "${sysroot}/ostree/repo" registry:"${image}" +ostree admin --sysroot="${sysroot}" undeploy 0 +# Now test the new syntax which has a nicer --image that defaults to registry. +ostree container image deploy --transport registry --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --image "${image}" +ostree admin --sysroot="${sysroot}" status +ostree admin --sysroot="${sysroot}" undeploy 0 +if ostree container image deploy --transport registry --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --image "${image}" --enforce-container-sigpolicy 2>err.txt; then + echo "Deployment with enforced verification succeeded unexpectedly" 1>&2 + exit 1 +fi +if ! grep -Ee 'insecureAcceptAnything.*refusing usage' err.txt; then + echo "unexpected error" 1>&2 + cat err.txt +fi +# Now we should prune it +ostree container image prune-images --sysroot "${sysroot}" +ostree container image list --repo "${sysroot}/ostree/repo" > out.txt +test $(stat -c '%s' out.txt) = 0 + +for img in "${image}"; do + ostree container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref ostree-unverified-registry:"${img}" + ostree admin --sysroot="${sysroot}" status + initial_refs=$(ostree --repo="${sysroot}/ostree/repo" refs | wc -l) + ostree container image remove --repo "${sysroot}/ostree/repo" registry:"${img}" + pruned_refs=$(ostree --repo="${sysroot}/ostree/repo" refs | wc -l) + # Removing the image should only drop the image reference, not its layers + test "$(($initial_refs - 1))" = "$pruned_refs" + ostree admin --sysroot="${sysroot}" undeploy 0 + # TODO: when we fold together ostree and ostree-ext, automatically prune layers + n_commits=$(find ${sysroot}/ostree/repo -name '*.commit' | wc -l) + test "${n_commits}" -gt 0 + # But right now this still doesn't prune *content* + ostree container image prune-layers --repo="${sysroot}/ostree/repo" + ostree --repo="${sysroot}/ostree/repo" refs > refs.txt + if test "$(wc -l < refs.txt)" -ne 0; then + echo "found refs" + cat refs.txt + exit 1 + fi + # And this one should GC the objects too + ostree container image prune-images --full --sysroot="${sysroot}" > out.txt + n_commits=$(find ${sysroot}/ostree/repo -name '*.commit' | wc -l) + test "${n_commits}" -eq 0 +done + +# Verify we have systemd journal messages +nsenter -m -t 1 journalctl _COMM=bootc > logs.txt +if ! grep 'layers already present: ' logs.txt; then + cat logs.txt + exit 1 +fi + +podman pull ${image} +ostree --repo="${sysroot}/ostree/repo" init --mode=bare-user +ostree container image pull ${sysroot}/ostree/repo ostree-unverified-image:containers-storage:${image} +echo "ok pulled from containers storage" + +ostree container compare ${imgref} ${imgref} > compare.txt +grep "Removed layers: *0 *Size: 0 bytes" compare.txt +grep "Added layers: *0 *Size: 0 bytes" compare.txt + +mkdir build +cd build +cat >Dockerfile << EOF +FROM ${image} +RUN touch /usr/share/somefile +EOF +systemd-run -dP --wait podman build -t localhost/fcos-derived . +derived_img=oci:/var/tmp/derived.oci +derived_img_dir=dir:/var/tmp/derived.dir +systemd-run -dP --wait skopeo copy containers-storage:localhost/fcos-derived "${derived_img}" +systemd-run -dP --wait skopeo copy "${derived_img}" "${derived_img_dir}" + +# Prune to reset state +ostree refs ostree/container/image --delete + +repo="${sysroot}/ostree/repo" +images=$(ostree container image list --repo "${repo}" | wc -l) +test "${images}" -eq 1 +ostree container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref ostree-unverified-image:"${derived_img}" +imgref=$(ostree refs --repo=${repo} ostree/container/image | head -1) +img_commit=$(ostree --repo=${repo} rev-parse ostree/container/image/${imgref}) +ostree container image remove --repo "${repo}" "${derived_img}" + +ostree container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref ostree-unverified-image:"${derived_img}" +img_commit2=$(ostree --repo=${repo} rev-parse ostree/container/image/${imgref}) +test "${img_commit}" = "${img_commit2}" +echo "ok deploy derived container identical revs" + +ostree container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref ostree-unverified-image:"${derived_img_dir}" +echo "ok deploy derived container from local dir" +ostree container image remove --repo "${repo}" "${derived_img_dir}" +rm -rf /var/tmp/derived.dir + +# Verify policy + +mkdir -p /etc/pki/containers +#Ensure Wrong Public Key fails +cat > /etc/pki/containers/fcos.pub << EOF +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPw/TzXY5FQ00LT2orloOuAbqoOKv +relAN0my/O8tziGvc16PtEhF6A7Eun0/9//AMRZ8BwLn2cORZiQsGd5adA== +-----END PUBLIC KEY----- +EOF + +cat > /etc/containers/registries.d/default.yaml << EOF +docker: + ${FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE}: + use-sigstore-attachments: true +EOF + +cat > /etc/containers/policy.json << EOF +{ + "default": [ + { + "type": "reject" + } + ], + "transports": { + "docker": { + "quay.io/fedora/fedora-coreos": [ + { + "type": "insecureAcceptAnything" + } + ], + "${FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE}": [ + { + "type": "sigstoreSigned", + "keyPath": "/etc/pki/containers/fcos.pub", + "signedIdentity": { + "type": "matchRepository" + } + } + ] + + } + } +} +EOF + +if ostree container image pull ${repo} ostree-image-signed:docker://${FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE} 2> error; then + echo "unexpectedly pulled image" 1>&2 + exit 1 +else + grep -q "invalid signature" error +fi + +#Ensure Correct Public Key succeeds +cat > /etc/pki/containers/fcos.pub << EOF +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEREpVb8t/Rp/78fawILAodC6EXGCG +rWNjJoPo7J99cBu5Ui4oCKD+hAHagop7GTi/G3UBP/dtduy2BVdICuBETQ== +-----END PUBLIC KEY----- +EOF +ostree container image pull ${repo} ostree-image-signed:docker://${FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE} +ostree container image history --repo ${repo} docker://${FIXTURE_SIGSTORE_SIGNED_FCOS_IMAGE} + +echo ok privileged integration diff --git a/crates/ostree-ext/ci/priv-test-cockpit-selinux.sh b/crates/ostree-ext/ci/priv-test-cockpit-selinux.sh new file mode 100755 index 000000000..2d71038de --- /dev/null +++ b/crates/ostree-ext/ci/priv-test-cockpit-selinux.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Assumes that the current environment is a privileged container +# with the host mounted at /run/host. We can basically write +# whatever we want, however we can't actually *reboot* the host. +set -euo pipefail + +sysroot=/run/host +stateroot=test-cockpit +repo=$sysroot/ostree/repo +image=registry.gitlab.com/fedora/bootc/tests/container-fixtures/cockpit +imgref=ostree-unverified-registry:${image} + +cd $(mktemp -d -p /var/tmp) + +set -x + +if test '!' -e "${sysroot}/ostree"; then + ostree admin init-fs --epoch=1 "${sysroot}" + ostree config --repo $repo set sysroot.bootloader none +fi +ostree admin stateroot-init "${stateroot}" --sysroot "${sysroot}" +ostree-ext-cli container image deploy --sysroot "${sysroot}" \ + --stateroot "${stateroot}" --imgref "${imgref}" +ref=$(ostree refs --repo $repo ostree/container/image | head -1) +commit=$(ostree rev-parse --repo $repo ostree/container/image/$ref) +ostree ls --repo $repo -X ${commit} /usr/lib/systemd/system|grep -i cockpit >out.txt +if ! grep -q :cockpit_unit_file_t:s0 out.txt; then + echo "failed to find cockpit_unit_file_t" 1>&2 + exit 1 +fi + +echo ok "derived selinux" diff --git a/crates/ostree-ext/deny.toml b/crates/ostree-ext/deny.toml new file mode 100644 index 000000000..24802969c --- /dev/null +++ b/crates/ostree-ext/deny.toml @@ -0,0 +1,10 @@ +[licenses] +unlicensed = "deny" +allow = ["Apache-2.0", "Apache-2.0 WITH LLVM-exception", "MIT", "BSD-3-Clause", "BSD-2-Clause", "Unicode-DFS-2016"] + +[bans] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [] diff --git a/crates/ostree-ext/docs/container-image-import.dot b/crates/ostree-ext/docs/container-image-import.dot new file mode 100644 index 000000000..43545d52d --- /dev/null +++ b/crates/ostree-ext/docs/container-image-import.dot @@ -0,0 +1,139 @@ +digraph { + compound=true; + + subgraph cluster_import { + label="store::import"; + import_blob[label="blob"]; + import_driver[label="driver"]; + import_r[label="r"]; + import_r2[label="r",shape="tripleoctagon"]; + import_r -> import_r2[label="join_fetch"]; + import_driver -> import_r2[label="join_fetch"]; + } + + + subgraph cluster_fetch_layer { + label="unencapsulate::fetch_layer"; + fetch_layer_blob[label="blob"]; + fetch_layer_driver[label="driver"]; + fetch_layer_blob -> import_blob; + fetch_layer_driver -> import_driver; + } + + subgraph cluster_get_blob { + label="imageproxy::get_blob"; + get_blob_fd[label="(_,fd)"]; + get_blob_fd2[label="tokio::File(tokio::BufReader)"]; + get_blob_finish[label="finish"]; + get_blob_fd2 -> fetch_layer_blob; + get_blob_finish -> fetch_layer_driver; + get_blob_fd -> get_blob_fd2; + } + + subgraph cluster_impl_request { + label="imageproxy::impl_request"; + request_req[label="req"]; + request_req -> get_blob_fd; + } + + subgraph cluster_finish_pipe { + label="imageproxy::finish_pipe"; + finish_pipe_r_fd[label="(r,fd)"]; + finish_pipe_r[label="r"]; + request_req -> finish_pipe_r_fd; + finish_pipe_r_fd -> finish_pipe_r; + finish_pipe_r -> get_blob_finish; + } + + subgraph cluster_write_tar { + label="tar::write_tar"; + write_tar_src[label="src"]; + write_tar_filtered_result[label="filtered_result"]; + write_tar_output_copier[label="output_copier"]; + import_blob -> write_tar_src; + write_tar_ostree_commit_process[label=""]; + write_tar_child_stdin[label="child_stdin"]; + write_tar_ostree_commit_process -> write_tar_child_stdin; + + subgraph cluster_write_tar_status_future { + label="status Future"; + write_tar_status_future_r[label="r"]; + } + + write_tar_r[label="r"]; + write_tar_ostree_commit_process -> write_tar_r[label="spawn()"]; + write_tar_r -> write_tar_status_future_r; + + subgraph cluster_write_tar_output_copier_future { + label="output_copier Future"; + write_tar_output_copier_future_child_stdout[label="child_stdout"]; + write_tar_output_copier_future_child_stderr[label="child_stderr"]; + } + + write_tar_ostree_commit_process -> write_tar_output_copier_future_child_stdout; + write_tar_ostree_commit_process -> write_tar_output_copier_future_child_stderr; + + + write_tar_filtered_result2[label="filtered_result (in try_join block)"]; + + write_tar_status_future_r -> write_tar_filtered_result2[label="tokio::try_join!"]; + write_tar_filtered_result -> write_tar_filtered_result2[label="tokio::try_join!"]; + + write_tar_output_copier_await[label="output_copier.await"]; + write_tar_filtered_result2 -> write_tar_output_copier_await; + write_tar_output_copier -> write_tar_output_copier_await; + + write_tar_output_copier_future_child_stderr -> write_tar_output_copier[ltail=cluster_write_tar_output_copier_future]; + } + + subgraph cluster_filter_tar_async { + label="tar::filter_tar_async"; + + subgraph cluster_tar_transformer { + label="tar_transformer Future"; + tar_transformer_src[label="src"]; + + tar_transformer_src2[label="src: SyncIoBridge(src)"]; + tar_transformer_src -> tar_transformer_src2; + tar_transformer_src3[label="src: decompressor(src)"]; + tar_transformer_src2 -> tar_transformer_src3; + tar_transformer_tx_buf[label="tx_buf"]; + tar_transformer_dest[label="dest: SyncIoBridge(tx_buf)"]; + tar_transformer_tx_buf -> tar_transformer_dest; + } + + subgraph cluster_filter_tar_async_copier_future { + label="copier Future"; + filter_tar_async_copier_future_rx_buf[label="rx_buf"]; + filter_tar_async_copier_future_dest[label="dest"]; + } + + filter_tar_async_src[label="src"]; + filter_tar_async_dest[label="dest"]; + write_tar_src -> filter_tar_async_src; + filter_tar_async_tx_buf[label="tx_buf"]; + filter_tar_async_rx_buf[label="rx_buf"]; + write_tar_child_stdin -> filter_tar_async_dest; + filter_tar_async_src -> tar_transformer_src; + filter_tar_async_tx_buf -> tar_transformer_tx_buf; + filter_tar_async_rx_buf -> filter_tar_async_copier_future_rx_buf[label="&mut"]; + filter_tar_async_dest -> filter_tar_async_copier_future_dest[label="&mut"]; + filter_tar_async_r[label="r"]; + tar_transformer_src -> filter_tar_async_r[label="tokio::join!", ltail=cluster_tar_transformer]; + filter_tar_async_copier_future_dest -> filter_tar_async_r[label="tokio::join!", ltail=cluster_filter_tar_async_copier_future]; + filter_tar_async_r2[label="r"]; + filter_tar_async_src2[label="src"]; + + filter_tar_async_r -> filter_tar_async_r2; + filter_tar_async_r -> filter_tar_async_src2; + + filter_tar_async_r2 -> write_tar_filtered_result; + + filter_tar_async_drop_src[label="drop(src)"]; + filter_tar_async_src2 -> filter_tar_async_drop_src; + + } + + write_tar_output_copier_await -> import_r[ltail=cluster_write_tar]; + +} diff --git a/crates/ostree-ext/docs/container-image-import.svg b/crates/ostree-ext/docs/container-image-import.svg new file mode 100644 index 000000000..9cb697cd0 --- /dev/null +++ b/crates/ostree-ext/docs/container-image-import.svg @@ -0,0 +1,534 @@ + + + + + + + + +cluster_import + +store::import + + +cluster_fetch_layer + +unencapsulate::fetch_layer + + +cluster_get_blob + +imageproxy::get_blob + + +cluster_impl_request + +imageproxy::impl_request + + +cluster_finish_pipe + +imageproxy::finish_pipe + + +cluster_write_tar + +tar::write_tar + + +cluster_write_tar_status_future + +status Future + + +cluster_write_tar_output_copier_future + +output_copier Future + + +cluster_filter_tar_async + +tar::filter_tar_async + + +cluster_tar_transformer + +tar_transformer Future + + +cluster_filter_tar_async_copier_future + +copier Future + + + +import_blob + +blob + + + +write_tar_src + +src + + + +import_blob->write_tar_src + + + + + +import_driver + +driver + + + +import_r2 + + + +r + + + +import_driver->import_r2 + + +join_fetch + + + +import_r + +r + + + +import_r->import_r2 + + +join_fetch + + + +fetch_layer_blob + +blob + + + +fetch_layer_blob->import_blob + + + + + +fetch_layer_driver + +driver + + + +fetch_layer_driver->import_driver + + + + + +get_blob_fd + +(_,fd) + + + +get_blob_fd2 + +tokio::File(tokio::BufReader) + + + +get_blob_fd->get_blob_fd2 + + + + + +get_blob_fd2->fetch_layer_blob + + + + + +get_blob_finish + +finish + + + +get_blob_finish->fetch_layer_driver + + + + + +request_req + +req + + + +request_req->get_blob_fd + + + + + +finish_pipe_r_fd + +(r,fd) + + + +request_req->finish_pipe_r_fd + + + + + +finish_pipe_r + +r + + + +finish_pipe_r_fd->finish_pipe_r + + + + + +finish_pipe_r->get_blob_finish + + + + + +filter_tar_async_src + +src + + + +write_tar_src->filter_tar_async_src + + + + + +write_tar_filtered_result + +filtered_result + + + +write_tar_filtered_result2 + +filtered_result (in try_join block) + + + +write_tar_filtered_result->write_tar_filtered_result2 + + +tokio::try_join! + + + +write_tar_output_copier + +output_copier + + + +write_tar_output_copier_await + +output_copier.await + + + +write_tar_output_copier->write_tar_output_copier_await + + + + + +write_tar_ostree_commit_process + +<ostree commit process> + + + +write_tar_child_stdin + +child_stdin + + + +write_tar_ostree_commit_process->write_tar_child_stdin + + + + + +write_tar_r + +r + + + +write_tar_ostree_commit_process->write_tar_r + + +spawn() + + + +write_tar_output_copier_future_child_stdout + +child_stdout + + + +write_tar_ostree_commit_process->write_tar_output_copier_future_child_stdout + + + + + +write_tar_output_copier_future_child_stderr + +child_stderr + + + +write_tar_ostree_commit_process->write_tar_output_copier_future_child_stderr + + + + + +filter_tar_async_dest + +dest + + + +write_tar_child_stdin->filter_tar_async_dest + + + + + +write_tar_status_future_r + +r + + + +write_tar_status_future_r->write_tar_filtered_result2 + + +tokio::try_join! + + + +write_tar_r->write_tar_status_future_r + + + + + +write_tar_output_copier_future_child_stderr->write_tar_output_copier + + + + + +write_tar_filtered_result2->write_tar_output_copier_await + + + + + +write_tar_output_copier_await->import_r + + + + + +tar_transformer_src + +src + + + +tar_transformer_src2 + +src: SyncIoBridge(src) + + + +tar_transformer_src->tar_transformer_src2 + + + + + +filter_tar_async_r + +r + + + +tar_transformer_src->filter_tar_async_r + + +tokio::join! + + + +tar_transformer_src3 + +src: decompressor(src) + + + +tar_transformer_src2->tar_transformer_src3 + + + + + +tar_transformer_tx_buf + +tx_buf + + + +tar_transformer_dest + +dest: SyncIoBridge(tx_buf) + + + +tar_transformer_tx_buf->tar_transformer_dest + + + + + +filter_tar_async_copier_future_rx_buf + +rx_buf + + + +filter_tar_async_copier_future_dest + +dest + + + +filter_tar_async_copier_future_dest->filter_tar_async_r + + +tokio::join! + + + +filter_tar_async_src->tar_transformer_src + + + + + +filter_tar_async_dest->filter_tar_async_copier_future_dest + + +&mut + + + +filter_tar_async_tx_buf + +tx_buf + + + +filter_tar_async_tx_buf->tar_transformer_tx_buf + + + + + +filter_tar_async_rx_buf + +rx_buf + + + +filter_tar_async_rx_buf->filter_tar_async_copier_future_rx_buf + + +&mut + + + +filter_tar_async_r2 + +r + + + +filter_tar_async_r->filter_tar_async_r2 + + + + + +filter_tar_async_src2 + +src + + + +filter_tar_async_r->filter_tar_async_src2 + + + + + +filter_tar_async_r2->write_tar_filtered_result + + + + + +filter_tar_async_drop_src + +drop(src) + + + +filter_tar_async_src2->filter_tar_async_drop_src + + + + + diff --git a/crates/ostree-ext/docs/questions-and-answers.md b/crates/ostree-ext/docs/questions-and-answers.md new file mode 100644 index 000000000..2c7abb32a --- /dev/null +++ b/crates/ostree-ext/docs/questions-and-answers.md @@ -0,0 +1,40 @@ +# Questions and answers + +## module "container": Encapsulate OSTree commits in OCI/Docker images + +### How is this different from the "tarball-of-archive-repo" approach currently used in RHEL CoreOS? Aren't both encapsulating an OSTree commit in an OCI image? + +- The "tarball-of-archive-repo" approach is essentially just putting an OSTree repo in archive mode under `/srv` as an additional layer over a regular RHEL base image. In the new data format, users can do e.g. `podman run --rm -ti quay.io/fedora/fedora-coreos:stable bash`. This could be quite useful for some tests for OSTree commits (at one point we had a test that literally booted a whole VM to run `rpm -q` - it'd be much cheaper to do those kinds of "OS sanity checks" in a container). + +- The new data format is intentionally designed to be streamed; the files inside the tarball are ordered by (commit, metadata, content ...). With "tarball-of-archive-repo" as is today that's not true, so we need to pull and extract the whole thing to a temporary location, which is inefficient. See also https://github.com/ostreedev/ostree-rs-ext/issues/1. + +- We have a much clearer story for adding Docker/OCI style _derivation_ later. + +- The new data format abstracts away OSTree a bit more and avoids needing people to think about OSTree unnecessarily. + +### Why pull from a container image instead of the current (older) method of pulling from OSTree repos? + +A good example is for people who want to do offline/disconnected installations and updates. They will almost certainly have container images they want to pull too - now the OS is just another container image. Users no longer need to mirror OSTree repos. Overall, as mentioned already, we want to abstract away OSTree a bit more. + +### Can users view this as a regular container image? + +Yes, and it also provides some extras. In addition to being able to be run as a container, if the host is OSTree-based, the host itself can be deployed/updated into this image, too. There is also GPG signing and per-file integrity validation that comes with OSTree. + +### So then would this OSTree commit in container image also work as a bootimage (bootable from a USB drive)? + +No. Though there could certainly be kernels and initramfses in the (OSTree commit in the) container image, that doesn't make it bootable. OSTree _understands_ bootloaders and can update kernels/initramfs images, but it doesn't update bootloaders, that is [bootupd](https://github.com/coreos/bootupd)'s job. Furthermore, this is still a container image, made of tarballs and manifests; it is not formatted to be a disk image (e.g. it doesn't have a FAT32 formatted ESP). Related to this topic is https://github.com/iximiuz/docker-to-linux, which illustrates the difference between a docker image and a bootable image. +TL;DR, OSTree commit in container image is meant only to deliver OS updates (OSTree commits), not bootable disk images. + +### How much deduplication do we still get with this new approach? + +Unfortunately, today, we do indeed need to download more than actually needed, but the files will still be deduplicated on disk, just like before. So we still won't be storing extra files, but we will be downloading extra files. +But for users doing offline mirroring, this shouldn't matter that much. In OpenShift, the entire image is downloaded today, as well. +Nevertheless, see https://github.com/ostreedev/ostree-rs-ext/#integrating-with-future-container-deltas. + +### Will there be support for "layers" in the OSTree commit in container image? + +Not yet, but, as mentioned above, this opens up the possibility of doing OCI style derivation, so this could certainly be added later. It would be useful to make this image as familiar to admins as possible. Right now, the ostree-rs-ext client is only parsing one layer of the container image. + +### How will mirroring image registries work? + +since ostree-rs-ext uses skopeo (which uses `containers/image`), mirroring is transparently supported, i.e. admins can configure their mirroring in `containers-registries.conf` and it'll just work. diff --git a/crates/ostree-ext/man/ostree-container-auth.md b/crates/ostree-ext/man/ostree-container-auth.md new file mode 100644 index 000000000..234f49959 --- /dev/null +++ b/crates/ostree-ext/man/ostree-container-auth.md @@ -0,0 +1,29 @@ +% ostree-container-auth 5 + +# NAME +ostree-container-auth description of the registry authentication file + +# DESCRIPTION + +The OSTree container stack uses the same file formats as **containers-auth(5)** but +not the same locations. + +When running as uid 0 (root), the tooling uses `/etc/ostree/auth.json` first, then looks +in `/run/ostree/auth.json`, and finally checks `/usr/lib/ostree/auth.json`. +For any other uid, the file paths used are in `${XDG_RUNTIME_DIR}/ostree/auth.json`. + +In the future, it is likely that a path that is supported for both "system podman" +usage and ostree will be added. + +## FORMAT + +The auth.json file stores, or references, credentials that allow the user to authenticate +to container image registries. +It is primarily managed by a `login` command from a container tool such as `podman login`, +`buildah login`, or `skopeo login`. + +For more information, see **containers-auth(5)**. + +# SEE ALSO + +**containers-auth(5)**, **skopeo-login(1)**, **skopeo-logout(1)** diff --git a/crates/ostree-ext/ostree-and-containers.md b/crates/ostree-ext/ostree-and-containers.md new file mode 100644 index 000000000..8e4b9c6e4 --- /dev/null +++ b/crates/ostree-ext/ostree-and-containers.md @@ -0,0 +1,65 @@ +# ostree vs OCI/Docker + +Be sure to see the main [README.md](README.md) which describes the current architecture intersecting ostree and OCI. + +Looking at this project, one might ask: why even have ostree? Why not just have the operating system directly use something like the [containers/image](https://github.com/containers/image/) storage? + +The first answer to this is that it's a goal of this project to "hide" ostree usage; it should feel "native" to ship and manage the operating system "as if" it was just running a container. + +But, ostree has a *lot* of stuff built up around it and we can't just throw that away. + +## Understanding kernels + +ostree was designed from the start to manage bootable operating system trees - hence the name of the project. For example, ostree understands bootloaders and kernels/initramfs images. Container tools don't. + +## Signing + +ostree also quite early on gained an opinionated mechanism to sign images (commits) via GPG. As of this time there are multiple competing mechanisms for container signing, and it is not widely deployed. +For running random containers from `docker.io`, it can be OK to just trust TLS or pin via `@sha256` - a whole idea of Docker is that containers are isolated and it should be reasonably safe to +at least try out random containers. But for the *operating system* its integrity is paramount because it's ultimately trusted. + +## Deduplication + +ostree's hardlink store is designed around de-duplication. Operating systems can get large and they are most natural as "base images" - which in the Docker container model +are duplicated on disk. Of course storage systems like containers/image could learn to de-duplicate; but it would be a use case that *mostly* applied to just the operating system. + +## Being able to remove all container images + +In Kubernetes, the kubelet will prune the image storage periodically, removing images not backed by containers. If we store the operating system itself as an image...well, we'd need to do something like teach the container storage to have the concept of an image that is "pinned" because it's actually the booted filesystem. Or create a "fake" container representing the running operating system. + +Other projects in this space ended up having an "early docker" distinct from the "main docker" which brings its own large set of challenges. + +## SELinux + +OSTree has *first class* support for SELinux. It was baked into the design from the very start. Handling SELinux is very tricky because it's a part of the operating system that can influence *everything else*. And specifically file labels. + +In this approach we aren't trying to inject xattrs into the tar stream; they're stored out of band for reliability. + +## Independence of complexity of container storage + +This stuff could be done - but the container storage and tooling is already quite complex, and introducing a special case like this would be treading into new ground. + +Today for example, cri-o ships a `crio-wipe.service` which removes all container storage across major version upgrades. + +ostree is a fairly simple format and has been 100% stable throughout its life so far. + +## ostree format has per-file integrity + +More on this here: https://ostreedev.github.io/ostree/related-projects/#docker + +## Allow hiding ostree while not reinventing everything + +So, again the goal here is: make it feel "native" to ship and manage the operating system "as if" it was just running a container without throwing away everything in ostree today. + + +### Future: Running an ostree-container as a webserver + +It also should work to run the ostree-container as a webserver, which will expose a webserver that responds to `GET /repo`. + +The effect will be as if it was built from a `Dockerfile` that contains `EXPOSE 8080`; it will work to e.g. +`kubectl run nginx --image=quay.io/exampleos/exampleos:latest --replicas=1` +and then also create a service for it. + +### Integrating with future container deltas + +See https://blogs.gnome.org/alexl/2020/05/13/putting-container-updates-on-a-diet/ diff --git a/crates/ostree-ext/src/bootabletree.rs b/crates/ostree-ext/src/bootabletree.rs new file mode 100644 index 000000000..0b9d2c04a --- /dev/null +++ b/crates/ostree-ext/src/bootabletree.rs @@ -0,0 +1,139 @@ +//! Helper functions for bootable OSTrees. + +use std::path::Path; + +use anyhow::Result; +use camino::Utf8Path; +use camino::Utf8PathBuf; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use ostree::gio; +use ostree::prelude::*; + +const MODULES: &str = "usr/lib/modules"; +const VMLINUZ: &str = "vmlinuz"; +const ABOOT_IMG: &str = "aboot.img"; + +/// Find the kernel modules directory in a bootable OSTree commit. +/// The target directory will have a `vmlinuz` file representing the kernel binary. +pub fn find_kernel_dir( + root: &gio::File, + cancellable: Option<&gio::Cancellable>, +) -> Result> { + let moddir = root.resolve_relative_path(MODULES); + let e = moddir.enumerate_children( + "standard::name", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + cancellable, + )?; + let mut r = None; + for child in e.clone() { + let child = &child?; + if child.file_type() != gio::FileType::Directory { + continue; + } + let childpath = e.child(child); + let vmlinuz = childpath.child(VMLINUZ); + if !vmlinuz.query_exists(cancellable) { + continue; + } + if r.replace(childpath).is_some() { + anyhow::bail!("Found multiple subdirectories in {}", MODULES); + } + } + Ok(r) +} + +fn read_dir_optional( + d: &Dir, + p: impl AsRef, +) -> std::io::Result> { + match d.read_dir(p.as_ref()) { + Ok(r) => Ok(Some(r)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Find the kernel modules directory in checked out directory tree. +/// The target directory will have a `vmlinuz` file representing the kernel binary. +pub fn find_kernel_dir_fs(root: &Dir) -> Result> { + let mut r = None; + let entries = if let Some(entries) = read_dir_optional(root, MODULES)? { + entries + } else { + return Ok(None); + }; + for child in entries { + let child = &child?; + if !child.file_type()?.is_dir() { + continue; + } + let name = child.file_name(); + let name = if let Some(n) = name.to_str() { + n + } else { + continue; + }; + let mut pbuf = Utf8Path::new(MODULES).to_owned(); + pbuf.push(name); + pbuf.push(VMLINUZ); + if !root.try_exists(&pbuf)? { + continue; + } + pbuf.pop(); + if r.replace(pbuf).is_some() { + anyhow::bail!("Found multiple subdirectories in {}", MODULES); + } + } + Ok(r) +} + +/// Check if there is an aboot image in the kernel tree dir +pub fn commit_has_aboot_img( + root: &gio::File, + cancellable: Option<&gio::Cancellable>, +) -> Result { + if let Some(kernel_dir) = find_kernel_dir(root, cancellable)? { + Ok(kernel_dir + .resolve_relative_path(ABOOT_IMG) + .query_exists(cancellable)) + } else { + Ok(false) + } +} + +#[cfg(test)] +mod test { + use super::*; + use cap_std_ext::{cap_std, cap_tempfile}; + + #[test] + fn test_find_kernel_dir_fs() -> Result<()> { + let td = cap_tempfile::tempdir(cap_std::ambient_authority())?; + + // Verify the empty case + assert!(find_kernel_dir_fs(&td).unwrap().is_none()); + let moddir = Utf8Path::new("usr/lib/modules"); + td.create_dir_all(moddir)?; + assert!(find_kernel_dir_fs(&td).unwrap().is_none()); + + let kpath = moddir.join("5.12.8-32.aarch64"); + td.create_dir_all(&kpath)?; + td.write(kpath.join("vmlinuz"), "some kernel")?; + let kpath2 = moddir.join("5.13.7-44.aarch64"); + td.create_dir_all(&kpath2)?; + td.write(kpath2.join("foo.ko"), "some kmod")?; + + assert_eq!( + find_kernel_dir_fs(&td) + .unwrap() + .unwrap() + .file_name() + .unwrap(), + kpath.file_name().unwrap() + ); + + Ok(()) + } +} diff --git a/crates/ostree-ext/src/chunking.rs b/crates/ostree-ext/src/chunking.rs new file mode 100644 index 000000000..07366f06d --- /dev/null +++ b/crates/ostree-ext/src/chunking.rs @@ -0,0 +1,1469 @@ +//! Split an OSTree commit into separate chunks + +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::borrow::{Borrow, Cow}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; +use std::hash::{Hash, Hasher}; +use std::num::NonZeroU32; +use std::rc::Rc; +use std::time::Instant; + +use crate::container::{COMPONENT_SEPARATOR, CONTENT_ANNOTATION}; +use crate::objectsource::{ContentID, ObjectMeta, ObjectMetaMap, ObjectSourceMeta}; +use crate::objgv::*; +use crate::statistics; +use anyhow::{anyhow, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use containers_image_proxy::oci_spec; +use gvariant::aligned_bytes::TryAsAligned; +use gvariant::{Marker, Structure}; +use indexmap::IndexMap; +use ostree::{gio, glib}; +use serde::{Deserialize, Serialize}; + +/// Maximum number of layers (chunks) we will use. +// We take half the limit of 128. +// https://github.com/ostreedev/ostree-rs-ext/issues/69 +pub(crate) const MAX_CHUNKS: u32 = 64; +/// Minimum number of layers we can create in a "chunked" flow; otherwise +/// we will just drop down to one. +const MIN_CHUNKED_LAYERS: u32 = 4; + +/// A convenient alias for a reference-counted, immutable string. +pub(crate) type RcStr = Rc; +/// Maps from a checksum to its size and file names (multiple in the case of +/// hard links). +pub(crate) type ChunkMapping = BTreeMap)>; +// TODO type PackageSet = HashSet; + +const LOW_PARTITION: &str = "2ls"; +const HIGH_PARTITION: &str = "1hs"; + +#[derive(Debug, Default)] +pub(crate) struct Chunk { + pub(crate) name: String, + pub(crate) content: ChunkMapping, + pub(crate) size: u64, + pub(crate) packages: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +/// Object metadata, but with additional size data +pub struct ObjectSourceMetaSized { + /// The original metadata + #[serde(flatten)] + pub meta: ObjectSourceMeta, + /// Total size of associated objects + pub size: u64, +} + +impl Hash for ObjectSourceMetaSized { + fn hash(&self, state: &mut H) { + self.meta.identifier.hash(state); + } +} + +impl Eq for ObjectSourceMetaSized {} + +impl PartialEq for ObjectSourceMetaSized { + fn eq(&self, other: &Self) -> bool { + self.meta.identifier == other.meta.identifier + } +} + +/// Extend content source metadata with sizes. +#[derive(Debug)] +pub struct ObjectMetaSized { + /// Mapping from content object to source. + pub map: ObjectMetaMap, + /// Computed sizes of each content source + pub sizes: Vec, +} + +impl ObjectMetaSized { + /// Given object metadata and a repo, compute the size of each content source. + pub fn compute_sizes(repo: &ostree::Repo, meta: ObjectMeta) -> Result { + let cancellable = gio::Cancellable::NONE; + // Destructure into component parts; we'll create the version with sizes + let map = meta.map; + let mut set = meta.set; + // Maps content id -> total size of associated objects + let mut sizes = BTreeMap::<&str, u64>::new(); + // Populate two mappings above, iterating over the object -> contentid mapping + for (checksum, contentid) in map.iter() { + let finfo = repo.query_file(checksum, cancellable)?.0; + let sz = sizes.entry(contentid).or_default(); + *sz += finfo.size() as u64; + } + // Combine data from sizes and the content mapping. + let sized: Result> = sizes + .into_iter() + .map(|(id, size)| -> Result { + set.take(id) + .ok_or_else(|| anyhow!("Failed to find {} in content set", id)) + .map(|meta| ObjectSourceMetaSized { meta, size }) + }) + .collect(); + let mut sizes = sized?; + sizes.sort_by(|a, b| b.size.cmp(&a.size)); + Ok(ObjectMetaSized { map, sizes }) + } +} + +/// How to split up an ostree commit into "chunks" - designed to map to container image layers. +#[derive(Debug, Default)] +pub struct Chunking { + pub(crate) metadata_size: u64, + pub(crate) remainder: Chunk, + pub(crate) chunks: Vec, + + pub(crate) max: u32, + + processed_mapping: bool, + /// Number of components (e.g. packages) provided originally + pub(crate) n_provided_components: u32, + /// The above, but only ones with non-zero size + pub(crate) n_sized_components: u32, +} + +#[derive(Default)] +struct Generation { + path: Utf8PathBuf, + metadata_size: u64, + dirtree_found: BTreeSet, + dirmeta_found: BTreeSet, +} + +fn push_dirmeta(repo: &ostree::Repo, gen: &mut Generation, checksum: &str) -> Result<()> { + if gen.dirtree_found.contains(checksum) { + return Ok(()); + } + let checksum = RcStr::from(checksum); + gen.dirmeta_found.insert(RcStr::clone(&checksum)); + let child_v = repo.load_variant(ostree::ObjectType::DirMeta, checksum.borrow())?; + gen.metadata_size += child_v.data_as_bytes().as_ref().len() as u64; + Ok(()) +} + +fn push_dirtree( + repo: &ostree::Repo, + gen: &mut Generation, + checksum: &str, +) -> Result { + let child_v = repo.load_variant(ostree::ObjectType::DirTree, checksum)?; + if !gen.dirtree_found.contains(checksum) { + gen.metadata_size += child_v.data_as_bytes().as_ref().len() as u64; + } else { + let checksum = RcStr::from(checksum); + gen.dirtree_found.insert(checksum); + } + Ok(child_v) +} + +fn generate_chunking_recurse( + repo: &ostree::Repo, + gen: &mut Generation, + chunk: &mut Chunk, + dt: &glib::Variant, +) -> Result<()> { + let dt = dt.data_as_bytes(); + let dt = dt.try_as_aligned()?; + let dt = gv_dirtree!().cast(dt); + let (files, dirs) = dt.to_tuple(); + // A reusable buffer to avoid heap allocating these + let mut hexbuf = [0u8; 64]; + for file in files { + let (name, csum) = file.to_tuple(); + let fpath = gen.path.join(name.to_str()); + hex::encode_to_slice(csum, &mut hexbuf)?; + let checksum = std::str::from_utf8(&hexbuf)?; + let meta = repo.query_file(checksum, gio::Cancellable::NONE)?.0; + let size = meta.size() as u64; + let entry = chunk.content.entry(RcStr::from(checksum)).or_default(); + entry.0 = size; + let first = entry.1.is_empty(); + if first { + chunk.size += size; + } + entry.1.push(fpath); + } + for item in dirs { + let (name, contents_csum, meta_csum) = item.to_tuple(); + let name = name.to_str(); + // Extend our current path + gen.path.push(name); + hex::encode_to_slice(contents_csum, &mut hexbuf)?; + let checksum_s = std::str::from_utf8(&hexbuf)?; + let dirtree_v = push_dirtree(repo, gen, checksum_s)?; + generate_chunking_recurse(repo, gen, chunk, &dirtree_v)?; + drop(dirtree_v); + hex::encode_to_slice(meta_csum, &mut hexbuf)?; + let checksum_s = std::str::from_utf8(&hexbuf)?; + push_dirmeta(repo, gen, checksum_s)?; + // We did a push above, so pop must succeed. + assert!(gen.path.pop()); + } + Ok(()) +} + +impl Chunk { + fn new(name: &str) -> Self { + Chunk { + name: name.to_string(), + ..Default::default() + } + } + + pub(crate) fn move_obj(&mut self, dest: &mut Self, checksum: &str) -> bool { + // In most cases, we expect the object to exist in the source. However, it's + // conveneient here to simply ignore objects which were already moved into + // a chunk. + if let Some((name, (size, paths))) = self.content.remove_entry(checksum) { + let v = dest.content.insert(name, (size, paths)); + debug_assert!(v.is_none()); + self.size -= size; + dest.size += size; + true + } else { + false + } + } + + pub(crate) fn move_path(&mut self, dest: &mut Self, checksum: &str, path: &Utf8Path) { + if let Some((_size, paths)) = self.content.get_mut(checksum) { + let path_index = paths.iter().position(|p| *p == path); + if let Some(index) = path_index { + let removed_path = paths.remove(index); + + let dest_entry = dest + .content + .entry(RcStr::from(checksum)) + .or_insert((0, Vec::new())); + dest_entry.1.push(removed_path); + + if paths.is_empty() { + self.content.remove(checksum); + } + } + } + } +} + +impl Chunking { + /// Creates a reverse map from content IDs to checksums + fn create_content_id_map( + map: &IndexMap, + ) -> IndexMap> { + let mut rmap = IndexMap::>::new(); + for (checksum, contentid) in map.iter() { + rmap.entry(Rc::clone(contentid)).or_default().push(checksum); + } + rmap + } + + /// Generate an initial single chunk. + pub fn new(repo: &ostree::Repo, rev: &str) -> Result { + // Find the target commit + let rev = repo.require_rev(rev)?; + + // Load and parse the commit object + let (commit_v, _) = repo.load_commit(&rev)?; + let commit_v = commit_v.data_as_bytes(); + let commit_v = commit_v.try_as_aligned()?; + let commit = gv_commit!().cast(commit_v); + let commit = commit.to_tuple(); + + // Load it all into a single chunk + let mut gen = Generation { + path: Utf8PathBuf::from("/"), + ..Default::default() + }; + let mut chunk: Chunk = Default::default(); + + // Find the root directory tree + let contents_checksum = &hex::encode(commit.6); + let contents_v = repo.load_variant(ostree::ObjectType::DirTree, contents_checksum)?; + push_dirtree(repo, &mut gen, contents_checksum)?; + let meta_checksum = &hex::encode(commit.7); + push_dirmeta(repo, &mut gen, meta_checksum.as_str())?; + + generate_chunking_recurse(repo, &mut gen, &mut chunk, &contents_v)?; + + let chunking = Chunking { + metadata_size: gen.metadata_size, + remainder: chunk, + ..Default::default() + }; + Ok(chunking) + } + + /// Generate a chunking from an object mapping. + pub fn from_mapping( + repo: &ostree::Repo, + rev: &str, + meta: &ObjectMetaSized, + max_layers: &Option, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, + specific_contentmeta: Option<&BTreeMap>>, + ) -> Result { + let mut r = Self::new(repo, rev)?; + r.process_mapping(meta, max_layers, prior_build_metadata, specific_contentmeta)?; + Ok(r) + } + + fn remaining(&self) -> u32 { + self.max.saturating_sub(self.chunks.len() as u32) + } + + /// Given metadata about which objects are owned by a particular content source, + /// generate chunks that group together those objects. + #[allow(clippy::or_fun_call)] + pub fn process_mapping( + &mut self, + meta: &ObjectMetaSized, + max_layers: &Option, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, + specific_contentmeta: Option<&BTreeMap>>, + ) -> Result<()> { + self.max = max_layers + .unwrap_or(NonZeroU32::new(MAX_CHUNKS).unwrap()) + .get(); + + let sizes = &meta.sizes; + // It doesn't make sense to handle multiple mappings + assert!(!self.processed_mapping); + self.processed_mapping = true; + let remaining = self.remaining(); + if remaining == 0 { + return Ok(()); + } + + // Create exclusive chunks first if specified + let mut processed_specific_components = BTreeSet::new(); + if let Some(specific_meta) = specific_contentmeta { + for (component, files) in specific_meta { + let mut chunk = Chunk::new(&component); + chunk.packages = vec![component.to_string()]; + + // Move all objects belonging to this exclusive component + for (path, checksum) in files { + self.remainder + .move_path(&mut chunk, checksum.as_str(), path); + } + + self.chunks.push(chunk); + processed_specific_components.insert(component.clone()); + } + } + + // Safety: Let's assume no one has over 4 billion components. + self.n_provided_components = meta.sizes.len().try_into().unwrap(); + self.n_sized_components = sizes + .iter() + .filter(|v| v.size > 0) + .count() + .try_into() + .unwrap(); + + // Filter out exclusive components for regular packing + let regular_sizes: Vec = sizes + .iter() + .filter(|component| { + !processed_specific_components.contains(&*component.meta.identifier) + }) + .cloned() + .collect(); + + let rmap = Self::create_content_id_map(&meta.map); + + // Process regular components with bin packing if we have remaining layers + if let Some(remaining) = NonZeroU32::new(self.remaining()) { + let start = Instant::now(); + let packing = basic_packing(®ular_sizes, remaining, prior_build_metadata)?; + let duration = start.elapsed(); + tracing::debug!("Time elapsed in packing: {:#?}", duration); + + for bin in packing.into_iter() { + let name = match bin.len() { + 0 => Cow::Borrowed("Reserved for new packages"), + 1 => { + let first = bin[0]; + let first_name = &*first.meta.identifier; + Cow::Borrowed(first_name) + } + 2..=5 => { + let first = bin[0]; + let first_name = &*first.meta.identifier; + let r = bin.iter().map(|v| &*v.meta.identifier).skip(1).fold( + String::from(first_name), + |mut acc, v| { + write!(acc, " and {v}").unwrap(); + acc + }, + ); + Cow::Owned(r) + } + n => Cow::Owned(format!("{n} components")), + }; + let mut chunk = Chunk::new(&name); + chunk.packages = bin.iter().map(|v| String::from(&*v.meta.name)).collect(); + for szmeta in bin { + for &obj in rmap.get(&szmeta.meta.identifier).unwrap() { + self.remainder.move_obj(&mut chunk, obj.as_str()); + } + } + self.chunks.push(chunk); + } + } + + // Check that all objects have been processed + if !processed_specific_components.is_empty() || !regular_sizes.is_empty() { + assert_eq!(self.remainder.content.len(), 0); + } + + Ok(()) + } + + pub(crate) fn take_chunks(&mut self) -> Vec { + let mut r = Vec::new(); + std::mem::swap(&mut self.chunks, &mut r); + r + } + + /// Print information about chunking to standard output. + pub fn print(&self) { + println!("Metadata: {}", glib::format_size(self.metadata_size)); + if self.n_provided_components > 0 { + println!( + "Components: provided={} sized={}", + self.n_provided_components, self.n_sized_components + ); + } + for (n, chunk) in self.chunks.iter().enumerate() { + let sz = glib::format_size(chunk.size); + println!( + "Chunk {}: \"{}\": objects:{} size:{}", + n, + chunk.name, + chunk.content.len(), + sz + ); + } + if !self.remainder.content.is_empty() { + let sz = glib::format_size(self.remainder.size); + println!( + "Remainder: \"{}\": objects:{} size:{}", + self.remainder.name, + self.remainder.content.len(), + sz + ); + } + } +} + +#[cfg(test)] +fn components_size(components: &[&ObjectSourceMetaSized]) -> u64 { + components.iter().map(|k| k.size).sum() +} + +/// Compute the total size of a packing +#[cfg(test)] +fn packing_size(packing: &[Vec<&ObjectSourceMetaSized>]) -> u64 { + packing.iter().map(|v| components_size(v)).sum() +} + +/// Given a certain threshold, divide a list of packages into all combinations +/// of (high, medium, low) size and (high,medium,low) using the following +/// outlier detection methods: +/// - Median and Median Absolute Deviation Method +/// Aggressively detects outliers in size and classifies them by +/// high, medium, low. The high size and low size are separate partitions +/// and deserve bins of their own +/// - Mean and Standard Deviation Method +/// The medium partition from the previous step is less aggressively +/// classified by using mean for both size and frequency +/// +/// Note: Assumes components is sorted by descending size +fn get_partitions_with_threshold<'a>( + components: &[&'a ObjectSourceMetaSized], + limit_hs_bins: usize, + threshold: f64, +) -> Option>> { + let mut partitions: BTreeMap> = BTreeMap::new(); + let mut med_size: Vec<&ObjectSourceMetaSized> = Vec::new(); + let mut high_size: Vec<&ObjectSourceMetaSized> = Vec::new(); + + let mut sizes: Vec = components.iter().map(|a| a.size).collect(); + let (median_size, mad_size) = statistics::median_absolute_deviation(&mut sizes)?; + + // We use abs here to ensure the lower limit stays positive + let size_low_limit = 0.5 * f64::abs(median_size - threshold * mad_size); + let size_high_limit = median_size + threshold * mad_size; + + for pkg in components { + let size = pkg.size as f64; + + // high size (hs) + if size >= size_high_limit { + high_size.push(pkg); + } + // low size (ls) + else if size <= size_low_limit { + partitions + .entry(LOW_PARTITION.to_string()) + .and_modify(|bin| bin.push(pkg)) + .or_insert_with(|| vec![pkg]); + } + // medium size (ms) + else { + med_size.push(pkg); + } + } + + // Extra high-size packages + let mut remaining_pkgs: Vec<_> = if high_size.len() <= limit_hs_bins { + Vec::new() + } else { + high_size.drain(limit_hs_bins..).collect() + }; + assert!(high_size.len() <= limit_hs_bins); + + // Concatenate extra high-size packages + med_sizes to keep it descending sorted + remaining_pkgs.append(&mut med_size); + partitions.insert(HIGH_PARTITION.to_string(), high_size); + + // Ascending sorted by frequency, so each partition within medium-size is freq sorted + remaining_pkgs.sort_by(|a, b| { + a.meta + .change_frequency + .partial_cmp(&b.meta.change_frequency) + .unwrap() + }); + let med_sizes: Vec = remaining_pkgs.iter().map(|a| a.size).collect(); + let med_frequencies: Vec = remaining_pkgs + .iter() + .map(|a| a.meta.change_frequency.into()) + .collect(); + + let med_mean_freq = statistics::mean(&med_frequencies)?; + let med_stddev_freq = statistics::std_deviation(&med_frequencies)?; + let med_mean_size = statistics::mean(&med_sizes)?; + let med_stddev_size = statistics::std_deviation(&med_sizes)?; + + // We use abs to avoid the lower limit being negative + let med_freq_low_limit = 0.5f64 * f64::abs(med_mean_freq - threshold * med_stddev_freq); + let med_freq_high_limit = med_mean_freq + threshold * med_stddev_freq; + let med_size_low_limit = 0.5f64 * f64::abs(med_mean_size - threshold * med_stddev_size); + let med_size_high_limit = med_mean_size + threshold * med_stddev_size; + + for pkg in remaining_pkgs { + let size = pkg.size as f64; + let freq = pkg.meta.change_frequency as f64; + + let size_name; + if size >= med_size_high_limit { + size_name = "hs"; + } else if size <= med_size_low_limit { + size_name = "ls"; + } else { + size_name = "ms"; + } + + // Numbered to maintain order of partitions in a BTreeMap of hf, mf, lf + let freq_name; + if freq >= med_freq_high_limit { + freq_name = "3hf"; + } else if freq <= med_freq_low_limit { + freq_name = "5lf"; + } else { + freq_name = "4mf"; + } + + let bucket = format!("{freq_name}_{size_name}"); + partitions + .entry(bucket.to_string()) + .and_modify(|bin| bin.push(pkg)) + .or_insert_with(|| vec![pkg]); + } + + for (name, pkgs) in &partitions { + tracing::debug!("{:#?}: {:#?}", name, pkgs.len()); + } + + Some(partitions) +} + +/// If the current rpm-ostree commit to be encapsulated is not the one in which packing structure changes, then +/// Flatten out prior_build_metadata to view all the packages in prior build as a single vec +/// Compare the flattened vector to components to see if pkgs added, updated, +/// removed or kept same +/// if pkgs added, then add them to the last bin of prior +/// if pkgs removed, then remove them from the prior[i] +/// iterate through prior[i] and make bins according to the name in nevra of pkgs to update +/// required packages +/// else if pkg structure to be changed || prior build not specified +/// Recompute optimal packaging structure (Compute partitions, place packages and optimize build) +fn basic_packing_with_prior_build<'a>( + components: &'a [ObjectSourceMetaSized], + bin_size: NonZeroU32, + prior_build: &oci_spec::image::ImageManifest, +) -> Result>> { + let before_processing_pkgs_len = components.len(); + + tracing::debug!("Keeping old package structure"); + + // The first layer is the ostree commit, which will always be different for different builds, + // so we ignore it. For the remaining layers, extract the components/packages in each one. + let curr_build: Result>> = prior_build + .layers() + .iter() + .skip(1) + .map(|layer| -> Result<_> { + let annotation_layer = layer + .annotations() + .as_ref() + .and_then(|annos| annos.get(CONTENT_ANNOTATION)) + .ok_or_else(|| anyhow!("Missing {CONTENT_ANNOTATION} on prior build"))?; + Ok(annotation_layer + .split(COMPONENT_SEPARATOR) + .map(ToOwned::to_owned) + .collect()) + }) + .collect(); + let mut curr_build = curr_build?; + + // View the packages as unordered sets for lookups and differencing + let prev_pkgs_set: BTreeSet = curr_build + .iter() + .flat_map(|v| v.iter().cloned()) + .filter(|name| !name.is_empty()) + .collect(); + let curr_pkgs_set: BTreeSet = components + .iter() + .map(|pkg| pkg.meta.name.to_string()) + .collect(); + + // Added packages are included in the last bin which was reserved space. + if let Some(last_bin) = curr_build.last_mut() { + let added = curr_pkgs_set.difference(&prev_pkgs_set); + last_bin.retain(|name| !name.is_empty()); + last_bin.extend(added.into_iter().cloned()); + } else { + panic!("No empty last bin for added packages"); + } + + // Handle removed packages + let removed: BTreeSet<&String> = prev_pkgs_set.difference(&curr_pkgs_set).collect(); + for bin in curr_build.iter_mut() { + bin.retain(|pkg| !removed.contains(pkg)); + } + + // Handle updated packages + let mut name_to_component: BTreeMap = BTreeMap::new(); + for component in components.iter() { + name_to_component + .entry(component.meta.name.to_string()) + .or_insert(component); + } + let mut modified_build: Vec> = Vec::new(); + for bin in curr_build { + let mut mod_bin = Vec::new(); + for pkg in bin { + // An empty component set can happen for the ostree commit layer; ignore that. + if pkg.is_empty() { + continue; + } + mod_bin.push(name_to_component[&pkg]); + } + modified_build.push(mod_bin); + } + + // Verify all packages are included + let after_processing_pkgs_len: usize = modified_build.iter().map(|b| b.len()).sum(); + assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len); + assert!(modified_build.len() <= bin_size.get() as usize); + Ok(modified_build) +} + +/// Given a set of components with size metadata (e.g. boxes of a certain size) +/// and a number of bins (possible container layers) to use, determine which components +/// go in which bin. This algorithm is pretty simple: +/// Total available bins = n +/// +/// 1 bin for all the u32_max frequency pkgs +/// 1 bin for all newly added pkgs +/// 1 bin for all low size pkgs +/// +/// 60% of n-3 bins for high size pkgs +/// 40% of n-3 bins for medium size pkgs +/// +/// If HS bins > limit, spillover to MS to package +/// If MS bins > limit, fold by merging 2 bins from the end +/// +fn basic_packing<'a>( + components: &'a [ObjectSourceMetaSized], + bin_size: NonZeroU32, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, +) -> Result>> { + const HIGH_SIZE_CUTOFF: f32 = 0.6; + let before_processing_pkgs_len = components.len(); + + anyhow::ensure!(bin_size.get() >= MIN_CHUNKED_LAYERS); + + // If we have a prior build, then use that + if let Some(prior_build) = prior_build_metadata { + return basic_packing_with_prior_build(components, bin_size, prior_build); + } + + tracing::debug!("Creating new packing structure"); + + // If there are fewer packages/components than there are bins, then we don't need to do + // any "bin packing" at all; just assign a single component to each and we're done. + if before_processing_pkgs_len < bin_size.get() as usize { + let mut r = components.iter().map(|pkg| vec![pkg]).collect::>(); + if before_processing_pkgs_len > 0 { + let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + r.push(new_pkgs_bin); + } + return Ok(r); + } + + let mut r = Vec::new(); + // Split off the components which are "max frequency". + let (components, max_freq_components) = components + .iter() + .partition::, _>(|pkg| pkg.meta.change_frequency != u32::MAX); + if !components.is_empty() { + // Given a total number of bins (layers), compute how many should be assigned to our + // partitioning based on size and frequency. + let limit_ls_bins = 1usize; + let limit_new_bins = 1usize; + let _limit_new_pkgs = 0usize; + let limit_max_frequency_pkgs = max_freq_components.len(); + let limit_max_frequency_bins = limit_max_frequency_pkgs.min(1); + let low_and_other_bin_limit = limit_ls_bins + limit_new_bins + limit_max_frequency_bins; + let limit_hs_bins = (HIGH_SIZE_CUTOFF + * (bin_size.get() - low_and_other_bin_limit as u32) as f32) + .floor() as usize; + let limit_ms_bins = + (bin_size.get() - (limit_hs_bins + low_and_other_bin_limit) as u32) as usize; + let partitions = get_partitions_with_threshold(&components, limit_hs_bins, 2f64) + .expect("Partitioning components into sets"); + + // Compute how many low-sized package/components we have. + let low_sized_component_count = partitions + .get(LOW_PARTITION) + .map(|p| p.len()) + .unwrap_or_default(); + + // Approximate number of components we should have per medium-size bin. + let pkg_per_bin_ms: usize = (components.len() - limit_hs_bins - low_sized_component_count) + .checked_div(limit_ms_bins) + .ok_or_else(|| anyhow::anyhow!("number of bins should be >= {}", MIN_CHUNKED_LAYERS))?; + + // Bins assignment + for (partition, pkgs) in partitions.iter() { + if partition == HIGH_PARTITION { + for pkg in pkgs { + r.push(vec![*pkg]); + } + } else if partition == LOW_PARTITION { + let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + for pkg in pkgs { + bin.push(*pkg); + } + r.push(bin); + } else { + let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + for (i, pkg) in pkgs.iter().enumerate() { + if bin.len() < pkg_per_bin_ms { + bin.push(*pkg); + } else { + r.push(bin.clone()); + bin.clear(); + bin.push(*pkg); + } + if i == pkgs.len() - 1 && !bin.is_empty() { + r.push(bin.clone()); + bin.clear(); + } + } + } + } + tracing::debug!("Bins before unoptimized build: {}", r.len()); + + // Despite allocation certain number of pkgs per bin in medium-size partitions, the + // hard limit of number of medium-size bins can be exceeded. This is because the pkg_per_bin_ms + // is only upper limit and there is no lower limit. Thus, if a partition in medium-size has only 1 pkg + // but pkg_per_bin_ms > 1, then the entire bin will have 1 pkg. This prevents partition + // mixing. + // + // Addressing medium-size bins limit breach by mergin internal MS partitions + // The partitions in medium-size are merged beginning from the end so to not mix high-frequency bins with low-frequency bins. The + // bins are kept in this order: high-frequency, medium-frequency, low-frequency. + while r.len() > (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) { + for i in (limit_ls_bins + limit_hs_bins..r.len() - 1) + .step_by(2) + .rev() + { + if r.len() <= (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) + { + break; + } + let prev = &r[i - 1]; + let curr = &r[i]; + let mut merge: Vec<&ObjectSourceMetaSized> = Vec::new(); + merge.extend(prev.iter()); + merge.extend(curr.iter()); + r.remove(i); + r.remove(i - 1); + r.insert(i, merge); + } + } + tracing::debug!("Bins after optimization: {}", r.len()); + } + + if !max_freq_components.is_empty() { + r.push(max_freq_components); + } + + // Allocate an empty bin for new packages + r.push(Vec::new()); + let after_processing_pkgs_len = r.iter().map(|b| b.len()).sum::(); + assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len); + assert!(r.len() <= bin_size.get() as usize); + Ok(r) +} + +#[cfg(test)] +mod test { + use super::*; + + use oci_spec::image as oci_image; + use std::str::FromStr; + + const FCOS_CONTENTMETA: &[u8] = include_bytes!("fixtures/fedora-coreos-contentmeta.json.gz"); + const SHA256_EXAMPLE: &str = + "sha256:0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"; + + #[test] + fn test_packing_basics() -> Result<()> { + // null cases + for v in [4, 7].map(|v| NonZeroU32::new(v).unwrap()) { + assert_eq!(basic_packing(&[], v, None).unwrap().len(), 0); + } + Ok(()) + } + + #[test] + fn test_packing_fcos() -> Result<()> { + let contentmeta: Vec = + serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?; + let total_size = contentmeta.iter().map(|v| v.size).sum::(); + + let packing = + basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap(), None).unwrap(); + assert!(!contentmeta.is_empty()); + // We should fit into the assigned chunk size + assert_eq!(packing.len() as u32, MAX_CHUNKS); + // And verify that the sizes match + let packed_total_size = packing_size(&packing); + assert_eq!(total_size, packed_total_size); + Ok(()) + } + + #[test] + fn test_packing_one_layer() -> Result<()> { + let contentmeta: Vec = + serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?; + let r = basic_packing(&contentmeta, NonZeroU32::new(1).unwrap(), None); + assert!(r.is_err()); + Ok(()) + } + + fn create_manifest(prev_expected_structure: Vec>) -> oci_spec::image::ImageManifest { + use std::collections::HashMap; + + let mut p = prev_expected_structure + .iter() + .map(|b| { + b.iter() + .map(|p| p.split('.').collect::>()[0].to_string()) + .collect() + }) + .collect(); + let mut metadata_with_ostree_commit = vec![vec![String::from("ostree_commit")]]; + metadata_with_ostree_commit.append(&mut p); + + let config = oci_spec::image::DescriptorBuilder::default() + .media_type(oci_spec::image::MediaType::ImageConfig) + .size(7023_u64) + .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap()) + .build() + .expect("build config descriptor"); + + let layers: Vec = metadata_with_ostree_commit + .iter() + .map(|l| { + let mut buf = [0; 8]; + let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf); + oci_spec::image::DescriptorBuilder::default() + .media_type(oci_spec::image::MediaType::ImageLayerGzip) + .size(100_u64) + .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap()) + .annotations(HashMap::from([( + CONTENT_ANNOTATION.to_string(), + l.join(sep), + )])) + .build() + .expect("build layer") + }) + .collect(); + + oci_spec::image::ImageManifestBuilder::default() + .schema_version(oci_spec::image::SCHEMA_VERSION) + .config(config) + .layers(layers) + .build() + .expect("build image manifest") + } + + #[test] + fn test_advanced_packing() -> Result<()> { + // Step1 : Initial build (Packing sructure computed) + let contentmeta_v0: Vec = vec![ + vec![1, u32::MAX, 100000], + vec![2, u32::MAX, 99999], + vec![3, 30, 99998], + vec![4, 100, 99997], + vec![10, 51, 1000], + vec![8, 50, 500], + vec![9, 1, 200], + vec![11, 100000, 199], + vec![6, 30, 2], + vec![7, 30, 1], + ] + .iter() + .map(|data| ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from(format!("pkg{}.0", data[0])), + name: RcStr::from(format!("pkg{}", data[0])), + srcid: RcStr::from(format!("srcpkg{}", data[0])), + change_time_offset: 0, + change_frequency: data[1], + }, + size: data[2] as u64, + }) + .collect(); + + let packing = basic_packing( + &contentmeta_v0.as_slice(), + NonZeroU32::new(6).unwrap(), + None, + ) + .unwrap(); + let structure: Vec> = packing + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v0_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg7.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.0", "pkg2.0"], + vec![], + ]; + assert_eq!(structure, v0_expected_structure); + + // Step 2: Derive packing structure from last build + + let mut contentmeta_v1: Vec = contentmeta_v0; + // Upgrade pkg1.0 to 1.1 + contentmeta_v1[0].meta.identifier = RcStr::from("pkg1.1"); + // Remove pkg7 + contentmeta_v1.remove(contentmeta_v1.len() - 1); + // Add pkg5 + contentmeta_v1.push(ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from("pkg5.0"), + name: RcStr::from("pkg5"), + srcid: RcStr::from("srcpkg5"), + change_time_offset: 0, + change_frequency: 42, + }, + size: 100000, + }); + + let image_manifest_v0 = create_manifest(v0_expected_structure); + let packing_derived = basic_packing( + &contentmeta_v1.as_slice(), + NonZeroU32::new(6).unwrap(), + Some(&image_manifest_v0), + ) + .unwrap(); + let structure_derived: Vec> = packing_derived + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v1_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.1", "pkg2.0"], + vec!["pkg5.0"], + ]; + + assert_eq!(structure_derived, v1_expected_structure); + + // Step 3: Another update on derived where the pkg in the last bin updates + + let mut contentmeta_v2: Vec = contentmeta_v1; + // Upgrade pkg5.0 to 5.1 + contentmeta_v2[9].meta.identifier = RcStr::from("pkg5.1"); + // Add pkg12 + contentmeta_v2.push(ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from("pkg12.0"), + name: RcStr::from("pkg12"), + srcid: RcStr::from("srcpkg12"), + change_time_offset: 0, + change_frequency: 42, + }, + size: 100000, + }); + + let image_manifest_v1 = create_manifest(v1_expected_structure); + let packing_derived = basic_packing( + &contentmeta_v2.as_slice(), + NonZeroU32::new(6).unwrap(), + Some(&image_manifest_v1), + ) + .unwrap(); + let structure_derived: Vec> = packing_derived + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v2_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.1", "pkg2.0"], + vec!["pkg5.1", "pkg12.0"], + ]; + + assert_eq!(structure_derived, v2_expected_structure); + Ok(()) + } + + fn setup_exclusive_test( + component_data: &[(u32, u32, u64)], + max_layers: u32, + num_fake_objects: Option, + ) -> Result<( + Vec, + ObjectMetaSized, + BTreeMap>, + Chunking, + )> { + // Create content metadata from provided data + let contentmeta: Vec = component_data + .iter() + .map(|&(id, freq, size)| ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from(format!("pkg{id}.0")), + name: RcStr::from(format!("pkg{id}")), + srcid: RcStr::from(format!("srcpkg{id}")), + change_time_offset: 0, + change_frequency: freq, + }, + size, + }) + .collect(); + + // Create object maps with fake checksums + let mut object_map = IndexMap::new(); + + for (i, component) in contentmeta.iter().enumerate() { + let checksum = format!("checksum_{i}"); + object_map.insert(checksum, component.meta.identifier.clone()); + } + + let regular_meta = ObjectMetaSized { + map: object_map, + sizes: contentmeta.clone(), + }; + + // Create exclusive metadata (initially empty, to be populated by individual tests) + let specific_contentmeta = BTreeMap::new(); + + // Set up chunking with remainder chunk + let mut chunking = Chunking::default(); + chunking.max = max_layers; + chunking.remainder = Chunk::new("remainder"); + + // Add fake objects to the remainder chunk if specified + if let Some(num_objects) = num_fake_objects { + for i in 0..num_objects { + let checksum = format!("checksum_{i}"); + chunking.remainder.content.insert( + RcStr::from(checksum), + ( + 1000, + vec![Utf8PathBuf::from(format!("/path/to/checksum_{i}"))], + ), + ); + chunking.remainder.size += 1000; + } + } + + Ok((contentmeta, regular_meta, specific_contentmeta, chunking)) + } + + #[test] + fn test_exclusive_chunks() -> Result<()> { + // Test that exclusive chunks are created first and get their own layers + let component_data = [ + (1, 100, 50000), + (2, 200, 40000), + (3, 300, 30000), + (4, 400, 20000), + (5, 500, 10000), + ]; + + let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) = + setup_exclusive_test(&component_data, 8, Some(5))?; + + // Create specific content metadata for pkg1 and pkg2 + specific_contentmeta.insert( + contentmeta[0].meta.identifier.clone(), + vec![( + Utf8PathBuf::from("/path/to/checksum_0"), + "checksum_0".to_string(), + )], + ); + specific_contentmeta.insert( + contentmeta[1].meta.identifier.clone(), + vec![( + Utf8PathBuf::from("/path/to/checksum_1"), + "checksum_1".to_string(), + )], + ); + + chunking.process_mapping( + ®ular_meta, + &Some(NonZeroU32::new(8).unwrap()), + None, + Some(&specific_contentmeta), + )?; + + // Verify exclusive chunks are created first + assert!(chunking.chunks.len() >= 2); + assert_eq!(chunking.chunks[0].name, "pkg1.0"); + assert_eq!(chunking.chunks[1].name, "pkg2.0"); + assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]); + assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]); + + Ok(()) + } + + #[test] + fn test_exclusive_chunks_with_regular_packing() -> Result<()> { + // Test that exclusive chunks are created first, then regular packing continues + let component_data = [ + (1, 100, 50000), // exclusive + (2, 200, 40000), // exclusive + (3, 300, 30000), // regular + (4, 400, 20000), // regular + (5, 500, 10000), // regular + (6, 600, 5000), // regular + ]; + + let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) = + setup_exclusive_test(&component_data, 8, Some(6))?; + + // Create specific content metadata for pkg1 and pkg2 + specific_contentmeta.insert( + contentmeta[0].meta.identifier.clone(), + vec![( + Utf8PathBuf::from("/path/to/checksum_0"), + "checksum_0".to_string(), + )], + ); + specific_contentmeta.insert( + contentmeta[1].meta.identifier.clone(), + vec![( + Utf8PathBuf::from("/path/to/checksum_1"), + "checksum_1".to_string(), + )], + ); + + chunking.process_mapping( + ®ular_meta, + &Some(NonZeroU32::new(8).unwrap()), + None, + Some(&specific_contentmeta), + )?; + + // Verify exclusive chunks are created first + assert!(chunking.chunks.len() >= 2); + assert_eq!(chunking.chunks[0].name, "pkg1.0"); + assert_eq!(chunking.chunks[1].name, "pkg2.0"); + assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]); + assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]); + + // Verify regular components are not in exclusive chunks + for chunk in &chunking.chunks[2..] { + assert!(!chunk.packages.contains(&"pkg1.0".to_string())); + assert!(!chunk.packages.contains(&"pkg2.0".to_string())); + } + + Ok(()) + } + + #[test] + fn test_exclusive_chunks_isolation() -> Result<()> { + // Test that exclusive chunks properly isolate components + let component_data = [(1, 100, 50000), (2, 200, 40000), (3, 300, 30000)]; + + let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) = + setup_exclusive_test(&component_data, 8, Some(3))?; + + // Create specific content metadata for pkg1 only + specific_contentmeta.insert( + contentmeta[0].meta.identifier.clone(), + vec![( + Utf8PathBuf::from("/path/to/checksum_0"), + "checksum_0".to_string(), + )], + ); + + chunking.process_mapping( + ®ular_meta, + &Some(NonZeroU32::new(8).unwrap()), + None, + Some(&specific_contentmeta), + )?; + + // Verify pkg1 is in its own exclusive chunk + assert!(!chunking.chunks.is_empty()); + assert_eq!(chunking.chunks[0].name, "pkg1.0"); + assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]); + + // Verify pkg2 and pkg3 are in regular chunks, not mixed with pkg1 + let mut found_pkg2 = false; + let mut found_pkg3 = false; + for chunk in &chunking.chunks[1..] { + if chunk.packages.contains(&"pkg2".to_string()) { + found_pkg2 = true; + assert!(!chunk.packages.contains(&"pkg1.0".to_string())); + } + if chunk.packages.contains(&"pkg3".to_string()) { + found_pkg3 = true; + assert!(!chunk.packages.contains(&"pkg1.0".to_string())); + } + } + assert!(found_pkg2 && found_pkg3); + + Ok(()) + } + + #[test] + fn test_process_mapping_specific_components_contain_correct_objects() -> Result<()> { + // This test validates that specific components get their own dedicated layers + // and that their objects are properly isolated from regular package layers + + // Setup: Create 5 packages + // - pkg1, pkg2: Will be marked as "specific components" (get their own layers) + // - pkg3, pkg4, pkg5: Regular packages (will be bin-packed together) + let packages = [ + (1, 100, 50000), // pkg1 - SPECIFIC COMPONENT + (2, 200, 40000), // pkg2 - SPECIFIC COMPONENT + (3, 300, 30000), // pkg3 - regular package + (4, 400, 20000), // pkg4 - regular package + (5, 500, 10000), // pkg5 - regular package + ]; + + let (contentmeta, mut system_metadata, mut specific_contentmeta, mut chunking) = + setup_exclusive_test(&packages, 8, None)?; + + // Create object mappings + // - system_objects_map: Contains ALL objects in the system (both specific and regular) + let mut system_objects_map = IndexMap::new(); + + // SPECIFIC COMPONENT 1 (pkg1): owns 3 objects + let pkg1_objects = ["checksum_1_a", "checksum_1_b", "checksum_1_c"]; + + // SPECIFIC COMPONENT 2 (pkg2): owns 2 objects + let pkg2_objects = ["checksum_2_a", "checksum_2_b"]; + + // REGULAR PACKAGE 1 (pkg3): owns 2 objects + let pkg3_objects = ["checksum_3_a", "checksum_3_b"]; + for obj in &pkg3_objects { + system_objects_map.insert(obj.to_string(), contentmeta[2].meta.identifier.clone()); + } + + // REGULAR PACKAGE 2 (pkg4): owns 1 object + let pkg4_objects = ["checksum_4_a"]; + for obj in &pkg4_objects { + system_objects_map.insert(obj.to_string(), contentmeta[3].meta.identifier.clone()); + } + + // REGULAR PACKAGE 3 (pkg5): owns 3 objects + let pkg5_objects = ["checksum_5_a", "checksum_5_b", "checksum_5_c"]; + for obj in &pkg5_objects { + system_objects_map.insert(obj.to_string(), contentmeta[4].meta.identifier.clone()); + } + + // Set up metadata + system_metadata.map = system_objects_map; + + // Create specific content metadata for pkg1 and pkg2 + specific_contentmeta.insert( + contentmeta[0].meta.identifier.clone(), + pkg1_objects + .iter() + .map(|obj| { + ( + Utf8PathBuf::from(format!("/path/to/{obj}")), + obj.to_string(), + ) + }) + .collect(), + ); + specific_contentmeta.insert( + contentmeta[1].meta.identifier.clone(), + pkg2_objects + .iter() + .map(|obj| { + ( + Utf8PathBuf::from(format!("/path/to/{obj}")), + obj.to_string(), + ) + }) + .collect(), + ); + + // Initialize: Add ALL objects to the remainder chunk before processing + // This includes both specific component objects and regular package objects + // because process_mapping needs to move them from remainder to their final layers + for (checksum, _) in &system_metadata.map { + chunking.remainder.content.insert( + RcStr::from(checksum.as_str()), + ( + 1000, + vec![Utf8PathBuf::from(format!("/path/to/{checksum}"))], + ), + ); + chunking.remainder.size += 1000; + } + for paths in specific_contentmeta.values() { + for (p, checksum) in paths { + chunking + .remainder + .content + .insert(RcStr::from(checksum.as_str()), (1000, vec![p.clone()])); + } + } + + // Process the mapping + // - system_metadata contains ALL objects in the system + // - specific_contentmeta tells process_mapping which objects belong to specific components + chunking.process_mapping( + &system_metadata, + &Some(NonZeroU32::new(8).unwrap()), + None, + Some(&specific_contentmeta), + )?; + + // VALIDATION PART 1: Specific components get their own dedicated chunks + assert!( + chunking.chunks.len() >= 2, + "Should have at least 2 chunks for specific components" + ); + + // Specific Component Layer 1: pkg1 only + let specific_component_1_layer = &chunking.chunks[0]; + assert_eq!(specific_component_1_layer.name, "pkg1.0"); + assert_eq!(specific_component_1_layer.packages, vec!["pkg1.0"]); + assert_eq!(specific_component_1_layer.content.len(), 3); + for obj in &pkg1_objects { + assert!( + specific_component_1_layer.content.contains_key(*obj), + "Specific component 1 layer should contain {obj}" + ); + } + + // Specific Component Layer 2: pkg2 only + let specific_component_2_layer = &chunking.chunks[1]; + assert_eq!(specific_component_2_layer.name, "pkg2.0"); + assert_eq!(specific_component_2_layer.packages, vec!["pkg2.0"]); + assert_eq!(specific_component_2_layer.content.len(), 2); + for obj in &pkg2_objects { + assert!( + specific_component_2_layer.content.contains_key(*obj), + "Specific component 2 layer should contain {obj}" + ); + } + + // VALIDATION PART 2: Specific component layers contain NO regular package objects + for specific_layer in &chunking.chunks[0..2] { + for obj in pkg3_objects + .iter() + .chain(&pkg4_objects) + .chain(&pkg5_objects) + { + assert!( + !specific_layer.content.contains_key(*obj), + "Specific component layer '{}' should NOT contain regular package object {}", + specific_layer.name, + obj + ); + } + } + + // VALIDATION PART 3: Regular package layers contain NO specific component objects + let regular_package_layers = &chunking.chunks[2..]; + for regular_layer in regular_package_layers { + for obj in pkg1_objects.iter().chain(&pkg2_objects) { + assert!( + !regular_layer.content.contains_key(*obj), + "Regular package layer should NOT contain specific component object {obj}" + ); + } + } + + // VALIDATION PART 4: All regular package objects are in some regular layer + let mut found_regular_objects = BTreeSet::new(); + for regular_layer in regular_package_layers { + for obj in regular_layer.content.keys() { + found_regular_objects.insert(obj.as_ref()); + } + } + + for obj in pkg3_objects + .iter() + .chain(&pkg4_objects) + .chain(&pkg5_objects) + { + assert!( + found_regular_objects.contains(*obj), + "Regular package object {obj} should be in some regular layer" + ); + } + + // VALIDATION PART 5: All objects moved from remainder + assert_eq!( + chunking.remainder.content.len(), + 0, + "All objects should be moved from remainder" + ); + assert_eq!(chunking.remainder.size, 0, "Remainder size should be 0"); + + Ok(()) + } +} diff --git a/crates/ostree-ext/src/cli.rs b/crates/ostree-ext/src/cli.rs new file mode 100644 index 000000000..119cea41e --- /dev/null +++ b/crates/ostree-ext/src/cli.rs @@ -0,0 +1,1408 @@ +//! # Commandline parsing +//! +//! While there is a separate `ostree-ext-cli` crate that +//! can be installed and used directly, the CLI code is +//! also exported as a library too, so that projects +//! such as `rpm-ostree` can directly reuse it. + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use canon_json::CanonJsonSerialize; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::prelude::CapStdExtDirExt; +use clap::{Parser, Subcommand}; +use fn_error_context::context; +use indexmap::IndexMap; +use io_lifetimes::AsFd; +use ostree::{gio, glib}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::File; +use std::io::{BufReader, BufWriter, Write}; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::process::Command; +use tokio::sync::mpsc::Receiver; + +use crate::chunking::{ObjectMetaSized, ObjectSourceMetaSized}; +use crate::commit::container_commit; +use crate::container::store::{ExportToOCIOpts, ImportProgress, LayerProgress, PreparedImport}; +use crate::container::{self as ostree_container, ManifestDiff}; +use crate::container::{Config, ImageReference, OstreeImageReference}; +use crate::objectsource::ObjectSourceMeta; +use crate::sysroot::SysrootLock; +use ostree_container::store::{ImageImporter, PrepareResult}; +use serde::{Deserialize, Serialize}; + +/// Parse an [`OstreeImageReference`] from a CLI arguemnt. +pub fn parse_imgref(s: &str) -> Result { + OstreeImageReference::try_from(s) +} + +/// Parse a base [`ImageReference`] from a CLI arguemnt. +pub fn parse_base_imgref(s: &str) -> Result { + ImageReference::try_from(s) +} + +/// Parse an [`ostree::Repo`] from a CLI arguemnt. +pub fn parse_repo(s: &Utf8Path) -> Result { + let repofd = cap_std::fs::Dir::open_ambient_dir(s, cap_std::ambient_authority()) + .with_context(|| format!("Opening directory at '{s}'"))?; + ostree::Repo::open_at_dir(repofd.as_fd(), ".") + .with_context(|| format!("Opening ostree repository at '{s}'")) +} + +/// Options for importing a tar archive. +#[derive(Debug, Parser)] +pub(crate) struct ImportOpts { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Path to a tar archive; if unspecified, will be stdin. Currently the tar archive must not be compressed. + path: Option, +} + +/// Options for exporting a tar archive. +#[derive(Debug, Parser)] +pub(crate) struct ExportOpts { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// The format version. Must be 1. + #[clap(long, hide(true))] + format_version: u32, + + /// The ostree ref or commit to export + rev: String, +} + +/// Options for import/export to tar archives. +#[derive(Debug, Subcommand)] +pub(crate) enum TarOpts { + /// Import a tar archive (currently, must not be compressed) + Import(ImportOpts), + + /// Write a tar archive to stdout + Export(ExportOpts), +} + +/// Options for container import/export. +#[derive(Debug, Subcommand)] +pub(crate) enum ContainerOpts { + #[clap(alias = "import")] + /// Import an ostree commit embedded in a remote container image + Unencapsulate { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + #[clap(flatten)] + proxyopts: ContainerProxyOpts, + + /// Image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref: OstreeImageReference, + + /// Create an ostree ref pointing to the imported commit + #[clap(long)] + write_ref: Option, + + /// Don't display progress + #[clap(long)] + quiet: bool, + }, + + /// Print information about an exported ostree-container image. + Info { + /// Image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref: OstreeImageReference, + }, + + /// Wrap an ostree commit into a container image. + /// + /// The resulting container image will have a single layer, which is + /// very often not what's desired. To handle things more intelligently, + /// you will need to use (or create) a higher level tool that splits + /// content into distinct "chunks"; functionality for this is + /// exposed by the API but not CLI currently. + #[clap(alias = "export")] + Encapsulate { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// The ostree ref or commit to export + rev: String, + + /// Image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + imgref: ImageReference, + + /// Additional labels for the container + #[clap(name = "label", long, short)] + labels: Vec, + + #[clap(long)] + /// Path to Docker-formatted authentication file. + authfile: Option, + + /// Path to a JSON-formatted serialized container configuration; this is the + /// `config` property of https://github.com/opencontainers/image-spec/blob/main/config.md + #[clap(long)] + config: Option, + + /// Propagate an OSTree commit metadata key to container label + #[clap(name = "copymeta", long)] + copy_meta_keys: Vec, + + /// Propagate an optionally-present OSTree commit metadata key to container label + #[clap(name = "copymeta-opt", long)] + copy_meta_opt_keys: Vec, + + /// Corresponds to the Dockerfile `CMD` instruction. + #[clap(long)] + cmd: Option>, + + /// Compress at the fastest level (e.g. gzip level 1) + #[clap(long)] + compression_fast: bool, + + /// Path to a JSON-formatted content meta object. + #[clap(long)] + contentmeta: Option, + }, + + /// Perform build-time checking and canonicalization. + /// This is presently an optional command, but may become required in the future. + Commit, + + /// Commands for working with (possibly layered, non-encapsulated) container images. + #[clap(subcommand)] + Image(ContainerImageOpts), + + /// Compare the contents of two OCI compliant images. + Compare { + /// Image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref_old: OstreeImageReference, + + /// Image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref_new: OstreeImageReference, + }, +} + +/// Options for container image fetching. +#[derive(Debug, Parser)] +pub(crate) struct ContainerProxyOpts { + #[clap(long)] + /// Do not use default authentication files. + auth_anonymous: bool, + + #[clap(long)] + /// Path to Docker-formatted authentication file. + authfile: Option, + + #[clap(long)] + /// Directory with certificates (*.crt, *.cert, *.key) used to connect to registry + /// Equivalent to `skopeo --cert-dir` + cert_dir: Option, + + #[clap(long)] + /// Skip TLS verification. + insecure_skip_tls_verification: bool, +} + +/// Options for import/export to tar archives. +#[derive(Debug, Subcommand)] +pub(crate) enum ContainerImageOpts { + /// List container images + List { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + }, + + /// Pull (or update) a container image. + Pull { + /// Path to the repository + #[clap(value_parser)] + repo: Utf8PathBuf, + + /// Image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref: OstreeImageReference, + + /// File to which to write the resulting OSTree commit digest + #[clap(long)] + ostree_digestfile: Option, + + #[clap(flatten)] + proxyopts: ContainerProxyOpts, + + /// Don't display progress + #[clap(long)] + quiet: bool, + + /// Just check for an updated manifest, but do not download associated container layers. + /// If an updated manifest is found, a file at the provided path will be created and contain + /// the new manifest. + #[clap(long)] + check: Option, + }, + + /// Output metadata about an already stored container image. + History { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Container image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + imgref: ImageReference, + }, + + /// Output manifest or configuration for an already stored container image. + Metadata { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Container image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + imgref: ImageReference, + + /// Output the config, not the manifest + #[clap(long)] + config: bool, + }, + + /// Remove metadata for a cached update. + ClearCachedUpdate { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Container image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + imgref: ImageReference, + }, + + /// Copy a pulled container image from one repo to another. + Copy { + /// Path to the source repository + #[clap(long, value_parser)] + src_repo: Utf8PathBuf, + + /// Path to the destination repository + #[clap(long, value_parser)] + dest_repo: Utf8PathBuf, + + /// Image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_imgref)] + imgref: OstreeImageReference, + }, + + /// Re-export a fetched image. + /// + /// Unlike `encapsulate`, this verb handles layered images, and will + /// also automatically preserve chunked structure from the fetched image. + Reexport { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Source image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + src_imgref: ImageReference, + + /// Destination image reference, e.g. registry:quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + dest_imgref: ImageReference, + + #[clap(long)] + /// Path to Docker-formatted authentication file. + authfile: Option, + + /// Compress at the fastest level (e.g. gzip level 1) + #[clap(long)] + compression_fast: bool, + }, + + /// Replace the detached metadata (e.g. to add a signature) + ReplaceDetachedMetadata { + /// Path to the source repository + #[clap(long)] + #[clap(value_parser = parse_base_imgref)] + src: ImageReference, + + /// Target image + #[clap(long)] + #[clap(value_parser = parse_base_imgref)] + dest: ImageReference, + + /// Path to file containing new detached metadata; if not provided, + /// any existing detached metadata will be deleted. + contents: Option, + }, + + /// Unreference one or more pulled container images and perform a garbage collection. + Remove { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Image reference, e.g. quay.io/exampleos/exampleos:latest + #[clap(value_parser = parse_base_imgref)] + imgrefs: Vec, + + /// Do not garbage collect unused layers + #[clap(long)] + skip_gc: bool, + }, + + /// Garbage collect unreferenced image layer references. + PruneLayers { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + }, + + /// Garbage collect unreferenced image layer references. + PruneImages { + /// Path to the system root + #[clap(long)] + sysroot: Utf8PathBuf, + + #[clap(long)] + /// Also prune layers + and_layers: bool, + + #[clap(long, conflicts_with = "and_layers")] + /// Also prune layers and OSTree objects + full: bool, + }, + + /// Perform initial deployment for a container image + Deploy { + /// Path to the system root + #[clap(long)] + sysroot: Option, + + /// Name for the state directory, also known as "osname". + /// If the current system is booted via ostree, then this will default to the booted stateroot. + /// Otherwise, the default is `default`. + #[clap(long)] + stateroot: Option, + + /// Source image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos@sha256:abcd... + /// This conflicts with `--image`. + /// This conflicts with `--image`. Supports `registry:`, `docker://`, `oci:`, `oci-archive:`, `containers-storage:`, and `dir:` + #[clap(long, required_unless_present = "image")] + imgref: Option, + + /// Name of the container image; for the `registry` transport this would be e.g. `quay.io/exampleos/foo:latest`. + /// This conflicts with `--imgref`. + #[clap(long, required_unless_present = "imgref")] + image: Option, + + /// The transport; e.g. registry, oci, oci-archive. The default is `registry`. + #[clap(long)] + transport: Option, + + /// This option does nothing and is now deprecated. Signature verification enforcement + /// proved to not be viable. + /// + /// If you want to still enforce it, use `--enforce-container-sigpolicy`. + #[clap(long, conflicts_with = "enforce_container_sigpolicy")] + no_signature_verification: bool, + + /// Require that the containers-storage stack + #[clap(long)] + enforce_container_sigpolicy: bool, + + /// Enable verification via an ostree remote + #[clap(long)] + ostree_remote: Option, + + #[clap(flatten)] + proxyopts: ContainerProxyOpts, + + /// Target image reference, e.g. ostree-remote-image:someremote:registry:quay.io/exampleos/exampleos:latest + /// + /// If specified, `--imgref` will be used as a source, but this reference will be emitted into the origin + /// so that later OS updates pull from it. + #[clap(long)] + #[clap(value_parser = parse_imgref)] + target_imgref: Option, + + /// If set, only write the layer refs, but not the final container image reference. This + /// allows generating a disk image that when booted uses "native ostree", but has layer + /// references "pre-cached" such that a container image fetch will avoid redownloading + /// everything. + #[clap(long)] + no_imgref: bool, + + #[clap(long)] + /// Add a kernel argument + karg: Option>, + + /// Write the deployed checksum to this file + #[clap(long)] + write_commitid_to: Option, + }, +} + +/// Options for deployment repair. +#[derive(Debug, Parser)] +pub(crate) enum ProvisionalRepairOpts { + AnalyzeInodes { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// Print additional information + #[clap(long)] + verbose: bool, + + /// Serialize the repair result to this file as JSON + #[clap(long)] + write_result_to: Option, + }, + + Repair { + /// Path to the sysroot + #[clap(long, value_parser)] + sysroot: Utf8PathBuf, + + /// Do not mutate any system state + #[clap(long)] + dry_run: bool, + + /// Serialize the repair result to this file as JSON + #[clap(long)] + write_result_to: Option, + + /// Print additional information + #[clap(long)] + verbose: bool, + }, +} + +/// Options for the Integrity Measurement Architecture (IMA). +#[derive(Debug, Parser)] +pub(crate) struct ImaSignOpts { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, + + /// The ostree ref or commit to use as a base + src_rev: String, + /// The ostree ref to use for writing the signed commit + target_ref: String, + + /// Digest algorithm + algorithm: String, + /// Path to IMA key + key: Utf8PathBuf, + + #[clap(long)] + /// Overwrite any existing signatures + overwrite: bool, +} + +/// Options for internal testing +#[derive(Debug, Subcommand)] +pub(crate) enum TestingOpts { + /// Detect the current environment + DetectEnv, + /// Generate a test fixture + CreateFixture, + /// Execute integration tests, assuming mutable environment + Run, + /// Execute IMA tests + RunIMA, + FilterTar, +} + +/// Options for man page generation +#[derive(Debug, Parser)] +pub(crate) struct ManOpts { + #[clap(long)] + /// Output to this directory + directory: Utf8PathBuf, +} + +/// Toplevel options for extended ostree functionality. +#[derive(Debug, Parser)] +#[clap(name = "ostree-ext")] +#[clap(rename_all = "kebab-case")] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Opt { + /// Import and export to tar + #[clap(subcommand)] + Tar(TarOpts), + /// Import and export to a container image + #[clap(subcommand)] + Container(ContainerOpts), + /// IMA signatures + ImaSign(ImaSignOpts), + /// Internal integration testing helpers. + #[clap(hide(true), subcommand)] + #[cfg(feature = "internal-testing-api")] + InternalOnlyForTesting(TestingOpts), + #[clap(hide(true))] + #[cfg(feature = "docgen")] + Man(ManOpts), + #[clap(hide = true, subcommand)] + ProvisionalRepair(ProvisionalRepairOpts), +} + +#[allow(clippy::from_over_into)] +impl Into for ContainerProxyOpts { + fn into(self) -> ostree_container::store::ImageProxyConfig { + ostree_container::store::ImageProxyConfig { + auth_anonymous: self.auth_anonymous, + authfile: self.authfile, + certificate_directory: self.cert_dir, + insecure_skip_tls_verification: Some(self.insecure_skip_tls_verification), + ..Default::default() + } + } +} + +/// Import a tar archive containing an ostree commit. +async fn tar_import(opts: &ImportOpts) -> Result<()> { + let repo = parse_repo(&opts.repo)?; + let imported = if let Some(path) = opts.path.as_ref() { + let instream = tokio::fs::File::open(path).await?; + crate::tar::import_tar(&repo, instream, None).await? + } else { + let stdin = tokio::io::stdin(); + crate::tar::import_tar(&repo, stdin, None).await? + }; + println!("Imported: {imported}"); + Ok(()) +} + +/// Export a tar archive containing an ostree commit. +fn tar_export(opts: &ExportOpts) -> Result<()> { + let repo = parse_repo(&opts.repo)?; + #[allow(clippy::needless_update)] + let subopts = crate::tar::ExportOptions { + ..Default::default() + }; + crate::tar::export_commit(&repo, opts.rev.as_str(), std::io::stdout(), Some(subopts))?; + Ok(()) +} + +/// Render an import progress notification as a string. +pub fn layer_progress_format(p: &ImportProgress) -> String { + let (starting, s, layer) = match p { + ImportProgress::OstreeChunkStarted(v) => (true, "ostree chunk", v), + ImportProgress::OstreeChunkCompleted(v) => (false, "ostree chunk", v), + ImportProgress::DerivedLayerStarted(v) => (true, "layer", v), + ImportProgress::DerivedLayerCompleted(v) => (false, "layer", v), + }; + // podman outputs 12 characters of digest, let's add 7 for `sha256:`. + let short_digest = layer + .digest() + .digest() + .chars() + .take(12 + 7) + .collect::(); + if starting { + let size = glib::format_size(layer.size()); + format!("Fetching {s} {short_digest} ({size})") + } else { + format!("Fetched {s} {short_digest}") + } +} + +/// Write container fetch progress to standard output. +pub async fn handle_layer_progress_print( + mut layers: Receiver, + mut layer_bytes: tokio::sync::watch::Receiver>, +) { + let style = indicatif::ProgressStyle::default_bar(); + let pb = indicatif::ProgressBar::new(100); + pb.set_style( + style + .template("{prefix} {bytes} [{bar:20}] ({eta}) {msg}") + .unwrap(), + ); + loop { + tokio::select! { + // Always handle layer changes first. + biased; + layer = layers.recv() => { + if let Some(l) = layer { + if l.is_starting() { + pb.set_position(0); + } else { + pb.finish(); + } + pb.set_message(layer_progress_format(&l)); + } else { + // If the receiver is disconnected, then we're done + break + }; + }, + r = layer_bytes.changed() => { + if r.is_err() { + // If the receiver is disconnected, then we're done + break + } + let bytes = layer_bytes.borrow(); + if let Some(bytes) = &*bytes { + pb.set_length(bytes.total); + pb.set_position(bytes.fetched); + } + } + + } + } +} + +/// Write the status of layers to download. +pub fn print_layer_status(prep: &PreparedImport) { + if let Some(status) = prep.format_layer_status() { + println!("{status}"); + let _ = std::io::stdout().flush(); + } +} + +/// Write a deprecation notice, and sleep for 3 seconds. +pub async fn print_deprecated_warning(msg: &str) { + eprintln!("warning: {msg}"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await +} + +/// Import a container image with an encapsulated ostree commit. +async fn container_import( + repo: &ostree::Repo, + imgref: &OstreeImageReference, + proxyopts: ContainerProxyOpts, + write_ref: Option<&str>, + quiet: bool, +) -> Result<()> { + let target = indicatif::ProgressDrawTarget::stdout(); + let style = indicatif::ProgressStyle::default_bar(); + let pb = (!quiet).then(|| { + let pb = indicatif::ProgressBar::new_spinner(); + pb.set_draw_target(target); + pb.set_style(style.template("{spinner} {prefix} {msg}").unwrap()); + pb.enable_steady_tick(std::time::Duration::from_millis(200)); + pb.set_message("Downloading..."); + pb + }); + let importer = ImageImporter::new(repo, imgref, proxyopts.into()).await?; + let import = importer.unencapsulate().await; + // Ensure we finish the progress bar before potentially propagating an error + if let Some(pb) = pb.as_ref() { + pb.finish(); + } + let import = import?; + if let Some(warning) = import.deprecated_warning.as_deref() { + print_deprecated_warning(warning).await; + } + if let Some(write_ref) = write_ref { + repo.set_ref_immediate( + None, + write_ref, + Some(import.ostree_commit.as_str()), + gio::Cancellable::NONE, + )?; + println!( + "Imported: {} => {}", + write_ref, + import.ostree_commit.as_str() + ); + } else { + println!("Imported: {}", import.ostree_commit); + } + + Ok(()) +} + +/// Grouping of metadata about an object. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct RawMeta { + /// The metadata format version. Should be set to 1. + pub version: u32, + /// The image creation timestamp. Format is YYYY-MM-DDTHH:MM:SSZ. + /// Should be synced with the label io.container.image.created. + pub created: Option, + /// Top level labels, to be prefixed to the ones with --label + /// Applied to both the outer config annotations and the inner config labels. + pub labels: Option>, + /// The output layers ordered. Provided as an ordered mapping of a unique + /// machine readable strings to a human readable name (e.g., the layer contents). + /// The human-readable name is placed in a layer annotation. + pub layers: IndexMap, + /// The layer contents. The key is an ostree hash and the value is the + /// machine readable string of the layer the hash belongs to. + /// WARNING: needs to contain all ostree hashes in the input commit. + pub mapping: IndexMap, + /// Whether the mapping is ordered. If true, the output tar stream of the + /// layers will reflect the order of the hashes in the mapping. + /// Otherwise, a deterministic ordering will be used regardless of mapping + /// order. Potentially useful for optimizing zstd:chunked compression. + /// WARNING: not currently supported. + pub ordered: Option, +} + +/// Export a container image with an encapsulated ostree commit. +#[allow(clippy::too_many_arguments)] +async fn container_export( + repo: &ostree::Repo, + rev: &str, + imgref: &ImageReference, + labels: BTreeMap, + authfile: Option, + copy_meta_keys: Vec, + copy_meta_opt_keys: Vec, + container_config: Option, + cmd: Option>, + compression_fast: bool, + package_contentmeta: Option, +) -> Result<()> { + let container_config = if let Some(container_config) = container_config { + serde_json::from_reader(File::open(container_config).map(BufReader::new)?)? + } else { + None + }; + + let mut contentmeta_data = None; + let mut created = None; + let mut labels = labels.clone(); + if let Some(contentmeta) = package_contentmeta { + let buf = File::open(contentmeta).map(BufReader::new); + let raw: RawMeta = serde_json::from_reader(buf?)?; + + // Check future variables are set correctly + let supported_version = 1; + if raw.version != supported_version { + return Err(anyhow::anyhow!( + "Unsupported metadata version: {}. Currently supported: {}", + raw.version, + supported_version + )); + } + if let Some(ordered) = raw.ordered { + if ordered { + return Err(anyhow::anyhow!("Ordered mapping not currently supported.")); + } + } + + created = raw.created; + contentmeta_data = Some(ObjectMetaSized { + map: raw + .mapping + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + sizes: raw + .layers + .into_iter() + .map(|(k, v)| ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: k.clone().into(), + name: v.into(), + srcid: k.clone().into(), + change_frequency: if k == "unpackaged" { u32::MAX } else { 1 }, + change_time_offset: 1, + }, + size: 1, + }) + .collect(), + }); + + // Merge --label args to the labels from the metadata + labels.extend(raw.labels.into_iter().flatten()); + } + + // Use enough layers so that each package ends in its own layer + // while respecting the layer ordering. + let max_layers = if let Some(contentmeta_data) = &contentmeta_data { + NonZeroU32::new((contentmeta_data.sizes.len() + 1).try_into().unwrap()) + } else { + None + }; + + let config = Config { + labels: Some(labels), + cmd, + }; + + let opts = crate::container::ExportOpts { + copy_meta_keys, + copy_meta_opt_keys, + container_config, + authfile, + skip_compression: compression_fast, // TODO rename this in the struct at the next semver break + package_contentmeta: contentmeta_data.as_ref(), + max_layers, + created, + ..Default::default() + }; + let pushed = crate::container::encapsulate(repo, rev, &config, Some(opts), imgref).await?; + println!("{pushed}"); + Ok(()) +} + +/// Load metadata for a container image with an encapsulated ostree commit. +async fn container_info(imgref: &OstreeImageReference) -> Result<()> { + let (_, digest) = crate::container::fetch_manifest(imgref).await?; + println!("{imgref} digest: {digest}"); + Ok(()) +} + +/// Write a layered container image into an OSTree commit. +async fn container_store( + repo: &ostree::Repo, + imgref: &OstreeImageReference, + ostree_digestfile: Option, + proxyopts: ContainerProxyOpts, + quiet: bool, + check: Option, +) -> Result<()> { + let mut imp = ImageImporter::new(repo, imgref, proxyopts.into()).await?; + let prep = match imp.prepare().await? { + PrepareResult::AlreadyPresent(c) => { + write_digest_file(ostree_digestfile, &c.merge_commit)?; + println!("No changes in {} => {}", imgref, c.merge_commit); + return Ok(()); + } + PrepareResult::Ready(r) => r, + }; + if let Some(warning) = prep.deprecated_warning() { + print_deprecated_warning(warning).await; + } + if let Some(check) = check.as_deref() { + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + rootfs.atomic_replace_with(check.as_str().trim_start_matches('/'), |w| { + prep.manifest + .to_canon_json_writer(w) + .context("Serializing manifest") + })?; + // In check mode, we're done + return Ok(()); + } + if let Some(previous_state) = prep.previous_state.as_ref() { + let diff = ManifestDiff::new(&previous_state.manifest, &prep.manifest); + diff.print(); + } + print_layer_status(&prep); + let printer = (!quiet).then(|| { + let layer_progress = imp.request_progress(); + let layer_byte_progress = imp.request_layer_progress(); + tokio::task::spawn(async move { + handle_layer_progress_print(layer_progress, layer_byte_progress).await + }) + }); + let import = imp.import(prep).await; + if let Some(printer) = printer { + let _ = printer.await; + } + let import = import?; + if let Some(msg) = + ostree_container::store::image_filtered_content_warning(&import.filtered_files)? + { + eprintln!("{msg}") + } + if let Some(ref text) = import.verify_text { + println!("{text}"); + } + write_digest_file(ostree_digestfile, &import.merge_commit)?; + println!("Wrote: {} => {}", imgref, import.merge_commit); + Ok(()) +} + +fn write_digest_file(digestfile: Option, digest: &str) -> Result<()> { + if let Some(digestfile) = digestfile.as_deref() { + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + rootfs.write(digestfile.as_str().trim_start_matches('/'), digest)?; + } + Ok(()) +} + +/// Output the container image history +async fn container_history(repo: &ostree::Repo, imgref: &ImageReference) -> Result<()> { + let img = crate::container::store::query_image(repo, imgref)? + .ok_or_else(|| anyhow::anyhow!("No such image: {}", imgref))?; + let mut table = comfy_table::Table::new(); + table + .load_preset(comfy_table::presets::NOTHING) + .set_content_arrangement(comfy_table::ContentArrangement::Dynamic) + .set_header(["ID", "SIZE", "CRCEATED BY"]); + + let mut history = img.configuration.history().iter().flatten(); + let layers = img.manifest.layers().iter(); + for layer in layers { + let histent = history.next(); + let created_by = histent + .and_then(|s| s.created_by().as_deref()) + .unwrap_or(""); + + let digest = layer.digest().digest(); + // Verify it's OK to slice, this should all be ASCII + assert!(digest.is_ascii()); + let digest_max = 20usize; + let digest = &digest[0..digest_max]; + let size = glib::format_size(layer.size()); + table.add_row([digest, size.as_str(), created_by]); + } + println!("{table}"); + Ok(()) +} + +/// Add IMA signatures to an ostree commit, generating a new commit. +fn ima_sign(cmdopts: &ImaSignOpts) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let signopts = crate::ima::ImaOpts { + algorithm: cmdopts.algorithm.clone(), + key: cmdopts.key.clone(), + overwrite: cmdopts.overwrite, + }; + let repo = parse_repo(&cmdopts.repo)?; + let tx = repo.auto_transaction(cancellable)?; + let signed_commit = crate::ima::ima_sign(&repo, cmdopts.src_rev.as_str(), &signopts)?; + repo.transaction_set_ref( + None, + cmdopts.target_ref.as_str(), + Some(signed_commit.as_str()), + ); + let _stats = tx.commit(cancellable)?; + println!("{} => {}", cmdopts.target_ref, signed_commit); + Ok(()) +} + +#[cfg(feature = "internal-testing-api")] +async fn testing(opts: &TestingOpts) -> Result<()> { + match opts { + TestingOpts::DetectEnv => { + println!("{}", crate::integrationtest::detectenv()?); + Ok(()) + } + TestingOpts::CreateFixture => crate::integrationtest::create_fixture().await, + TestingOpts::Run => crate::integrationtest::run_tests(), + TestingOpts::RunIMA => crate::integrationtest::test_ima(), + TestingOpts::FilterTar => { + let tmpdir = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + crate::tar::filter_tar( + std::io::stdin(), + std::io::stdout(), + &Default::default(), + &tmpdir, + ) + .map(|_| {}) + } + } +} + +// Quick hack; TODO dedup this with the code in bootc or lower here +#[context("Remounting sysroot writable")] +fn container_remount_sysroot(sysroot: &Utf8Path) -> Result<()> { + if !Utf8Path::new("/run/.containerenv").exists() { + return Ok(()); + } + println!("Running in container, assuming we can remount {sysroot} writable"); + let st = Command::new("mount") + .args(["-o", "remount,rw", sysroot.as_str()]) + .status()?; + if !st.success() { + anyhow::bail!("Failed to remount {sysroot}: {st:?}"); + } + Ok(()) +} + +#[context("Serializing to output file")] +fn handle_serialize_to_file(path: Option<&Utf8Path>, obj: T) -> Result<()> { + if let Some(path) = path { + let mut out = std::fs::File::create(path) + .map(BufWriter::new) + .with_context(|| anyhow::anyhow!("Opening {path} for writing"))?; + obj.to_canon_json_writer(&mut out) + .context("Serializing output")?; + } + Ok(()) +} + +/// Parse the provided arguments and execute. +/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program. +pub async fn run_from_iter(args: I) -> Result<()> +where + I: IntoIterator, + I::Item: Into + Clone, +{ + run_from_opt(Opt::parse_from(args)).await +} + +async fn run_from_opt(opt: Opt) -> Result<()> { + match opt { + Opt::Tar(TarOpts::Import(ref opt)) => tar_import(opt).await, + Opt::Tar(TarOpts::Export(ref opt)) => tar_export(opt), + Opt::Container(o) => match o { + ContainerOpts::Info { imgref } => container_info(&imgref).await, + ContainerOpts::Commit => container_commit().await, + ContainerOpts::Unencapsulate { + repo, + imgref, + proxyopts, + write_ref, + quiet, + } => { + let repo = parse_repo(&repo)?; + container_import(&repo, &imgref, proxyopts, write_ref.as_deref(), quiet).await + } + ContainerOpts::Encapsulate { + repo, + rev, + imgref, + labels, + authfile, + copy_meta_keys, + copy_meta_opt_keys, + config, + cmd, + compression_fast, + contentmeta, + } => { + let labels: Result> = labels + .into_iter() + .map(|l| { + let (k, v) = l + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("Missing '=' in label {}", l))?; + Ok((k.to_string(), v.to_string())) + }) + .collect(); + let repo = parse_repo(&repo)?; + container_export( + &repo, + &rev, + &imgref, + labels?, + authfile, + copy_meta_keys, + copy_meta_opt_keys, + config, + cmd, + compression_fast, + contentmeta, + ) + .await + } + ContainerOpts::Image(opts) => match opts { + ContainerImageOpts::List { repo } => { + let repo = parse_repo(&repo)?; + for image in crate::container::store::list_images(&repo)? { + println!("{image}"); + } + Ok(()) + } + ContainerImageOpts::Pull { + repo, + imgref, + ostree_digestfile, + proxyopts, + quiet, + check, + } => { + let repo = parse_repo(&repo)?; + container_store(&repo, &imgref, ostree_digestfile, proxyopts, quiet, check) + .await + } + ContainerImageOpts::Reexport { + repo, + src_imgref, + dest_imgref, + authfile, + compression_fast, + } => { + let repo = &parse_repo(&repo)?; + let opts = ExportToOCIOpts { + authfile, + skip_compression: compression_fast, + ..Default::default() + }; + let digest = ostree_container::store::export( + repo, + &src_imgref, + &dest_imgref, + Some(opts), + ) + .await?; + println!("Exported: {digest}"); + Ok(()) + } + ContainerImageOpts::History { repo, imgref } => { + let repo = parse_repo(&repo)?; + container_history(&repo, &imgref).await + } + ContainerImageOpts::Metadata { + repo, + imgref, + config, + } => { + let repo = parse_repo(&repo)?; + let image = crate::container::store::query_image(&repo, &imgref)? + .ok_or_else(|| anyhow::anyhow!("No such image"))?; + let stdout = std::io::stdout().lock(); + let mut stdout = std::io::BufWriter::new(stdout); + if config { + image.configuration.to_canon_json_writer(&mut stdout)?; + } else { + image.manifest.to_canon_json_writer(&mut stdout)?; + } + stdout.flush()?; + Ok(()) + } + ContainerImageOpts::ClearCachedUpdate { repo, imgref } => { + let repo = parse_repo(&repo)?; + crate::container::store::clear_cached_update(&repo, &imgref)?; + Ok(()) + } + ContainerImageOpts::Remove { + repo, + imgrefs, + skip_gc, + } => { + let nimgs = imgrefs.len(); + let repo = parse_repo(&repo)?; + crate::container::store::remove_images(&repo, imgrefs.iter())?; + if !skip_gc { + let nlayers = crate::container::store::gc_image_layers(&repo)?; + println!("Removed images: {nimgs} layers: {nlayers}"); + } else { + println!("Removed images: {nimgs}"); + } + Ok(()) + } + ContainerImageOpts::PruneLayers { repo } => { + let repo = parse_repo(&repo)?; + let nlayers = crate::container::store::gc_image_layers(&repo)?; + println!("Removed layers: {nlayers}"); + Ok(()) + } + ContainerImageOpts::PruneImages { + sysroot, + and_layers, + full, + } => { + let sysroot = &ostree::Sysroot::new(Some(&gio::File::for_path(&sysroot))); + sysroot.load(gio::Cancellable::NONE)?; + let sysroot = &SysrootLock::new_from_sysroot(sysroot).await?; + if full { + let res = crate::container::deploy::prune(sysroot)?; + if res.is_empty() { + println!("No content was pruned."); + } else { + println!("Removed images: {}", res.n_images); + println!("Removed layers: {}", res.n_layers); + println!("Removed objects: {}", res.n_objects_pruned); + let objsize = glib::format_size(res.objsize); + println!("Freed: {objsize}"); + } + } else { + let removed = crate::container::deploy::remove_undeployed_images(sysroot)?; + match removed.as_slice() { + [] => { + println!("No unreferenced images."); + return Ok(()); + } + o => { + for imgref in o { + println!("Removed: {imgref}"); + } + } + } + if and_layers { + let nlayers = + crate::container::store::gc_image_layers(&sysroot.repo())?; + println!("Removed layers: {nlayers}"); + } + } + Ok(()) + } + ContainerImageOpts::Copy { + src_repo, + dest_repo, + imgref, + } => { + let src_repo = parse_repo(&src_repo)?; + let dest_repo = parse_repo(&dest_repo)?; + let imgref = &imgref.imgref; + crate::container::store::copy(&src_repo, imgref, &dest_repo, imgref).await + } + ContainerImageOpts::ReplaceDetachedMetadata { + src, + dest, + contents, + } => { + let contents = contents.map(std::fs::read).transpose()?; + let digest = crate::container::update_detached_metadata( + &src, + &dest, + contents.as_deref(), + ) + .await?; + println!("Pushed: {digest}"); + Ok(()) + } + ContainerImageOpts::Deploy { + sysroot, + stateroot, + imgref, + image, + transport, + mut no_signature_verification, + enforce_container_sigpolicy, + ostree_remote, + target_imgref, + no_imgref, + karg, + proxyopts, + write_commitid_to, + } => { + // As of recent releases, signature verification enforcement is + // off by default, and must be explicitly enabled. + no_signature_verification = !enforce_container_sigpolicy; + let sysroot = &if let Some(sysroot) = sysroot { + ostree::Sysroot::new(Some(&gio::File::for_path(sysroot))) + } else { + ostree::Sysroot::new_default() + }; + sysroot.load(gio::Cancellable::NONE)?; + let kargs = karg.as_deref(); + let kargs = kargs.map(|v| { + let r: Vec<_> = v.iter().map(|s| s.as_str()).collect(); + r + }); + + // If the user specified a stateroot, we always use that. + let stateroot = if let Some(stateroot) = stateroot.as_deref() { + Cow::Borrowed(stateroot) + } else { + // Otherwise, if we're booted via ostree, use the booted. + // If that doesn't hold, then use `default`. + let booted_stateroot = sysroot + .booted_deployment() + .map(|d| Cow::Owned(d.osname().to_string())); + booted_stateroot.unwrap_or({ + Cow::Borrowed(crate::container::deploy::STATEROOT_DEFAULT) + }) + }; + + let imgref = if let Some(image) = image { + let transport = transport.as_deref().unwrap_or("registry"); + let transport = ostree_container::Transport::try_from(transport)?; + let imgref = ostree_container::ImageReference { + transport, + name: image, + }; + let sigverify = if no_signature_verification { + ostree_container::SignatureSource::ContainerPolicyAllowInsecure + } else if let Some(remote) = ostree_remote.as_ref() { + ostree_container::SignatureSource::OstreeRemote(remote.to_string()) + } else { + ostree_container::SignatureSource::ContainerPolicy + }; + ostree_container::OstreeImageReference { sigverify, imgref } + } else { + // SAFETY: We use the clap required_unless_present flag, so this must be set + // because --image is not. + let imgref = imgref.expect("imgref option should be set"); + imgref.as_str().try_into()? + }; + + #[allow(clippy::needless_update)] + let options = crate::container::deploy::DeployOpts { + kargs: kargs.as_deref(), + target_imgref: target_imgref.as_ref(), + proxy_cfg: Some(proxyopts.into()), + no_imgref, + ..Default::default() + }; + let state = crate::container::deploy::deploy( + sysroot, + &stateroot, + &imgref, + Some(options), + ) + .await?; + if let Some(msg) = ostree_container::store::image_filtered_content_warning( + &state.filtered_files, + )? { + eprintln!("{msg}") + } + if let Some(p) = write_commitid_to { + std::fs::write(&p, state.merge_commit.as_bytes()) + .with_context(|| format!("Failed to write commitid to {p}"))?; + } + Ok(()) + } + }, + ContainerOpts::Compare { + imgref_old, + imgref_new, + } => { + let (manifest_old, _) = crate::container::fetch_manifest(&imgref_old).await?; + let (manifest_new, _) = crate::container::fetch_manifest(&imgref_new).await?; + let manifest_diff = + crate::container::ManifestDiff::new(&manifest_old, &manifest_new); + manifest_diff.print(); + Ok(()) + } + }, + Opt::ImaSign(ref opts) => ima_sign(opts), + #[cfg(feature = "internal-testing-api")] + Opt::InternalOnlyForTesting(ref opts) => testing(opts).await, + #[cfg(feature = "docgen")] + Opt::Man(manopts) => crate::docgen::generate_manpages(&manopts.directory), + Opt::ProvisionalRepair(opts) => match opts { + ProvisionalRepairOpts::AnalyzeInodes { + repo, + verbose, + write_result_to, + } => { + let repo = parse_repo(&repo)?; + let check_res = crate::repair::check_inode_collision(&repo, verbose)?; + handle_serialize_to_file(write_result_to.as_deref(), &check_res)?; + if check_res.collisions.is_empty() { + println!("OK: No colliding objects found."); + } else { + eprintln!( + "warning: {} potentially colliding inodes found", + check_res.collisions.len() + ); + } + Ok(()) + } + ProvisionalRepairOpts::Repair { + sysroot, + verbose, + dry_run, + write_result_to, + } => { + container_remount_sysroot(&sysroot)?; + let sysroot = &ostree::Sysroot::new(Some(&gio::File::for_path(&sysroot))); + sysroot.load(gio::Cancellable::NONE)?; + let sysroot = &SysrootLock::new_from_sysroot(sysroot).await?; + let result = crate::repair::analyze_for_repair(sysroot, verbose)?; + handle_serialize_to_file(write_result_to.as_deref(), &result)?; + if dry_run { + result.check() + } else { + result.repair(sysroot) + } + } + }, + } +} diff --git a/crates/ostree-ext/src/commit.rs b/crates/ostree-ext/src/commit.rs new file mode 100644 index 000000000..31571d1ed --- /dev/null +++ b/crates/ostree-ext/src/commit.rs @@ -0,0 +1,180 @@ +//! This module contains the functions to implement the commit +//! procedures as part of building an ostree container image. +//! + +use crate::container_utils::require_ostree_container; +use anyhow::Context; +use anyhow::Result; +use cap_std::fs::Dir; +use cap_std::fs::MetadataExt; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; +use std::path::Path; +use std::path::PathBuf; +use tokio::task; + +/// Directories for which we will always remove all content. +const FORCE_CLEAN_PATHS: &[&str] = &["run", "tmp", "var/tmp", "var/cache"]; + +/// Recursively remove the target directory, but avoid traversing across mount points. +fn remove_all_on_mount_recurse(root: &Dir, rootdev: u64, path: &Path) -> Result { + let mut skipped = false; + for entry in root + .read_dir(path) + .with_context(|| format!("Reading {path:?}"))? + { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.dev() != rootdev { + skipped = true; + continue; + } + let name = entry.file_name(); + let path = &path.join(name); + + if metadata.is_dir() { + skipped |= remove_all_on_mount_recurse(root, rootdev, path.as_path())?; + } else { + root.remove_file(path) + .with_context(|| format!("Removing {path:?}"))?; + } + } + if !skipped { + root.remove_dir(path) + .with_context(|| format!("Removing {path:?}"))?; + } + Ok(skipped) +} + +fn clean_subdir(root: &Dir, rootdev: u64) -> Result<()> { + for entry in root.entries()? { + let entry = entry?; + let metadata = entry.metadata()?; + let dev = metadata.dev(); + let path = PathBuf::from(entry.file_name()); + // Ignore other filesystem mounts, e.g. podman injects /run/.containerenv + if dev != rootdev { + tracing::trace!("Skipping entry in foreign dev {path:?}"); + continue; + } + // Also ignore bind mounts, if we have a new enough kernel with statx() + // that will tell us. + if root.is_mountpoint(&path)?.unwrap_or_default() { + tracing::trace!("Skipping mount point {path:?}"); + continue; + } + if metadata.is_dir() { + remove_all_on_mount_recurse(root, rootdev, &path)?; + } else { + root.remove_file(&path) + .with_context(|| format!("Removing {path:?}"))?; + } + } + Ok(()) +} + +fn clean_paths_in(root: &Dir, rootdev: u64) -> Result<()> { + for path in FORCE_CLEAN_PATHS { + let subdir = if let Some(subdir) = root.open_dir_optional(path)? { + subdir + } else { + continue; + }; + clean_subdir(&subdir, rootdev).with_context(|| format!("Cleaning {path}"))?; + } + Ok(()) +} + +/// Given a root filesystem, clean out empty directories and warn about +/// files in /var. /run, /tmp, and /var/tmp have their contents recursively cleaned. +pub fn prepare_ostree_commit_in(root: &Dir) -> Result<()> { + let rootdev = root.dir_metadata()?.dev(); + clean_paths_in(root, rootdev) +} + +/// Like [`prepare_ostree_commit_in`] but only emits warnings about unsupported +/// files in `/var` and will not error. +pub fn prepare_ostree_commit_in_nonstrict(root: &Dir) -> Result<()> { + let rootdev = root.dir_metadata()?.dev(); + clean_paths_in(root, rootdev) +} + +/// Entrypoint to the commit procedures, initially we just +/// have one validation but we expect more in the future. +pub(crate) async fn container_commit() -> Result<()> { + task::spawn_blocking(move || { + require_ostree_container()?; + let rootdir = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + prepare_ostree_commit_in(&rootdir) + }) + .await? +} + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8Path; + + use cap_std_ext::cap_tempfile; + + #[test] + fn commit() -> Result<()> { + let td = &cap_tempfile::tempdir(cap_std::ambient_authority())?; + + // Handle the empty case + prepare_ostree_commit_in(td).unwrap(); + prepare_ostree_commit_in_nonstrict(td).unwrap(); + + let var = Utf8Path::new("var"); + let run = Utf8Path::new("run"); + let tmp = Utf8Path::new("tmp"); + let vartmp_foobar = &var.join("tmp/foo/bar"); + let runsystemd = &run.join("systemd"); + let resolvstub = &runsystemd.join("resolv.conf"); + + for p in [var, run, tmp] { + td.create_dir(p)?; + } + + td.create_dir_all(vartmp_foobar)?; + td.write(vartmp_foobar.join("a"), "somefile")?; + td.write(vartmp_foobar.join("b"), "somefile2")?; + td.create_dir_all(runsystemd)?; + td.write(resolvstub, "stub resolv")?; + prepare_ostree_commit_in(td).unwrap(); + assert!(td.try_exists(var)?); + assert!(td.try_exists(var.join("tmp"))?); + assert!(!td.try_exists(vartmp_foobar)?); + assert!(td.try_exists(run)?); + assert!(!td.try_exists(runsystemd)?); + + let systemd = run.join("systemd"); + td.create_dir_all(&systemd)?; + prepare_ostree_commit_in(td).unwrap(); + assert!(td.try_exists(var)?); + assert!(!td.try_exists(&systemd)?); + + td.remove_dir_all(var)?; + td.create_dir(var)?; + td.write(var.join("foo"), "somefile")?; + prepare_ostree_commit_in(td).unwrap(); + // Right now we don't auto-create var/tmp if it didn't exist, but maybe + // we will in the future. + assert!(!td.try_exists(var.join("tmp"))?); + assert!(td.try_exists(var)?); + + td.write(var.join("foo"), "somefile")?; + prepare_ostree_commit_in_nonstrict(td).unwrap(); + assert!(td.try_exists(var)?); + + let nested = Utf8Path::new("var/lib/nested"); + td.create_dir_all(nested)?; + td.write(nested.join("foo"), "test1")?; + td.write(nested.join("foo2"), "test2")?; + prepare_ostree_commit_in(td).unwrap(); + assert!(td.try_exists(var)?); + assert!(td.try_exists(nested)?); + + Ok(()) + } +} diff --git a/crates/ostree-ext/src/container/deploy.rs b/crates/ostree-ext/src/container/deploy.rs new file mode 100644 index 000000000..32345f8e8 --- /dev/null +++ b/crates/ostree-ext/src/container/deploy.rs @@ -0,0 +1,264 @@ +//! Perform initial setup for a container image based system root + +use anyhow::Result; +use fn_error_context::context; +use ostree::glib; +use std::collections::HashSet; + +use super::store::{gc_image_layers, LayeredImageState}; +use super::{ImageReference, OstreeImageReference}; +use crate::container::store::PrepareResult; +use crate::keyfileext::KeyFileExt; +use crate::sysroot::SysrootLock; + +/// The key in the OSTree origin which holds a serialized [`super::OstreeImageReference`]. +pub const ORIGIN_CONTAINER: &str = "container-image-reference"; + +/// The name of the default stateroot. +// xref https://github.com/ostreedev/ostree/issues/2794 +pub const STATEROOT_DEFAULT: &str = "default"; + +/// Options configuring deployment. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct DeployOpts<'a> { + /// Kernel arguments to use. + pub kargs: Option<&'a [&'a str]>, + /// Target image reference, as distinct from the source. + /// + /// In many cases, one may want a workflow where a system is provisioned from + /// an image with a specific digest (e.g. `quay.io/example/os@sha256:...) for + /// reproducibilty. However, one would want `ostree admin upgrade` to fetch + /// `quay.io/example/os:latest`. + /// + /// To implement this, use this option for the latter `:latest` tag. + pub target_imgref: Option<&'a OstreeImageReference>, + + /// Configuration for fetching containers. + pub proxy_cfg: Option, + + /// If true, then no image reference will be written; but there will be refs + /// for the fetched layers. This ensures that if the machine is later updated + /// to a different container image, the fetch process will reuse shared layers, but + /// it will not be necessary to remove the previous image. + pub no_imgref: bool, + + /// Do not invoke bootc completion + pub skip_completion: bool, + + /// Do not cleanup deployments + pub no_clean: bool, +} + +/// Write a container image to an OSTree deployment. +/// +/// This API is currently intended for only an initial deployment. +#[context("Performing deployment")] +pub async fn deploy( + sysroot: &ostree::Sysroot, + stateroot: &str, + imgref: &OstreeImageReference, + options: Option>, +) -> Result> { + // Log the deployment operation to systemd journal (Debug level since staging already logged the main info) + + const DEPLOY_JOURNAL_ID: &str = "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3"; + + tracing::debug!( + message_id = DEPLOY_JOURNAL_ID, + bootc.image.reference = &imgref.imgref.name, + bootc.image.transport = &imgref.imgref.transport.to_string(), + bootc.stateroot = stateroot, + "Deploying container image to OSTree: {}", + imgref + ); + + let cancellable = ostree::gio::Cancellable::NONE; + let options = options.unwrap_or_default(); + let repo = &sysroot.repo(); + let merge_deployment = sysroot.merge_deployment(Some(stateroot)); + let mut imp = + super::store::ImageImporter::new(repo, imgref, options.proxy_cfg.unwrap_or_default()) + .await?; + imp.require_bootable(); + if let Some(target) = options.target_imgref { + imp.set_target(target); + } + if options.no_imgref { + imp.set_no_imgref(); + } + let state = match imp.prepare().await? { + PrepareResult::AlreadyPresent(r) => r, + PrepareResult::Ready(prep) => { + if let Some(warning) = prep.deprecated_warning() { + crate::cli::print_deprecated_warning(warning).await; + } + + imp.import(prep).await? + } + }; + let commit = state.merge_commit.as_str(); + let origin = glib::KeyFile::new(); + let target_imgref = options.target_imgref.unwrap_or(imgref); + origin.set_string("origin", ORIGIN_CONTAINER, &target_imgref.to_string()); + + let opts = ostree::SysrootDeployTreeOpts { + override_kernel_argv: options.kargs, + ..Default::default() + }; + + if sysroot.booted_deployment().is_some() { + sysroot.stage_tree_with_options( + Some(stateroot), + commit, + Some(&origin), + merge_deployment.as_ref(), + &opts, + cancellable, + )?; + } else { + let deployment = &sysroot.deploy_tree_with_options( + Some(stateroot), + commit, + Some(&origin), + merge_deployment.as_ref(), + Some(&opts), + cancellable, + )?; + let flags = if options.no_clean { + ostree::SysrootSimpleWriteDeploymentFlags::NO_CLEAN + } else { + ostree::SysrootSimpleWriteDeploymentFlags::NONE + }; + sysroot.simple_write_deployment( + Some(stateroot), + deployment, + merge_deployment.as_ref(), + flags, + cancellable, + )?; + + // We end up re-executing ourselves as a subprocess because + // otherwise right now we end up with a circular dependency between + // crates. We need an option to skip though so when the *main* + // bootc install code calls this API, we don't do this as it + // will have already been handled. + // Note also we do this under a feature gate to ensure rpm-ostree + // doesn't try to invoke this, as that won't work right now. + #[cfg(feature = "bootc")] + if !options.skip_completion { + use bootc_utils::CommandRunExt; + use cap_std_ext::cmdext::CapStdExtCommandExt; + use ocidir::cap_std::fs::Dir; + + let sysroot_dir = &Dir::reopen_dir(&crate::sysroot::sysroot_fd(sysroot))?; + + // Note that the sysroot is provided as `.` but we use cwd_dir to + // make the process current working directory the sysroot. + let st = std::process::Command::new(std::env::current_exe()?) + .args(["internals", "bootc-install-completion", ".", stateroot]) + .cwd_dir(sysroot_dir.try_clone()?) + .lifecycle_bind() + .status()?; + if !st.success() { + anyhow::bail!("Failed to complete bootc install"); + } + } + + if !options.no_clean { + sysroot.cleanup(cancellable)?; + } + } + + Ok(state) +} + +/// Query the container image reference for a deployment +fn deployment_origin_container( + deploy: &ostree::Deployment, +) -> Result> { + let origin = deploy + .origin() + .map(|o| o.optional_string("origin", ORIGIN_CONTAINER)) + .transpose()? + .flatten(); + let r = origin + .map(|v| OstreeImageReference::try_from(v.as_str())) + .transpose()?; + Ok(r) +} + +/// Remove all container images which are not the target of a deployment. +/// This acts equivalently to [`super::store::remove_images()`] - the underlying layers +/// are not pruned. +/// +/// The set of removed images is returned. +pub fn remove_undeployed_images(sysroot: &SysrootLock) -> Result> { + let repo = &sysroot.repo(); + let deployment_origins: Result> = sysroot + .deployments() + .into_iter() + .filter_map(|deploy| { + deployment_origin_container(&deploy) + .map(|v| v.map(|v| v.imgref)) + .transpose() + }) + .collect(); + let deployment_origins = deployment_origins?; + // TODO add an API that returns ImageReference instead + let all_images = super::store::list_images(&sysroot.repo())? + .into_iter() + .filter_map(|img| ImageReference::try_from(img.as_str()).ok()); + let mut removed = Vec::new(); + for image in all_images { + if !deployment_origins.contains(&image) { + super::store::remove_image(repo, &image)?; + removed.push(image); + } + } + Ok(removed) +} + +/// The result of a prune operation +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pruned { + /// The number of images that were pruned + pub n_images: u32, + /// The number of image layers that were pruned + pub n_layers: u32, + /// The number of OSTree objects that were pruned + pub n_objects_pruned: u32, + /// The total size of pruned objects + pub objsize: u64, +} + +impl Pruned { + /// Whether this prune was a no-op (i.e. no images, layers or objects were pruned). + pub fn is_empty(&self) -> bool { + self.n_images == 0 && self.n_layers == 0 && self.n_objects_pruned == 0 + } +} + +/// This combines the functionality of [`remove_undeployed_images()`] with [`super::store::gc_image_layers()`]. +pub fn prune(sysroot: &SysrootLock) -> Result { + let repo = &sysroot.repo(); + // Prune container images which are not deployed. + // SAFETY: There should never be more than u32 images + let n_images = remove_undeployed_images(sysroot)?.len().try_into().unwrap(); + // Prune unreferenced layer branches. + let n_layers = gc_image_layers(repo)?; + // Prune the objects in the repo; the above just removed refs (branches). + let (_, n_objects_pruned, objsize) = repo.prune( + ostree::RepoPruneFlags::REFS_ONLY, + 0, + ostree::gio::Cancellable::NONE, + )?; + // SAFETY: The number of pruned objects should never be negative + let n_objects_pruned = u32::try_from(n_objects_pruned).unwrap(); + Ok(Pruned { + n_images, + n_layers, + n_objects_pruned, + objsize, + }) +} diff --git a/crates/ostree-ext/src/container/encapsulate.rs b/crates/ostree-ext/src/container/encapsulate.rs new file mode 100644 index 000000000..338dbaab4 --- /dev/null +++ b/crates/ostree-ext/src/container/encapsulate.rs @@ -0,0 +1,482 @@ +//! APIs for creating container images from OSTree commits + +use super::{ImageReference, SignatureSource, OSTREE_COMMIT_LABEL}; +use super::{OstreeImageReference, Transport, COMPONENT_SEPARATOR, CONTENT_ANNOTATION}; +use crate::chunking::{Chunk, Chunking, ObjectMetaSized}; +use crate::container::skopeo; +use crate::objectsource::ContentID; +use crate::tar as ostree_tar; +use anyhow::{anyhow, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use chrono::DateTime; +use containers_image_proxy::oci_spec; +use flate2::Compression; +use fn_error_context::context; +use gio::glib; +use oci_spec::image as oci_image; +use ocidir::{Layer, OciDir}; +use ostree::gio; +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; +use std::num::NonZeroU32; +use tracing::instrument; + +/// The label which may be used in addition to the standard OCI label. +pub const LEGACY_VERSION_LABEL: &str = "version"; +/// The label which indicates where the ostree layers stop, and the +/// derived ones start. +pub const DIFFID_LABEL: &str = "ostree.final-diffid"; +/// The label for bootc. +pub const BOOTC_LABEL: &str = "containers.bootc"; + +/// Annotation injected into the layer to say that this is an ostree commit. +/// However, because this gets lost when converted to D2S2 https://docs.docker.com/registry/spec/manifest-v2-2/ +/// schema, it's not actually useful today. But, we keep it +/// out of principle. +const BLOB_OSTREE_ANNOTATION: &str = "ostree.encapsulated"; +/// Configuration for the generated container. +#[derive(Debug, Default)] +pub struct Config { + /// Additional labels. + pub labels: Option>, + /// The equivalent of a `Dockerfile`'s `CMD` instruction. + pub cmd: Option>, +} + +fn commit_meta_to_labels<'a>( + meta: &glib::VariantDict, + keys: impl IntoIterator, + opt_keys: impl IntoIterator, + labels: &mut HashMap, +) -> Result<()> { + for k in keys { + let v = meta + .lookup::(k) + .context("Expected string for commit metadata value")? + .ok_or_else(|| anyhow!("Could not find commit metadata key: {}", k))?; + labels.insert(k.to_string(), v); + } + for k in opt_keys { + let v = meta + .lookup::(k) + .context("Expected string for commit metadata value")?; + if let Some(v) = v { + labels.insert(k.to_string(), v); + } + } + // Copy standard metadata keys `ostree.bootable` and `ostree.linux`. + // Bootable is an odd one out in being a boolean. + #[allow(clippy::explicit_auto_deref)] + if let Some(v) = meta.lookup::(ostree::METADATA_KEY_BOOTABLE)? { + labels.insert(ostree::METADATA_KEY_BOOTABLE.to_string(), v.to_string()); + labels.insert(BOOTC_LABEL.into(), "1".into()); + } + // Handle any other string-typed values here. + for k in &[&ostree::METADATA_KEY_LINUX] { + if let Some(v) = meta.lookup::(k)? { + labels.insert(k.to_string(), v); + } + } + Ok(()) +} + +fn export_chunks( + repo: &ostree::Repo, + commit: &str, + ociw: &mut OciDir, + chunks: Vec, + opts: &ExportOpts, +) -> Result)>> { + chunks + .into_iter() + .enumerate() + .map(|(i, chunk)| -> Result<_> { + let mut w = ociw.create_layer(Some(opts.compression()))?; + ostree_tar::export_chunk( + repo, + commit, + chunk.content, + &mut w, + opts.tar_create_parent_dirs, + ) + .with_context(|| format!("Exporting chunk {i}"))?; + let w = w.into_inner()?; + Ok((w.complete()?, chunk.name, chunk.packages)) + }) + .collect() +} + +/// Write an ostree commit to an OCI blob +#[context("Writing ostree root to blob")] +#[allow(clippy::too_many_arguments)] +pub(crate) fn export_chunked( + repo: &ostree::Repo, + commit: &str, + ociw: &mut OciDir, + manifest: &mut oci_image::ImageManifest, + imgcfg: &mut oci_image::ImageConfiguration, + labels: &mut HashMap, + mut chunking: Chunking, + opts: &ExportOpts, + description: &str, +) -> Result<()> { + let layers = export_chunks(repo, commit, ociw, chunking.take_chunks(), opts)?; + let compression = Some(opts.compression()); + + // In V1, the ostree layer comes first + let mut w = ociw.create_layer(compression)?; + ostree_tar::export_final_chunk( + repo, + commit, + chunking.remainder, + &mut w, + opts.tar_create_parent_dirs, + )?; + let w = w.into_inner()?; + let ostree_layer = w.complete()?; + + // Then, we have a label that points to the last chunk. + // Note in the pathological case of a single layer chunked v1 image, this could be the ostree layer. + let last_digest = layers + .last() + .map(|v| &v.0) + .unwrap_or(&ostree_layer) + .uncompressed_sha256 + .clone(); + + let created = imgcfg + .created() + .as_deref() + .and_then(bootc_utils::try_deserialize_timestamp) + .unwrap_or_default(); + // Add the ostree layer + ociw.push_layer_full( + manifest, + imgcfg, + ostree_layer, + None::>, + description, + created, + ); + // Add the component/content layers + let mut buf = [0; 8]; + let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf); + for (layer, name, mut packages) in layers { + let mut annotation_component_layer = HashMap::new(); + packages.sort(); + annotation_component_layer.insert(CONTENT_ANNOTATION.to_string(), packages.join(sep)); + ociw.push_layer_full( + manifest, + imgcfg, + layer, + Some(annotation_component_layer), + name.as_str(), + created, + ); + } + + // This label (mentioned above) points to the last layer that is part of + // the ostree commit. + labels.insert( + DIFFID_LABEL.into(), + format!("sha256:{}", last_digest.digest()), + ); + Ok(()) +} + +/// Generate an OCI image from a given ostree root +#[context("Building oci")] +#[allow(clippy::too_many_arguments)] +fn build_oci( + repo: &ostree::Repo, + rev: &str, + writer: &mut OciDir, + tag: Option<&str>, + config: &Config, + opts: ExportOpts, +) -> Result<()> { + let commit = repo.require_rev(rev)?; + let commit = commit.as_str(); + let (commit_v, _) = repo.load_commit(commit)?; + let commit_timestamp = DateTime::from_timestamp( + ostree::commit_get_timestamp(&commit_v).try_into().unwrap(), + 0, + ) + .unwrap(); + let commit_subject = commit_v.child_value(3); + let commit_subject = commit_subject.str().ok_or_else(|| { + anyhow::anyhow!( + "Corrupted commit {}; expecting string value for subject", + commit + ) + })?; + let commit_meta = &commit_v.child_value(0); + let commit_meta = glib::VariantDict::new(Some(commit_meta)); + + let mut ctrcfg = opts.container_config.clone().unwrap_or_default(); + let mut imgcfg = oci_image::ImageConfiguration::default(); + // If a platform was provided, propagate it to the config + if let Some(platform) = opts.platform.as_ref() { + imgcfg.set_architecture(platform.architecture().clone()); + imgcfg.set_os(platform.os().clone()); + } + + let created_at = opts + .created + .clone() + .unwrap_or_else(|| commit_timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string()); + imgcfg.set_created(Some(created_at)); + let mut labels = HashMap::new(); + + commit_meta_to_labels( + &commit_meta, + opts.copy_meta_keys.iter().map(|k| k.as_str()), + opts.copy_meta_opt_keys.iter().map(|k| k.as_str()), + &mut labels, + )?; + + let mut manifest = writer.new_empty_manifest()?.build().unwrap(); + + let chunking = opts + .package_contentmeta + .as_ref() + .map(|meta| { + crate::chunking::Chunking::from_mapping( + repo, + commit, + meta, + &opts.max_layers, + opts.prior_build, + opts.specific_contentmeta, + ) + }) + .transpose()?; + // If no chunking was provided, create a logical single chunk. + let chunking = chunking + .map(Ok) + .unwrap_or_else(|| crate::chunking::Chunking::new(repo, commit))?; + + if let Some(version) = commit_meta.lookup::("version")? { + if opts.legacy_version_label { + labels.insert(LEGACY_VERSION_LABEL.into(), version.clone()); + } + labels.insert(oci_image::ANNOTATION_VERSION.into(), version); + } + labels.insert(OSTREE_COMMIT_LABEL.into(), commit.into()); + + for (k, v) in config.labels.iter().flat_map(|k| k.iter()) { + labels.insert(k.into(), v.into()); + } + + let mut annos = HashMap::new(); + annos.insert(BLOB_OSTREE_ANNOTATION.to_string(), "true".to_string()); + let description = if commit_subject.is_empty() { + Cow::Owned(format!("ostree export of commit {commit}")) + } else { + Cow::Borrowed(commit_subject) + }; + + export_chunked( + repo, + commit, + writer, + &mut manifest, + &mut imgcfg, + &mut labels, + chunking, + &opts, + &description, + )?; + + // Lookup the cmd embedded in commit metadata + let cmd = commit_meta.lookup::>(ostree::COMMIT_META_CONTAINER_CMD)?; + // But support it being overridden by CLI options + + // https://github.com/rust-lang/rust-clippy/pull/7639#issuecomment-1050340564 + #[allow(clippy::unnecessary_lazy_evaluations)] + let cmd = config.cmd.as_ref().or_else(|| cmd.as_ref()); + if let Some(cmd) = cmd { + ctrcfg.set_cmd(Some(cmd.clone())); + } + + // Our platform uses the image config + let platform = oci_image::PlatformBuilder::default() + .architecture(imgcfg.architecture().clone()) + .os(imgcfg.os().clone()) + .build() + .unwrap(); + + ctrcfg + .labels_mut() + .get_or_insert_with(Default::default) + .extend(labels.clone()); + imgcfg.set_config(Some(ctrcfg)); + let ctrcfg = writer.write_config(imgcfg)?; + manifest.set_config(ctrcfg); + manifest.set_annotations(Some(labels)); + + if let Some(tag) = tag { + writer.insert_manifest(manifest, Some(tag), platform)?; + } else { + writer.replace_with_single_manifest(manifest, platform)?; + } + + Ok(()) +} + +/// Interpret a filesystem path as optionally including a tag. Paths +/// such as `/foo/bar` will return `("/foo/bar"`, None)`, whereas +/// e.g. `/foo/bar:latest` will return `("/foo/bar", Some("latest"))`. +pub(crate) fn parse_oci_path_and_tag(path: &str) -> (&str, Option<&str>) { + match path.split_once(':') { + Some((path, tag)) => (path, Some(tag)), + None => (path, None), + } +} + +/// Helper for `build()` that avoids generics +#[instrument(level = "debug", skip_all)] +async fn build_impl( + repo: &ostree::Repo, + ostree_ref: &str, + config: &Config, + opts: Option>, + dest: &ImageReference, +) -> Result { + let mut opts = opts.unwrap_or_default(); + if dest.transport == Transport::ContainerStorage { + opts.skip_compression = true; + } + let digest = if dest.transport == Transport::OciDir { + let (path, tag) = parse_oci_path_and_tag(dest.name.as_str()); + tracing::debug!("using OCI path={path} tag={tag:?}"); + if !Utf8Path::new(path).exists() { + std::fs::create_dir(path).with_context(|| format!("Creating {path}"))?; + } + let ocidir = Dir::open_ambient_dir(path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + let mut ocidir = OciDir::ensure(ocidir).context("Opening OCI")?; + build_oci(repo, ostree_ref, &mut ocidir, tag, config, opts)?; + None + } else { + let tempdir = { + let vartmp = Dir::open_ambient_dir("/var/tmp", cap_std::ambient_authority())?; + cap_std_ext::cap_tempfile::tempdir_in(&vartmp)? + }; + let mut ocidir = OciDir::ensure(tempdir.try_clone()?)?; + + // Minor TODO: refactor to avoid clone + let authfile = opts.authfile.clone(); + build_oci(repo, ostree_ref, &mut ocidir, None, config, opts)?; + drop(ocidir); + + // Pass the temporary oci directory as the current working directory for the skopeo process + let target_fd = 3i32; + let tempoci = ImageReference { + transport: Transport::OciDir, + name: format!("/proc/self/fd/{target_fd}"), + }; + let digest = skopeo::copy( + &tempoci, + dest, + authfile.as_deref(), + Some((std::sync::Arc::new(tempdir.try_clone()?.into()), target_fd)), + false, + ) + .await?; + Some(digest) + }; + if let Some(digest) = digest { + Ok(digest) + } else { + // If `skopeo copy` doesn't have `--digestfile` yet, then fall back + // to running an inspect cycle. + let imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: dest.to_owned(), + }; + let (_, digest) = super::unencapsulate::fetch_manifest(&imgref) + .await + .context("Querying manifest after push")?; + Ok(digest) + } +} + +/// Options controlling commit export into OCI +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct ExportOpts<'m, 'o> { + /// If true, do not perform gzip compression of the tar layers. + pub skip_compression: bool, + /// A set of commit metadata keys to copy as image labels. + pub copy_meta_keys: Vec, + /// A set of optionally-present commit metadata keys to copy as image labels. + pub copy_meta_opt_keys: Vec, + /// Maximum number of layers to use + pub max_layers: Option, + /// Path to Docker-formatted authentication file. + pub authfile: Option, + /// Also include the legacy `version` label. + pub legacy_version_label: bool, + /// Image runtime configuration that will be used as a base + pub container_config: Option, + /// Override the default platform + pub platform: Option, + /// A reference to the metadata for a previous build; used to optimize + /// the packing structure. + pub prior_build: Option<&'m oci_image::ImageManifest>, + /// Metadata mapping between objects and their owning component/package; + /// used to optimize packing. + pub package_contentmeta: Option<&'o ObjectMetaSized>, + /// Metadata for exclusive components that should have their own layers. + /// Map from component -> (path, checksum) + pub specific_contentmeta: Option<&'o BTreeMap>>, + /// Sets the created tag in the image manifest. + pub created: Option, + /// Whether to explicitly create all parent directories in the tar layers. + pub tar_create_parent_dirs: bool, +} + +impl ExportOpts<'_, '_> { + /// Return the gzip compression level to use, as configured by the export options. + fn compression(&self) -> Compression { + if self.skip_compression { + Compression::fast() + } else { + Compression::default() + } + } +} + +/// Given an OSTree repository and ref, generate a container image. +/// +/// The returned `ImageReference` will contain a digested (e.g. `@sha256:`) version of the destination. +pub async fn encapsulate>( + repo: &ostree::Repo, + ostree_ref: S, + config: &Config, + opts: Option>, + dest: &ImageReference, +) -> Result { + build_impl(repo, ostree_ref.as_ref(), config, opts, dest).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ocipath() { + let default = "/foo/bar"; + let untagged = "/foo/bar:baz"; + let tagged = "/foo/bar:baz:latest"; + assert_eq!(parse_oci_path_and_tag(default), ("/foo/bar", None)); + assert_eq!( + parse_oci_path_and_tag(tagged), + ("/foo/bar", Some("baz:latest")) + ); + assert_eq!(parse_oci_path_and_tag(untagged), ("/foo/bar", Some("baz"))); + } +} diff --git a/crates/ostree-ext/src/container/mod.rs b/crates/ostree-ext/src/container/mod.rs new file mode 100644 index 000000000..5c252478a --- /dev/null +++ b/crates/ostree-ext/src/container/mod.rs @@ -0,0 +1,652 @@ +//! # APIs bridging OSTree and container images +//! +//! This module contains APIs to bidirectionally map between a single OSTree commit and a container image wrapping it. +//! Because container images are just layers of tarballs, this builds on the [`crate::tar`] module. +//! +//! To emphasize this, the current high level model is that this is a one-to-one mapping - an ostree commit +//! can be exported (wrapped) into a container image, which will have exactly one layer. Upon import +//! back into an ostree repository, all container metadata except for its digested checksum will be discarded. +//! +//! ## Signatures +//! +//! OSTree supports GPG and ed25519 signatures natively, and it's expected by default that +//! when booting from a fetched container image, one verifies ostree-level signatures. +//! For ostree, a signing configuration is specified via an ostree remote. In order to +//! pair this configuration together, this library defines a "URL-like" string schema: +//! +//! `ostree-remote-registry::` +//! +//! A concrete instantiation might be e.g.: `ostree-remote-registry:fedora:quay.io/coreos/fedora-coreos:stable` +//! +//! To parse and generate these strings, see [`OstreeImageReference`]. +//! +//! ## Layering +//! +//! A key feature of container images is support for layering. At the moment, support +//! for this is [planned but not implemented](https://github.com/ostreedev/ostree-rs-ext/issues/12). + +use anyhow::anyhow; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::Dir; +use containers_image_proxy::oci_spec; +use ostree::glib; +use serde::Serialize; + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Debug; +use std::ops::Deref; +use std::str::FromStr; + +/// The label injected into a container image that contains the ostree commit SHA-256. +pub const OSTREE_COMMIT_LABEL: &str = "ostree.commit"; + +/// The name of an annotation attached to a layer which names the packages/components +/// which are part of it. +pub(crate) const CONTENT_ANNOTATION: &str = "ostree.components"; +/// The character we use to separate values in [`CONTENT_ANNOTATION`]. +pub(crate) const COMPONENT_SEPARATOR: char = ','; + +/// Our generic catchall fatal error, expected to be converted +/// to a string to output to a terminal or logs. +type Result = anyhow::Result; + +/// A backend/transport for OCI/Docker images. +#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq)] +pub enum Transport { + /// A remote Docker/OCI registry (`registry:` or `docker://`) + Registry, + /// A local OCI directory (`oci:`) + OciDir, + /// A local OCI archive tarball (`oci-archive:`) + OciArchive, + /// A local Docker archive tarball (`docker-archive:`) + DockerArchive, + /// Local container storage (`containers-storage:`) + ContainerStorage, + /// Local directory (`dir:`) + Dir, + /// Local Docker daemon (`docker-daemon:`) + DockerDaemon, +} + +/// Combination of a remote image reference and transport. +/// +/// For example, +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct ImageReference { + /// The storage and transport for the image + pub transport: Transport, + /// The image name (e.g. `quay.io/somerepo/someimage:latest`) + pub name: String, +} + +/// Policy for signature verification. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SignatureSource { + /// Fetches will use the named ostree remote for signature verification of the ostree commit. + OstreeRemote(String), + /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy. + ContainerPolicy, + /// NOT RECOMMENDED. Fetches will defer to the `containers-policy.json` default which is usually `insecureAcceptAnything`. + ContainerPolicyAllowInsecure, +} + +/// A commonly used pre-OCI label for versions. +pub const LABEL_VERSION: &str = "version"; + +/// Combination of a signature verification mechanism, and a standard container image reference. +/// +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OstreeImageReference { + /// The signature verification mechanism. + pub sigverify: SignatureSource, + /// The container image reference. + pub imgref: ImageReference, +} + +impl TryFrom<&str> for Transport { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + Ok(match value { + Self::REGISTRY_STR | "docker" => Self::Registry, + Self::OCI_STR => Self::OciDir, + Self::OCI_ARCHIVE_STR => Self::OciArchive, + Self::DOCKER_ARCHIVE_STR => Self::DockerArchive, + Self::CONTAINERS_STORAGE_STR => Self::ContainerStorage, + Self::LOCAL_DIRECTORY_STR => Self::Dir, + Self::DOCKER_DAEMON_STR => Self::DockerDaemon, + o => return Err(anyhow!("Unknown transport '{}'", o)), + }) + } +} + +impl Transport { + const OCI_STR: &'static str = "oci"; + const OCI_ARCHIVE_STR: &'static str = "oci-archive"; + const DOCKER_ARCHIVE_STR: &'static str = "docker-archive"; + const CONTAINERS_STORAGE_STR: &'static str = "containers-storage"; + const LOCAL_DIRECTORY_STR: &'static str = "dir"; + const REGISTRY_STR: &'static str = "registry"; + const DOCKER_DAEMON_STR: &'static str = "docker-daemon"; + + /// Retrieve an identifier that can then be re-parsed from [`Transport::try_from::<&str>`]. + pub fn serializable_name(&self) -> &'static str { + match self { + Transport::Registry => Self::REGISTRY_STR, + Transport::OciDir => Self::OCI_STR, + Transport::OciArchive => Self::OCI_ARCHIVE_STR, + Transport::DockerArchive => Self::DOCKER_ARCHIVE_STR, + Transport::ContainerStorage => Self::CONTAINERS_STORAGE_STR, + Transport::Dir => Self::LOCAL_DIRECTORY_STR, + Transport::DockerDaemon => Self::DOCKER_DAEMON_STR, + } + } +} + +impl TryFrom<&str> for ImageReference { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + let (transport_name, mut name) = value + .split_once(':') + .ok_or_else(|| anyhow!("Missing ':' in {}", value))?; + let transport: Transport = transport_name.try_into()?; + if name.is_empty() { + return Err(anyhow!("Invalid empty name in {}", value)); + } + if transport_name == "docker" { + name = name + .strip_prefix("//") + .ok_or_else(|| anyhow!("Missing // in docker:// in {}", value))?; + } + Ok(Self { + transport, + name: name.to_string(), + }) + } +} + +impl FromStr for ImageReference { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl TryFrom<&str> for SignatureSource { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "ostree-image-signed" => Ok(Self::ContainerPolicy), + "ostree-unverified-image" => Ok(Self::ContainerPolicyAllowInsecure), + o => match o.strip_prefix("ostree-remote-image:") { + Some(rest) => Ok(Self::OstreeRemote(rest.to_string())), + _ => Err(anyhow!("Invalid signature source: {}", o)), + }, + } + } +} + +impl FromStr for SignatureSource { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl TryFrom<&str> for OstreeImageReference { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + let (first, second) = value + .split_once(':') + .ok_or_else(|| anyhow!("Missing ':' in {}", value))?; + let (sigverify, rest) = match first { + "ostree-image-signed" => (SignatureSource::ContainerPolicy, Cow::Borrowed(second)), + "ostree-unverified-image" => ( + SignatureSource::ContainerPolicyAllowInsecure, + Cow::Borrowed(second), + ), + // Shorthand for ostree-unverified-image:registry: + "ostree-unverified-registry" => ( + SignatureSource::ContainerPolicyAllowInsecure, + Cow::Owned(format!("registry:{second}")), + ), + // This is a shorthand for ostree-remote-image with registry: + "ostree-remote-registry" => { + let (remote, rest) = second + .split_once(':') + .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?; + ( + SignatureSource::OstreeRemote(remote.to_string()), + Cow::Owned(format!("registry:{rest}")), + ) + } + "ostree-remote-image" => { + let (remote, rest) = second + .split_once(':') + .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?; + ( + SignatureSource::OstreeRemote(remote.to_string()), + Cow::Borrowed(rest), + ) + } + o => { + return Err(anyhow!("Invalid ostree image reference scheme: {}", o)); + } + }; + let imgref = rest.deref().try_into()?; + Ok(Self { sigverify, imgref }) + } +} + +impl FromStr for OstreeImageReference { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl std::fmt::Display for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + // TODO once skopeo supports this, canonicalize as registry: + Self::Registry => "docker://", + Self::OciArchive => "oci-archive:", + Self::DockerArchive => "docker-archive:", + Self::OciDir => "oci:", + Self::ContainerStorage => "containers-storage:", + Self::Dir => "dir:", + Self::DockerDaemon => "docker-daemon:", + }; + f.write_str(s) + } +} + +impl std::fmt::Display for ImageReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.transport, self.name) + } +} + +impl std::fmt::Display for SignatureSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SignatureSource::OstreeRemote(r) => write!(f, "ostree-remote-image:{r}"), + SignatureSource::ContainerPolicy => write!(f, "ostree-image-signed"), + SignatureSource::ContainerPolicyAllowInsecure => { + write!(f, "ostree-unverified-image") + } + } + } +} + +impl std::fmt::Display for OstreeImageReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match (&self.sigverify, &self.imgref) { + (SignatureSource::ContainerPolicyAllowInsecure, imgref) + if imgref.transport == Transport::Registry => + { + // Because allow-insecure is the effective default, allow formatting + // without it. Note this formatting is asymmetric and cannot be + // re-parsed. + if f.alternate() { + write!(f, "{}", self.imgref) + } else { + write!(f, "ostree-unverified-registry:{}", self.imgref.name) + } + } + (sigverify, imgref) => { + write!(f, "{sigverify}:{imgref}") + } + } + } +} + +/// Represents the difference in layer/blob content between two OCI image manifests. +#[derive(Debug, Serialize)] +pub struct ManifestDiff<'a> { + /// The source container image manifest. + #[serde(skip)] + pub from: &'a oci_spec::image::ImageManifest, + /// The target container image manifest. + #[serde(skip)] + pub to: &'a oci_spec::image::ImageManifest, + /// Layers which are present in the old image but not the new image. + #[serde(skip)] + pub removed: Vec<&'a oci_spec::image::Descriptor>, + /// Layers which are present in the new image but not the old image. + #[serde(skip)] + pub added: Vec<&'a oci_spec::image::Descriptor>, + /// Total number of layers + pub total: u64, + /// Size of total number of layers. + pub total_size: u64, + /// Number of layers removed + pub n_removed: u64, + /// Size of the number of layers removed + pub removed_size: u64, + /// Number of packages added + pub n_added: u64, + /// Size of the number of layers added + pub added_size: u64, +} + +impl<'a> ManifestDiff<'a> { + /// Compute the layer difference between two OCI image manifests. + pub fn new( + src: &'a oci_spec::image::ImageManifest, + dest: &'a oci_spec::image::ImageManifest, + ) -> Self { + let src_layers = src + .layers() + .iter() + .map(|l| (l.digest().digest(), l)) + .collect::>(); + let dest_layers = dest + .layers() + .iter() + .map(|l| (l.digest().digest(), l)) + .collect::>(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for (blobid, &descriptor) in src_layers.iter() { + if !dest_layers.contains_key(blobid) { + removed.push(descriptor); + } + } + removed.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest())); + for (blobid, &descriptor) in dest_layers.iter() { + if !src_layers.contains_key(blobid) { + added.push(descriptor); + } + } + added.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest())); + + fn layersum<'a, I: Iterator>(layers: I) -> u64 { + layers.map(|layer| layer.size()).sum() + } + let total = dest_layers.len() as u64; + let total_size = layersum(dest.layers().iter()); + let n_removed = removed.len() as u64; + let n_added = added.len() as u64; + let removed_size = layersum(removed.iter().copied()); + let added_size = layersum(added.iter().copied()); + ManifestDiff { + from: src, + to: dest, + removed, + added, + total, + total_size, + n_removed, + removed_size, + n_added, + added_size, + } + } +} + +impl ManifestDiff<'_> { + /// Prints the total, removed and added content between two OCI images + pub fn print(&self) { + let print_total = self.total; + let print_total_size = glib::format_size(self.total_size); + let print_n_removed = self.n_removed; + let print_removed_size = glib::format_size(self.removed_size); + let print_n_added = self.n_added; + let print_added_size = glib::format_size(self.added_size); + println!("Total new layers: {print_total:<4} Size: {print_total_size}"); + println!("Removed layers: {print_n_removed:<4} Size: {print_removed_size}"); + println!("Added layers: {print_n_added:<4} Size: {print_added_size}"); + } +} + +/// Apply default configuration for container image pulls to an existing configuration. +/// For example, if `authfile` is not set, and `auth_anonymous` is `false`, and a global configuration file exists, it will be used. +/// +/// If there is no configured explicit subprocess for skopeo, and the process is running +/// as root, then a default isolation of running the process via `nobody` will be applied. +pub fn merge_default_container_proxy_opts( + config: &mut containers_image_proxy::ImageProxyConfig, +) -> Result<()> { + let user = rustix::process::getuid() + .is_root() + .then_some(isolation::DEFAULT_UNPRIVILEGED_USER); + merge_default_container_proxy_opts_with_isolation(config, user) +} + +/// Apply default configuration for container image pulls, with optional support +/// for isolation as an unprivileged user. +pub fn merge_default_container_proxy_opts_with_isolation( + config: &mut containers_image_proxy::ImageProxyConfig, + isolation_user: Option<&str>, +) -> Result<()> { + let auth_specified = + config.auth_anonymous || config.authfile.is_some() || config.auth_data.is_some(); + if !auth_specified { + let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + config.auth_data = crate::globals::get_global_authfile(root)?.map(|a| a.1); + // If there's no auth data, then force on anonymous pulls to ensure + // that the container stack doesn't try to find it in the standard + // container paths. + if config.auth_data.is_none() { + config.auth_anonymous = true; + } + } + // By default, drop privileges, unless the higher level code + // has configured the skopeo command explicitly. + let isolation_user = config + .skopeo_cmd + .is_none() + .then_some(isolation_user.as_ref()) + .flatten(); + if let Some(user) = isolation_user { + // Read the default authfile if it exists and pass it via file descriptor + // which will ensure it's readable when we drop privileges. + if let Some(authfile) = config.authfile.take() { + config.auth_data = Some(std::fs::File::open(authfile)?); + } + let cmd = crate::isolation::unprivileged_subprocess("skopeo", user); + config.skopeo_cmd = Some(cmd); + } + Ok(()) +} + +/// Convenience helper to return the labels, if present. +pub(crate) fn labels_of( + config: &oci_spec::image::ImageConfiguration, +) -> Option<&HashMap> { + config.config().as_ref().and_then(|c| c.labels().as_ref()) +} + +/// Retrieve the version number from an image configuration. +pub fn version_for_config(config: &oci_spec::image::ImageConfiguration) -> Option<&str> { + if let Some(labels) = labels_of(config) { + for k in [oci_spec::image::ANNOTATION_VERSION, LABEL_VERSION] { + if let Some(v) = labels.get(k) { + return Some(v.as_str()); + } + } + } + None +} + +pub mod deploy; +mod encapsulate; +pub use encapsulate::*; +mod unencapsulate; +pub use unencapsulate::*; +mod skopeo; +pub mod store; +mod update_detachedmeta; +pub use update_detachedmeta::*; + +use crate::isolation; + +#[cfg(test)] +mod tests { + use std::process::Command; + + use containers_image_proxy::ImageProxyConfig; + + use super::*; + + #[test] + fn test_serializable_transport() { + for v in [ + Transport::Registry, + Transport::ContainerStorage, + Transport::OciArchive, + Transport::DockerArchive, + Transport::OciDir, + ] { + assert_eq!(Transport::try_from(v.serializable_name()).unwrap(), v); + } + } + + const INVALID_IRS: &[&str] = &["", "foo://", "docker:blah", "registry:", "foo:bar"]; + const VALID_IRS: &[&str] = &[ + "containers-storage:localhost/someimage", + "docker://quay.io/exampleos/blah:sometag", + ]; + + #[test] + fn test_imagereference() { + let ir: ImageReference = "registry:quay.io/exampleos/blah".try_into().unwrap(); + assert_eq!(ir.transport, Transport::Registry); + assert_eq!(ir.name, "quay.io/exampleos/blah"); + assert_eq!(ir.to_string(), "docker://quay.io/exampleos/blah"); + + for &v in VALID_IRS { + ImageReference::try_from(v).unwrap(); + } + + for &v in INVALID_IRS { + if ImageReference::try_from(v).is_ok() { + panic!("Should fail to parse: {v}") + } + } + struct Case { + s: &'static str, + transport: Transport, + name: &'static str, + } + for case in [ + Case { + s: "oci:somedir", + transport: Transport::OciDir, + name: "somedir", + }, + Case { + s: "dir:/some/dir/blah", + transport: Transport::Dir, + name: "/some/dir/blah", + }, + Case { + s: "oci-archive:/path/to/foo.ociarchive", + transport: Transport::OciArchive, + name: "/path/to/foo.ociarchive", + }, + Case { + s: "docker-archive:/path/to/foo.dockerarchive", + transport: Transport::DockerArchive, + name: "/path/to/foo.dockerarchive", + }, + Case { + s: "containers-storage:localhost/someimage:blah", + transport: Transport::ContainerStorage, + name: "localhost/someimage:blah", + }, + ] { + let ir: ImageReference = case.s.try_into().unwrap(); + assert_eq!(ir.transport, case.transport); + assert_eq!(ir.name, case.name); + let reserialized = ir.to_string(); + assert_eq!(case.s, reserialized.as_str()); + } + } + + #[test] + fn test_ostreeimagereference() { + // Test both long form `ostree-remote-image:$myremote:registry` and the + // shorthand `ostree-remote-registry:$myremote`. + let ir_s = "ostree-remote-image:myremote:registry:quay.io/exampleos/blah"; + let ir_registry = "ostree-remote-registry:myremote:quay.io/exampleos/blah"; + for &ir_s in &[ir_s, ir_registry] { + let ir: OstreeImageReference = ir_s.try_into().unwrap(); + assert_eq!( + ir.sigverify, + SignatureSource::OstreeRemote("myremote".to_string()) + ); + assert_eq!(ir.imgref.transport, Transport::Registry); + assert_eq!(ir.imgref.name, "quay.io/exampleos/blah"); + assert_eq!( + ir.to_string(), + "ostree-remote-image:myremote:docker://quay.io/exampleos/blah" + ); + } + + // Also verify our FromStr impls + + let ir: OstreeImageReference = ir_s.try_into().unwrap(); + assert_eq!(ir, OstreeImageReference::from_str(ir_s).unwrap()); + // test our Eq implementation + assert_eq!(&ir, &OstreeImageReference::try_from(ir_registry).unwrap()); + + let ir_s = "ostree-image-signed:docker://quay.io/exampleos/blah"; + let ir: OstreeImageReference = ir_s.try_into().unwrap(); + assert_eq!(ir.sigverify, SignatureSource::ContainerPolicy); + assert_eq!(ir.imgref.transport, Transport::Registry); + assert_eq!(ir.imgref.name, "quay.io/exampleos/blah"); + assert_eq!(ir.to_string(), ir_s); + assert_eq!(format!("{:#}", &ir), ir_s); + + let ir_s = "ostree-unverified-image:docker://quay.io/exampleos/blah"; + let ir: OstreeImageReference = ir_s.try_into().unwrap(); + assert_eq!(ir.sigverify, SignatureSource::ContainerPolicyAllowInsecure); + assert_eq!(ir.imgref.transport, Transport::Registry); + assert_eq!(ir.imgref.name, "quay.io/exampleos/blah"); + assert_eq!( + ir.to_string(), + "ostree-unverified-registry:quay.io/exampleos/blah" + ); + let ir_shorthand = + OstreeImageReference::try_from("ostree-unverified-registry:quay.io/exampleos/blah") + .unwrap(); + assert_eq!(&ir_shorthand, &ir); + assert_eq!(format!("{:#}", &ir), "docker://quay.io/exampleos/blah"); + } + + #[test] + fn test_merge_authopts() { + // Verify idempotence of authentication processing + let mut c = ImageProxyConfig::default(); + let authf = std::fs::File::open("/dev/null").unwrap(); + c.auth_data = Some(authf); + super::merge_default_container_proxy_opts_with_isolation(&mut c, None).unwrap(); + assert!(!c.auth_anonymous); + assert!(c.authfile.is_none()); + assert!(c.auth_data.is_some()); + assert!(c.skopeo_cmd.is_none()); + super::merge_default_container_proxy_opts_with_isolation(&mut c, None).unwrap(); + assert!(!c.auth_anonymous); + assert!(c.authfile.is_none()); + assert!(c.auth_data.is_some()); + assert!(c.skopeo_cmd.is_none()); + + // Verify interaction with explicit isolation + let mut c = ImageProxyConfig { + skopeo_cmd: Some(Command::new("skopeo")), + ..Default::default() + }; + super::merge_default_container_proxy_opts_with_isolation(&mut c, Some("foo")).unwrap(); + assert_eq!(c.skopeo_cmd.unwrap().get_program(), "skopeo"); + } +} diff --git a/crates/ostree-ext/src/container/skopeo.rs b/crates/ostree-ext/src/container/skopeo.rs new file mode 100644 index 000000000..7ac462509 --- /dev/null +++ b/crates/ostree-ext/src/container/skopeo.rs @@ -0,0 +1,156 @@ +//! Fork skopeo as a subprocess + +use super::ImageReference; +use anyhow::{Context, Result}; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use containers_image_proxy::oci_spec::image as oci_image; +use fn_error_context::context; +use io_lifetimes::OwnedFd; +use serde::Deserialize; +use std::io::Read; +use std::path::Path; +use std::process::Stdio; +use std::str::FromStr; +use tokio::process::Command; + +// See `man containers-policy.json` and +// https://github.com/containers/image/blob/main/signature/policy_types.go +// Ideally we add something like `skopeo pull --disallow-insecure-accept-anything` +// but for now we parse the policy. +const POLICY_PATH: &str = "/etc/containers/policy.json"; +const INSECURE_ACCEPT_ANYTHING: &str = "insecureAcceptAnything"; + +#[derive(Deserialize)] +struct PolicyEntry { + #[serde(rename = "type")] + ty: String, +} +#[derive(Deserialize)] +struct ContainerPolicy { + default: Option>, +} + +impl ContainerPolicy { + fn is_default_insecure(&self) -> bool { + if let Some(default) = self.default.as_deref() { + match default.split_first() { + Some((v, &[])) => v.ty == INSECURE_ACCEPT_ANYTHING, + _ => false, + } + } else { + false + } + } +} + +pub(crate) fn container_policy_is_default_insecure() -> Result { + let r = std::io::BufReader::new(std::fs::File::open(POLICY_PATH)?); + let policy: ContainerPolicy = serde_json::from_reader(r)?; + Ok(policy.is_default_insecure()) +} + +/// Create a Command builder for skopeo. +pub(crate) fn new_cmd() -> std::process::Command { + let mut cmd = std::process::Command::new("skopeo"); + cmd.stdin(Stdio::null()); + cmd +} + +/// Spawn the child process +pub(crate) fn spawn(mut cmd: Command) -> Result { + let cmd = cmd.stdin(Stdio::null()).stderr(Stdio::piped()); + cmd.spawn().context("Failed to exec skopeo") +} + +/// Use skopeo to copy a container image. +#[context("Skopeo copy")] +pub(crate) async fn copy( + src: &ImageReference, + dest: &ImageReference, + authfile: Option<&Path>, + add_fd: Option<(std::sync::Arc, i32)>, + progress: bool, +) -> Result { + let digestfile = tempfile::NamedTempFile::new()?; + let mut cmd = new_cmd(); + cmd.arg("copy"); + if !progress { + cmd.stdout(std::process::Stdio::null()); + } + cmd.arg("--digestfile"); + cmd.arg(digestfile.path()); + if let Some((add_fd, n)) = add_fd { + cmd.take_fd_n(add_fd, n); + } + if let Some(authfile) = authfile { + cmd.arg("--authfile"); + cmd.arg(authfile); + } + cmd.args(&[src.to_string(), dest.to_string()]); + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + let proc = super::skopeo::spawn(cmd)?; + let output = proc.wait_with_output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("skopeo failed: {}\n", stderr)); + } + let mut digestfile = digestfile.into_file(); + let mut r = String::new(); + digestfile.read_to_string(&mut r)?; + Ok(oci_image::Digest::from_str(r.trim())?) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Default value as of the Fedora 34 containers-common-1-21.fc34.noarch package. + const DEFAULT_POLICY: &str = indoc::indoc! {r#" + { + "default": [ + { + "type": "insecureAcceptAnything" + } + ], + "transports": + { + "docker-daemon": + { + "": [{"type":"insecureAcceptAnything"}] + } + } + } + "#}; + + // Stripped down copy from the manual. + const REASONABLY_LOCKED_DOWN: &str = indoc::indoc! { r#" + { + "default": [{"type": "reject"}], + "transports": { + "dir": { + "": [{"type": "insecureAcceptAnything"}] + }, + "atomic": { + "hostname:5000/myns/official": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "/path/to/official-pubkey.gpg" + } + ] + } + } + } + "#}; + + #[test] + fn policy_is_insecure() { + let p: ContainerPolicy = serde_json::from_str(DEFAULT_POLICY).unwrap(); + assert!(p.is_default_insecure()); + for &v in &["{}", REASONABLY_LOCKED_DOWN] { + let p: ContainerPolicy = serde_json::from_str(v).unwrap(); + assert!(!p.is_default_insecure()); + } + } +} diff --git a/crates/ostree-ext/src/container/store.rs b/crates/ostree-ext/src/container/store.rs new file mode 100644 index 000000000..3e9991faa --- /dev/null +++ b/crates/ostree-ext/src/container/store.rs @@ -0,0 +1,2096 @@ +//! APIs for storing (layered) container images as OSTree commits +//! +//! # Extension of encapsulation support +//! +//! This code supports ingesting arbitrary layered container images from an ostree-exported +//! base. See [`encapsulate`][`super::encapsulate()`] for more information on encapsulation of images. + +use super::*; +use crate::chunking::{self, Chunk}; +use crate::generic_decompress::Decompressor; +use crate::logging::system_repo_journal_print; +use crate::refescape; +use crate::sysroot::SysrootLock; +use anyhow::{anyhow, Context}; +use bootc_utils::ResultExt; +use camino::{Utf8Path, Utf8PathBuf}; +use canon_json::CanonJsonSerialize; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::{Dir, MetadataExt}; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use cap_std_ext::dirext::CapStdExtDirExt; +use containers_image_proxy::{ImageProxy, OpenedImage}; +use flate2::Compression; +use fn_error_context::context; +use futures_util::TryFutureExt; +use glib::prelude::*; +use oci_spec::image::{ + self as oci_image, Arch, Descriptor, Digest, History, ImageConfiguration, ImageManifest, +}; +use ocidir::oci_spec::distribution::Reference; +use ostree::prelude::{Cast, FileEnumeratorExt, FileExt, ToVariant}; +use ostree::{gio, glib}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt::Write as _; +use std::iter::FromIterator; +use std::num::NonZeroUsize; +use tokio::sync::mpsc::{Receiver, Sender}; + +/// Configuration for the proxy. +/// +/// We re-export this rather than inventing our own wrapper +/// in the interest of avoiding duplication. +pub use containers_image_proxy::ImageProxyConfig; + +/// The ostree ref prefix for blobs. +const LAYER_PREFIX: &str = "ostree/container/blob"; +/// The ostree ref prefix for image references. +const IMAGE_PREFIX: &str = "ostree/container/image"; +/// The ostree ref prefix for "base" image references that are used by derived images. +/// If you maintain tooling which is locally building derived commits, write a ref +/// with this prefix that is owned by your code. It's a best practice to prefix the +/// ref with the project name, so the final ref may be of the form e.g. `ostree/container/baseimage/bootc/foo`. +pub const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage"; + +/// The key injected into the merge commit for the manifest digest. +pub(crate) const META_MANIFEST_DIGEST: &str = "ostree.manifest-digest"; +/// The key injected into the merge commit with the manifest serialized as JSON. +const META_MANIFEST: &str = "ostree.manifest"; +/// The key injected into the merge commit with the image configuration serialized as JSON. +const META_CONFIG: &str = "ostree.container.image-config"; +/// The type used to store content filtering information. +pub type MetaFilteredData = HashMap>; + +/// The ref prefixes which point to ostree deployments. (TODO: Add an official API for this) +const OSTREE_BASE_DEPLOYMENT_REFS: &[&str] = &["ostree/0", "ostree/1"]; +/// A layering violation we'll carry for a bit to band-aid over https://github.com/coreos/rpm-ostree/issues/4185 +const RPMOSTREE_BASE_REFS: &[&str] = &["rpmostree/base"]; + +/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`. +fn ref_for_blob_digest(d: &str) -> Result { + refescape::prefix_escape_for_ref(LAYER_PREFIX, d) +} + +/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`. +fn ref_for_layer(l: &oci_image::Descriptor) -> Result { + ref_for_blob_digest(&l.digest().as_ref()) +} + +/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`. +fn ref_for_image(l: &ImageReference) -> Result { + refescape::prefix_escape_for_ref(IMAGE_PREFIX, &l.to_string()) +} + +/// Sent across a channel to track start and end of a container fetch. +#[derive(Debug)] +pub enum ImportProgress { + /// Started fetching this layer. + OstreeChunkStarted(Descriptor), + /// Successfully completed the fetch of this layer. + OstreeChunkCompleted(Descriptor), + /// Started fetching this layer. + DerivedLayerStarted(Descriptor), + /// Successfully completed the fetch of this layer. + DerivedLayerCompleted(Descriptor), +} + +impl ImportProgress { + /// Returns `true` if this message signifies the start of a new layer being fetched. + pub fn is_starting(&self) -> bool { + match self { + ImportProgress::OstreeChunkStarted(_) => true, + ImportProgress::OstreeChunkCompleted(_) => false, + ImportProgress::DerivedLayerStarted(_) => true, + ImportProgress::DerivedLayerCompleted(_) => false, + } + } +} + +/// Sent across a channel to track the byte-level progress of a layer fetch. +#[derive(Clone, Debug)] +pub struct LayerProgress { + /// Index of the layer in the manifest + pub layer_index: usize, + /// Number of bytes downloaded + pub fetched: u64, + /// Total number of bytes outstanding + pub total: u64, +} + +/// State of an already pulled layered image. +#[derive(Debug, PartialEq, Eq)] +pub struct LayeredImageState { + /// The base ostree commit + pub base_commit: String, + /// The merge commit unions all layers + pub merge_commit: String, + /// The digest of the original manifest + pub manifest_digest: Digest, + /// The image manifest + pub manifest: ImageManifest, + /// The image configuration + pub configuration: ImageConfiguration, + /// Metadata for (cached, previously fetched) updates to the image, if any. + pub cached_update: Option, + /// The signature verification text from libostree for the base commit; + /// in the future we should probably instead just proxy a signature object + /// instead, but this is sufficient for now. + pub verify_text: Option, + /// Files that were filtered out during the import. + pub filtered_files: Option, +} + +impl LayeredImageState { + /// Return the merged ostree commit for this image. + /// + /// This is not the same as the underlying base ostree commit. + pub fn get_commit(&self) -> &str { + self.merge_commit.as_str() + } + + /// Retrieve the container image version. + pub fn version(&self) -> Option<&str> { + super::version_for_config(&self.configuration) + } +} + +/// Locally cached metadata for an update to an existing image. +#[derive(Debug, PartialEq, Eq)] +pub struct CachedImageUpdate { + /// The image manifest + pub manifest: ImageManifest, + /// The image configuration + pub config: ImageConfiguration, + /// The digest of the manifest + pub manifest_digest: Digest, +} + +impl CachedImageUpdate { + /// Retrieve the container image version. + pub fn version(&self) -> Option<&str> { + super::version_for_config(&self.config) + } +} + +/// Context for importing a container image. +#[derive(Debug)] +pub struct ImageImporter { + repo: ostree::Repo, + pub(crate) proxy: ImageProxy, + imgref: OstreeImageReference, + target_imgref: Option, + no_imgref: bool, // If true, do not write final image ref + disable_gc: bool, // If true, don't prune unused image layers + /// If true, require the image has the bootable flag + require_bootable: bool, + /// Do not attempt to contact the network + offline: bool, + /// If true, we have ostree v2024.3 or newer. + ostree_v2024_3: bool, + + layer_progress: Option>, + layer_byte_progress: Option>>, +} + +/// Result of invoking [`ImageImporter::prepare`]. +#[derive(Debug)] +pub enum PrepareResult { + /// The image reference is already present; the contained string is the OSTree commit. + AlreadyPresent(Box), + /// The image needs to be downloaded + Ready(Box), +} + +/// A container image layer with associated downloaded-or-not state. +#[derive(Debug)] +pub struct ManifestLayerState { + /// The underlying layer descriptor. + pub layer: oci_image::Descriptor, + // TODO semver: Make this readonly via an accessor + /// The ostree ref name for this layer. + pub ostree_ref: String, + // TODO semver: Make this readonly via an accessor + /// The ostree commit that caches this layer, if present. + pub commit: Option, +} + +impl ManifestLayerState { + /// Return the layer descriptor. + pub fn layer(&self) -> &oci_image::Descriptor { + &self.layer + } +} + +/// Information about which layers need to be downloaded. +#[derive(Debug)] +pub struct PreparedImport { + /// The manifest digest that was found + pub manifest_digest: Digest, + /// The deserialized manifest. + pub manifest: oci_image::ImageManifest, + /// The deserialized configuration. + pub config: oci_image::ImageConfiguration, + /// The previous manifest + pub previous_state: Option>, + /// The previously stored manifest digest. + pub previous_manifest_digest: Option, + /// The previously stored image ID. + pub previous_imageid: Option, + /// The layers containing split objects + pub ostree_layers: Vec, + /// The layer for the ostree commit. + pub ostree_commit_layer: Option, + /// Any further non-ostree (derived) layers. + pub layers: Vec, + /// OSTree remote signature verification text, if enabled. + pub verify_text: Option, + /// Our open image reference + proxy_img: OpenedImage, +} + +impl PreparedImport { + /// Iterate over all layers; the commit layer, the ostree split object layers, and any non-ostree layers. + pub fn all_layers(&self) -> impl Iterator { + self.ostree_commit_layer + .iter() + .chain(self.ostree_layers.iter()) + .chain(self.layers.iter()) + } + + /// Retrieve the container image version. + pub fn version(&self) -> Option<&str> { + super::version_for_config(&self.config) + } + + /// If this image is using any deprecated features, return a message saying so. + pub fn deprecated_warning(&self) -> Option<&'static str> { + None + } + + /// Iterate over all layers paired with their history entry. + /// An error will be returned if the history does not cover all entries. + pub fn layers_with_history( + &self, + ) -> impl Iterator> { + // FIXME use .filter(|h| h.empty_layer.unwrap_or_default()) after https://github.com/containers/oci-spec-rs/pull/100 lands. + let truncated = std::iter::once_with(|| Err(anyhow::anyhow!("Truncated history"))); + let history = self + .config + .history() + .iter() + .flatten() + .map(Ok) + .chain(truncated); + self.all_layers() + .zip(history) + .map(|(s, h)| h.map(|h| (s, h))) + } + + /// Iterate over all layers that are not present, along with their history description. + pub fn layers_to_fetch(&self) -> impl Iterator> { + self.layers_with_history().filter_map(|r| { + r.map(|(l, h)| { + l.commit.is_none().then(|| { + let comment = h.created_by().as_deref().unwrap_or(""); + (l, comment) + }) + }) + .transpose() + }) + } + + /// Common helper to format a string for the status + pub(crate) fn format_layer_status(&self) -> Option { + let (stored, to_fetch, to_fetch_size) = + self.all_layers() + .fold((0u32, 0u32, 0u64), |(stored, to_fetch, sz), v| { + if v.commit.is_some() { + (stored + 1, to_fetch, sz) + } else { + (stored, to_fetch + 1, sz + v.layer().size()) + } + }); + (to_fetch > 0).then(|| { + let size = crate::glib::format_size(to_fetch_size); + format!("layers already present: {stored}; layers needed: {to_fetch} ({size})") + }) + } +} + +// Given a manifest, compute its ostree ref name and cached ostree commit +pub(crate) fn query_layer( + repo: &ostree::Repo, + layer: oci_image::Descriptor, +) -> Result { + let ostree_ref = ref_for_layer(&layer)?; + let commit = repo.resolve_rev(&ostree_ref, true)?.map(|s| s.to_string()); + Ok(ManifestLayerState { + layer, + ostree_ref, + commit, + }) +} + +#[context("Reading manifest data from commit")] +fn manifest_data_from_commitmeta( + commit_meta: &glib::VariantDict, +) -> Result<(oci_image::ImageManifest, Digest)> { + let digest = commit_meta + .lookup::(META_MANIFEST_DIGEST)? + .ok_or_else(|| anyhow!("Missing {} metadata on merge commit", META_MANIFEST_DIGEST))?; + let digest = Digest::from_str(&digest)?; + let manifest_bytes: String = commit_meta + .lookup::(META_MANIFEST)? + .ok_or_else(|| anyhow!("Failed to find {} metadata key", META_MANIFEST))?; + let r = serde_json::from_str(&manifest_bytes)?; + Ok((r, digest)) +} + +fn image_config_from_commitmeta(commit_meta: &glib::VariantDict) -> Result { + let config = if let Some(config) = commit_meta + .lookup::(META_CONFIG)? + .filter(|v| v != "null") // Format v0 apparently old versions injected `null` here sadly... + .map(|v| serde_json::from_str(&v).map_err(anyhow::Error::msg)) + .transpose()? + { + config + } else { + tracing::debug!("No image configuration found"); + Default::default() + }; + Ok(config) +} + +/// Return the original digest of the manifest stored in the commit metadata. +/// This will be a string of the form e.g. `sha256:`. +/// +/// This can be used to uniquely identify the image. For example, it can be used +/// in a "digested pull spec" like `quay.io/someuser/exampleos@sha256:...`. +pub fn manifest_digest_from_commit(commit: &glib::Variant) -> Result { + let commit_meta = &commit.child_value(0); + let commit_meta = &glib::VariantDict::new(Some(commit_meta)); + Ok(manifest_data_from_commitmeta(commit_meta)?.1) +} + +/// Given a target diffid, return its corresponding layer. In our current model, +/// we require a 1-to-1 mapping between the two up until the ostree level. +/// For a bit more information on this, see https://github.com/opencontainers/image-spec/blob/main/config.md +fn layer_from_diffid<'a>( + manifest: &'a ImageManifest, + config: &ImageConfiguration, + diffid: &str, +) -> Result<&'a Descriptor> { + let idx = config + .rootfs() + .diff_ids() + .iter() + .position(|x| x.as_str() == diffid) + .ok_or_else(|| anyhow!("Missing {} {}", DIFFID_LABEL, diffid))?; + manifest.layers().get(idx).ok_or_else(|| { + anyhow!( + "diffid position {} exceeds layer count {}", + idx, + manifest.layers().len() + ) + }) +} + +#[context("Parsing manifest layout")] +pub(crate) fn parse_manifest_layout<'a>( + manifest: &'a ImageManifest, + config: &ImageConfiguration, +) -> Result<( + Option<&'a Descriptor>, + Vec<&'a Descriptor>, + Vec<&'a Descriptor>, +)> { + let config_labels = super::labels_of(config); + + let first_layer = manifest + .layers() + .first() + .ok_or_else(|| anyhow!("No layers in manifest"))?; + let Some(target_diffid) = config_labels.and_then(|labels| labels.get(DIFFID_LABEL)) else { + return Ok((None, Vec::new(), manifest.layers().iter().collect())); + }; + + let target_layer = layer_from_diffid(manifest, config, target_diffid.as_str())?; + let mut chunk_layers = Vec::new(); + let mut derived_layers = Vec::new(); + let mut after_target = false; + // Gather the ostree layer + let ostree_layer = first_layer; + for layer in manifest.layers() { + if layer == target_layer { + if after_target { + anyhow::bail!("Multiple entries for {}", layer.digest()); + } + after_target = true; + if layer != ostree_layer { + chunk_layers.push(layer); + } + } else if !after_target { + if layer != ostree_layer { + chunk_layers.push(layer); + } + } else { + derived_layers.push(layer); + } + } + + Ok((Some(ostree_layer), chunk_layers, derived_layers)) +} + +/// Like [`parse_manifest_layout`] but requires the image has an ostree base. +#[context("Parsing manifest layout")] +pub(crate) fn parse_ostree_manifest_layout<'a>( + manifest: &'a ImageManifest, + config: &ImageConfiguration, +) -> Result<(&'a Descriptor, Vec<&'a Descriptor>, Vec<&'a Descriptor>)> { + let (ostree_layer, component_layers, derived_layers) = parse_manifest_layout(manifest, config)?; + let ostree_layer = ostree_layer.ok_or_else(|| { + anyhow!("No {DIFFID_LABEL} label found, not an ostree encapsulated container") + })?; + Ok((ostree_layer, component_layers, derived_layers)) +} + +/// Find the timestamp of the manifest (or config), ignoring errors. +fn timestamp_of_manifest_or_config( + manifest: &ImageManifest, + config: &ImageConfiguration, +) -> Option { + // The manifest timestamp seems to not be widely used, but let's + // try it in preference to the config one. + let timestamp = manifest + .annotations() + .as_ref() + .and_then(|a| a.get(oci_image::ANNOTATION_CREATED)) + .or_else(|| config.created().as_ref()); + // Try to parse the timestamp + timestamp + .map(|t| { + chrono::DateTime::parse_from_rfc3339(t) + .context("Failed to parse manifest timestamp") + .map(|t| t.timestamp() as u64) + }) + .transpose() + .log_err_default() +} + +/// Automatically clean up files that may have been injected by container +/// builds. xref https://github.com/containers/buildah/issues/4242 +fn cleanup_root(root: &Dir) -> Result<()> { + const RUNTIME_INJECTED: &[&str] = &["usr/etc/hostname", "usr/etc/resolv.conf"]; + for ent in RUNTIME_INJECTED { + if let Some(meta) = root.symlink_metadata_optional(ent)? { + if meta.is_file() && meta.size() == 0 { + tracing::debug!("Removing {ent}"); + root.remove_file(ent)?; + } + } + } + Ok(()) +} + +impl ImageImporter { + /// The metadata key used in ostree commit metadata to serialize + const CACHED_KEY_MANIFEST_DIGEST: &'static str = "ostree-ext.cached.manifest-digest"; + const CACHED_KEY_MANIFEST: &'static str = "ostree-ext.cached.manifest"; + const CACHED_KEY_CONFIG: &'static str = "ostree-ext.cached.config"; + + /// Create a new importer. + #[context("Creating importer")] + pub async fn new( + repo: &ostree::Repo, + imgref: &OstreeImageReference, + mut config: ImageProxyConfig, + ) -> Result { + if imgref.imgref.transport == Transport::ContainerStorage { + // Fetching from containers-storage, may require privileges to read files + merge_default_container_proxy_opts_with_isolation(&mut config, None)?; + } else { + // Apply our defaults to the proxy config + merge_default_container_proxy_opts(&mut config)?; + } + let proxy = ImageProxy::new_with_config(config).await?; + + system_repo_journal_print( + repo, + libsystemd::logging::Priority::Info, + &format!("Fetching {imgref}"), + ); + + let repo = repo.clone(); + Ok(ImageImporter { + repo, + proxy, + target_imgref: None, + no_imgref: false, + ostree_v2024_3: ostree::check_version(2024, 3), + disable_gc: false, + require_bootable: false, + offline: false, + imgref: imgref.clone(), + layer_progress: None, + layer_byte_progress: None, + }) + } + + /// Write cached data as if the image came from this source. + pub fn set_target(&mut self, target: &OstreeImageReference) { + self.target_imgref = Some(target.clone()) + } + + /// Do not write the final image ref, but do write refs for shared layers. + /// This is useful in scenarios where you want to "pre-pull" an image, + /// but in such a way that it does not need to be manually removed later. + pub fn set_no_imgref(&mut self) { + self.no_imgref = true; + } + + /// Do not attempt to contact the network + pub fn set_offline(&mut self) { + self.offline = true; + } + + /// Require that the image has the bootable metadata field + pub fn require_bootable(&mut self) { + self.require_bootable = true; + } + + /// Override the ostree version being targeted + pub fn set_ostree_version(&mut self, year: u32, v: u32) { + self.ostree_v2024_3 = (year > 2024) || (year == 2024 && v >= 3) + } + + /// Do not prune image layers. + pub fn disable_gc(&mut self) { + self.disable_gc = true; + } + + /// Determine if there is a new manifest, and if so return its digest. + /// This will also serialize the new manifest and configuration into + /// metadata associated with the image, so that invocations of `[query_cached]` + /// can re-fetch it without accessing the network. + #[context("Preparing import")] + pub async fn prepare(&mut self) -> Result { + self.prepare_internal(false).await + } + + /// Create a channel receiver that will get notifications for layer fetches. + pub fn request_progress(&mut self) -> Receiver { + assert!(self.layer_progress.is_none()); + let (s, r) = tokio::sync::mpsc::channel(2); + self.layer_progress = Some(s); + r + } + + /// Create a channel receiver that will get notifications for byte-level progress of layer fetches. + pub fn request_layer_progress( + &mut self, + ) -> tokio::sync::watch::Receiver> { + assert!(self.layer_byte_progress.is_none()); + let (s, r) = tokio::sync::watch::channel(None); + self.layer_byte_progress = Some(s); + r + } + + /// Serialize the metadata about a pending fetch as detached metadata on the commit object, + /// so it can be retrieved later offline + #[context("Writing cached pending manifest")] + pub(crate) async fn cache_pending( + &self, + commit: &str, + manifest_digest: &Digest, + manifest: &ImageManifest, + config: &ImageConfiguration, + ) -> Result<()> { + let commitmeta = glib::VariantDict::new(None); + commitmeta.insert( + Self::CACHED_KEY_MANIFEST_DIGEST, + manifest_digest.to_string(), + ); + let cached_manifest = manifest + .to_canon_json_string() + .context("Serializing manifest")?; + commitmeta.insert(Self::CACHED_KEY_MANIFEST, cached_manifest); + let cached_config = config + .to_canon_json_string() + .context("Serializing config")?; + commitmeta.insert(Self::CACHED_KEY_CONFIG, cached_config); + let commitmeta = commitmeta.to_variant(); + // Clone these to move into blocking method + let commit = commit.to_string(); + let repo = self.repo.clone(); + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + repo.write_commit_detached_metadata(&commit, Some(&commitmeta), Some(cancellable)) + .map_err(anyhow::Error::msg) + }) + .await + } + + /// Given existing metadata (manifest, config, previous image statE) generate a PreparedImport structure + /// which e.g. includes a diff of the layers. + fn create_prepared_import( + &mut self, + manifest_digest: Digest, + manifest: ImageManifest, + config: ImageConfiguration, + previous_state: Option>, + previous_imageid: Option, + proxy_img: OpenedImage, + ) -> Result> { + let config_labels = super::labels_of(&config); + if self.require_bootable { + let bootable_key = ostree::METADATA_KEY_BOOTABLE; + let bootable = config_labels.is_some_and(|l| { + l.contains_key(bootable_key.as_str()) || l.contains_key(BOOTC_LABEL) + }); + if !bootable { + anyhow::bail!("Target image does not have {bootable_key} label"); + } + let container_arch = config.architecture(); + let target_arch = &Arch::default(); + if container_arch != target_arch { + anyhow::bail!("Image has architecture {container_arch}; expected {target_arch}"); + } + } + + let (commit_layer, component_layers, remaining_layers) = + parse_manifest_layout(&manifest, &config)?; + + let query = |l: &Descriptor| query_layer(&self.repo, l.clone()); + let commit_layer = commit_layer.map(query).transpose()?; + let component_layers = component_layers + .into_iter() + .map(query) + .collect::>>()?; + let remaining_layers = remaining_layers + .into_iter() + .map(query) + .collect::>>()?; + + let previous_manifest_digest = previous_state.as_ref().map(|s| s.manifest_digest.clone()); + let imp = PreparedImport { + manifest_digest, + manifest, + config, + previous_state, + previous_manifest_digest, + previous_imageid, + ostree_layers: component_layers, + ostree_commit_layer: commit_layer, + layers: remaining_layers, + verify_text: None, + proxy_img, + }; + Ok(Box::new(imp)) + } + + /// Determine if there is a new manifest, and if so return its digest. + #[context("Fetching manifest")] + pub(crate) async fn prepare_internal(&mut self, verify_layers: bool) -> Result { + match &self.imgref.sigverify { + SignatureSource::ContainerPolicy if skopeo::container_policy_is_default_insecure()? => { + return Err(anyhow!("containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage")); + } + SignatureSource::OstreeRemote(_) if verify_layers => { + return Err(anyhow!( + "Cannot currently verify layered containers via ostree remote" + )); + } + _ => {} + } + + // Check if we have an image already pulled + let previous_state = try_query_image(&self.repo, &self.imgref.imgref)?; + + // Parse the target reference to see if it's a digested pull + let target_reference = self.imgref.imgref.name.parse::().ok(); + let previous_state = if let Some(target_digest) = target_reference + .as_ref() + .and_then(|v| v.digest()) + .map(Digest::from_str) + .transpose()? + { + if let Some(previous_state) = previous_state { + // A digested pull spec, and our existing state matches. + if previous_state.manifest_digest == target_digest { + tracing::debug!("Digest-based pullspec {:?} already present", self.imgref); + return Ok(PrepareResult::AlreadyPresent(previous_state)); + } + Some(previous_state) + } else { + None + } + } else { + previous_state + }; + + if self.offline { + anyhow::bail!("Manifest fetch required in offline mode"); + } + + let proxy_img = self + .proxy + .open_image(&self.imgref.imgref.to_string()) + .await?; + + let (manifest_digest, manifest) = self.proxy.fetch_manifest(&proxy_img).await?; + let manifest_digest = Digest::from_str(&manifest_digest)?; + let new_imageid = manifest.config().digest(); + + // Query for previous stored state + + let (previous_state, previous_imageid) = if let Some(previous_state) = previous_state { + // If the manifest digests match, we're done. + if previous_state.manifest_digest == manifest_digest { + return Ok(PrepareResult::AlreadyPresent(previous_state)); + } + // Failing that, if they have the same imageID, we're also done. + let previous_imageid = previous_state.manifest.config().digest(); + if previous_imageid == new_imageid { + return Ok(PrepareResult::AlreadyPresent(previous_state)); + } + let previous_imageid = previous_imageid.to_string(); + (Some(previous_state), Some(previous_imageid)) + } else { + (None, None) + }; + + let config = self.proxy.fetch_config(&proxy_img).await?; + + // If there is a currently fetched image, cache the new pending manifest+config + // as detached commit metadata, so that future fetches can query it offline. + if let Some(previous_state) = previous_state.as_ref() { + self.cache_pending( + previous_state.merge_commit.as_str(), + &manifest_digest, + &manifest, + &config, + ) + .await?; + } + + let imp = self.create_prepared_import( + manifest_digest, + manifest, + config, + previous_state, + previous_imageid, + proxy_img, + )?; + Ok(PrepareResult::Ready(imp)) + } + + /// Extract the base ostree commit. + #[context("Unencapsulating base")] + pub(crate) async fn unencapsulate_base( + &self, + import: &mut store::PreparedImport, + require_ostree: bool, + write_refs: bool, + ) -> Result<()> { + tracing::debug!("Fetching base"); + if matches!(self.imgref.sigverify, SignatureSource::ContainerPolicy) + && skopeo::container_policy_is_default_insecure()? + { + return Err(anyhow!("containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage")); + } + let remote = match &self.imgref.sigverify { + SignatureSource::OstreeRemote(remote) => Some(remote.clone()), + SignatureSource::ContainerPolicy | SignatureSource::ContainerPolicyAllowInsecure => { + None + } + }; + let Some(commit_layer) = import.ostree_commit_layer.as_mut() else { + if require_ostree { + anyhow::bail!( + "No {DIFFID_LABEL} label found, not an ostree encapsulated container" + ); + } + return Ok(()); + }; + let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?; + for layer in import.ostree_layers.iter_mut() { + if layer.commit.is_some() { + continue; + } + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::OstreeChunkStarted(layer.layer.clone())) + .await?; + } + let (blob, driver, media_type) = fetch_layer( + &self.proxy, + &import.proxy_img, + &import.manifest, + &layer.layer, + self.layer_byte_progress.as_ref(), + des_layers.as_ref(), + self.imgref.imgref.transport, + ) + .await?; + let repo = self.repo.clone(); + let target_ref = layer.ostree_ref.clone(); + let import_task = + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let txn = repo.auto_transaction(Some(cancellable))?; + let mut importer = crate::tar::Importer::new_for_object_set(&repo); + let blob = tokio_util::io::SyncIoBridge::new(blob); + let mut blob = Decompressor::new(&media_type, blob)?; + let mut archive = tar::Archive::new(&mut blob); + importer.import_objects(&mut archive, Some(cancellable))?; + let commit = if write_refs { + let commit = importer.finish_import_object_set()?; + repo.transaction_set_ref(None, &target_ref, Some(commit.as_str())); + tracing::debug!("Wrote {} => {}", target_ref, commit); + Some(commit) + } else { + None + }; + txn.commit(Some(cancellable))?; + blob.finish()?; + Ok::<_, anyhow::Error>(commit) + }) + .map_err(|e| e.context(format!("Layer {}", layer.layer.digest()))); + let commit = super::unencapsulate::join_fetch(import_task, driver).await?; + layer.commit = commit; + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::OstreeChunkCompleted(layer.layer.clone())) + .await?; + } + } + if commit_layer.commit.is_none() { + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::OstreeChunkStarted( + commit_layer.layer.clone(), + )) + .await?; + } + let (blob, driver, media_type) = fetch_layer( + &self.proxy, + &import.proxy_img, + &import.manifest, + &commit_layer.layer, + self.layer_byte_progress.as_ref(), + des_layers.as_ref(), + self.imgref.imgref.transport, + ) + .await?; + let repo = self.repo.clone(); + let target_ref = commit_layer.ostree_ref.clone(); + let import_task = + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let txn = repo.auto_transaction(Some(cancellable))?; + let mut importer = crate::tar::Importer::new_for_commit(&repo, remote); + let blob = tokio_util::io::SyncIoBridge::new(blob); + let mut blob = Decompressor::new(&media_type, blob)?; + let mut archive = tar::Archive::new(&mut blob); + importer.import_commit(&mut archive, Some(cancellable))?; + let (commit, verify_text) = importer.finish_import_commit(); + if write_refs { + repo.transaction_set_ref(None, &target_ref, Some(commit.as_str())); + tracing::debug!("Wrote {} => {}", target_ref, commit); + } + repo.mark_commit_partial(&commit, false)?; + txn.commit(Some(cancellable))?; + blob.finish()?; + Ok::<_, anyhow::Error>((commit, verify_text)) + }); + let (commit, verify_text) = + super::unencapsulate::join_fetch(import_task, driver).await?; + commit_layer.commit = Some(commit); + import.verify_text = verify_text; + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::OstreeChunkCompleted( + commit_layer.layer.clone(), + )) + .await?; + } + }; + Ok(()) + } + + /// Retrieve an inner ostree commit. + /// + /// This does not write cached references for each blob, and errors out if + /// the image has any non-ostree layers. + pub async fn unencapsulate(mut self) -> Result { + let mut prep = match self.prepare_internal(false).await? { + PrepareResult::AlreadyPresent(_) => { + panic!("Should not have image present for unencapsulation") + } + PrepareResult::Ready(r) => r, + }; + if !prep.layers.is_empty() { + anyhow::bail!("Image has {} non-ostree layers", prep.layers.len()); + } + let deprecated_warning = prep.deprecated_warning().map(ToOwned::to_owned); + self.unencapsulate_base(&mut prep, true, false).await?; + // TODO change the imageproxy API to ensure this happens automatically when + // the image reference is dropped + self.proxy.close_image(&prep.proxy_img).await?; + // SAFETY: We know we have a commit + let ostree_commit = prep.ostree_commit_layer.unwrap().commit.unwrap(); + let image_digest = prep.manifest_digest; + Ok(Import { + ostree_commit, + image_digest, + deprecated_warning, + }) + } + + /// Generate a single ostree commit that combines all layers, and also + /// includes container image metadata such as the manifest and config. + fn write_merge_commit_impl( + repo: &ostree::Repo, + base_commit: Option<&str>, + layer_commits: &[String], + have_derived_layers: bool, + metadata: glib::Variant, + timestamp: u64, + ostree_ref: &str, + no_imgref: bool, + disable_gc: bool, + cancellable: Option<&gio::Cancellable>, + ) -> Result> { + use rustix::fd::AsRawFd; + + let txn = repo.auto_transaction(cancellable)?; + + let devino = ostree::RepoDevInoCache::new(); + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + let repo_tmp = repodir.open_dir("tmp")?; + let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?; + + let rootpath = "root"; + let checkout_mode = if repo.mode() == ostree::RepoMode::Bare { + ostree::RepoCheckoutMode::None + } else { + ostree::RepoCheckoutMode::User + }; + let mut checkout_opts = ostree::RepoCheckoutAtOptions { + mode: checkout_mode, + overwrite_mode: ostree::RepoCheckoutOverwriteMode::UnionFiles, + devino_to_csum_cache: Some(devino.clone()), + no_copy_fallback: true, + force_copy_zerosized: true, + process_whiteouts: false, + ..Default::default() + }; + if let Some(base) = base_commit.as_ref() { + repo.checkout_at( + Some(&checkout_opts), + (*td).as_raw_fd(), + rootpath, + &base, + cancellable, + ) + .context("Checking out base commit")?; + } + + // Layer all subsequent commits + checkout_opts.process_whiteouts = true; + for commit in layer_commits { + tracing::debug!("Unpacking {commit}"); + repo.checkout_at( + Some(&checkout_opts), + (*td).as_raw_fd(), + rootpath, + &commit, + cancellable, + ) + .with_context(|| format!("Checking out layer {commit}"))?; + } + + let root_dir = td.open_dir(rootpath)?; + + let modifier = + ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::empty(), None); + modifier.set_devino_cache(&devino); + // If we have derived layers, then we need to handle the case where + // the derived layers include custom policy. Just relabel everything + // in this case. + if have_derived_layers { + let sepolicy = ostree::SePolicy::new_at(root_dir.as_raw_fd(), cancellable)?; + tracing::debug!("labeling from merged tree"); + modifier.set_sepolicy(Some(&sepolicy)); + } else if let Some(base) = base_commit.as_ref() { + tracing::debug!("labeling from base tree"); + // TODO: We can likely drop this; we know all labels should be pre-computed. + modifier.set_sepolicy_from_commit(repo, &base, cancellable)?; + } else { + panic!("Unexpected state: no derived layers and no base") + } + + cleanup_root(&root_dir)?; + + let mt = ostree::MutableTree::new(); + repo.write_dfd_to_mtree( + (*td).as_raw_fd(), + rootpath, + &mt, + Some(&modifier), + cancellable, + ) + .context("Writing merged filesystem to mtree")?; + + let merged_root = repo + .write_mtree(&mt, cancellable) + .context("Writing mtree")?; + let merged_root = merged_root.downcast::().unwrap(); + // The merge has the base commit as a parent, if it exists. See + // https://github.com/ostreedev/ostree/pull/3523 + let parent = base_commit.as_deref(); + let merged_commit = repo + .write_commit_with_time( + parent, + None, + None, + Some(&metadata), + &merged_root, + timestamp, + cancellable, + ) + .context("Writing commit")?; + if !no_imgref { + repo.transaction_set_ref(None, ostree_ref, Some(merged_commit.as_str())); + } + txn.commit(cancellable)?; + + if !disable_gc { + let n: u32 = gc_image_layers_impl(repo, cancellable)?; + tracing::debug!("pruned {n} layers"); + } + + // Here we re-query state just to run through the same code path, + // though it'd be cheaper to synthesize it from the data we already have. + let state = query_image_commit(repo, &merged_commit)?; + Ok(state) + } + + /// Import a layered container image. + /// + /// If enabled, this will also prune unused container image layers. + #[context("Importing")] + pub async fn import( + mut self, + mut import: Box, + ) -> Result> { + if let Some(status) = import.format_layer_status() { + system_repo_journal_print(&self.repo, libsystemd::logging::Priority::Info, &status); + } + // First download all layers for the base image (if necessary) - we need the SELinux policy + // there to label all following layers. + self.unencapsulate_base(&mut import, false, true).await?; + let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?; + let proxy = self.proxy; + let target_imgref = self.target_imgref.as_ref().unwrap_or(&self.imgref); + let base_commit = import + .ostree_commit_layer + .as_ref() + .map(|c| c.commit.clone().unwrap()); + + let root_is_transient = if let Some(base) = base_commit.as_ref() { + let rootf = self.repo.read_commit(&base, gio::Cancellable::NONE)?.0; + let rootf = rootf.downcast_ref::().unwrap(); + crate::ostree_prepareroot::overlayfs_root_enabled(rootf)? + } else { + // For generic images we assume they're using composefs + true + }; + tracing::debug!("Base rootfs is transient: {root_is_transient}"); + + let ostree_ref = ref_for_image(&target_imgref.imgref)?; + + let mut layer_commits = Vec::new(); + let mut layer_filtered_content: Option = None; + let have_derived_layers = !import.layers.is_empty(); + tracing::debug!("Processing layers: {}", import.layers.len()); + for layer in import.layers { + if let Some(c) = layer.commit { + tracing::debug!("Reusing fetched commit {}", c); + layer_commits.push(c.to_string()); + } else { + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::DerivedLayerStarted(layer.layer.clone())) + .await?; + } + let (blob, driver, media_type) = super::unencapsulate::fetch_layer( + &proxy, + &import.proxy_img, + &import.manifest, + &layer.layer, + self.layer_byte_progress.as_ref(), + des_layers.as_ref(), + self.imgref.imgref.transport, + ) + .await?; + // An important aspect of this is that we SELinux label the derived layers using + // the base policy. + let opts = crate::tar::WriteTarOptions { + base: base_commit.clone(), + selinux: true, + allow_nonusr: root_is_transient, + retain_var: self.ostree_v2024_3, + }; + let r = crate::tar::write_tar( + &self.repo, + blob, + media_type, + layer.ostree_ref.as_str(), + Some(opts), + ); + let r = super::unencapsulate::join_fetch(r, driver) + .await + .with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?; + tracing::debug!("Imported layer: {}", r.commit.as_str()); + layer_commits.push(r.commit); + let filtered_owned = HashMap::from_iter(r.filtered.clone()); + if let Some((filtered, n_rest)) = bootc_utils::collect_until( + r.filtered.iter(), + const { NonZeroUsize::new(5).unwrap() }, + ) { + let mut msg = String::new(); + for (path, n) in filtered { + write!(msg, "{path}: {n} ").unwrap(); + } + if n_rest > 0 { + write!(msg, "...and {n_rest} more").unwrap(); + } + tracing::debug!("Found filtered toplevels: {msg}"); + layer_filtered_content + .get_or_insert_default() + .insert(layer.layer.digest().to_string(), filtered_owned); + } else { + tracing::debug!("No filtered content"); + } + if let Some(p) = self.layer_progress.as_ref() { + p.send(ImportProgress::DerivedLayerCompleted(layer.layer.clone())) + .await?; + } + } + } + + // TODO change the imageproxy API to ensure this happens automatically when + // the image reference is dropped + proxy.close_image(&import.proxy_img).await?; + + // We're done with the proxy, make sure it didn't have any errors. + proxy.finalize().await?; + tracing::debug!("finalized proxy"); + + // Disconnect progress notifiers to signal we're done with fetching. + let _ = self.layer_byte_progress.take(); + let _ = self.layer_progress.take(); + + let mut metadata = BTreeMap::new(); + metadata.insert( + META_MANIFEST_DIGEST, + import.manifest_digest.to_string().to_variant(), + ); + metadata.insert( + META_MANIFEST, + import.manifest.to_canon_json_string()?.to_variant(), + ); + metadata.insert( + META_CONFIG, + import.config.to_canon_json_string()?.to_variant(), + ); + metadata.insert( + "ostree.importer.version", + env!("CARGO_PKG_VERSION").to_variant(), + ); + let metadata = metadata.to_variant(); + + let timestamp = timestamp_of_manifest_or_config(&import.manifest, &import.config) + .unwrap_or_else(|| chrono::offset::Utc::now().timestamp() as u64); + // Destructure to transfer ownership to thread + let repo = self.repo; + let mut state = crate::tokio_util::spawn_blocking_cancellable_flatten( + move |cancellable| -> Result> { + Self::write_merge_commit_impl( + &repo, + base_commit.as_deref(), + &layer_commits, + have_derived_layers, + metadata, + timestamp, + &ostree_ref, + self.no_imgref, + self.disable_gc, + Some(cancellable), + ) + }, + ) + .await?; + // We can at least avoid re-verifying the base commit. + state.verify_text = import.verify_text; + state.filtered_files = layer_filtered_content; + Ok(state) + } +} + +/// List all images stored +pub fn list_images(repo: &ostree::Repo) -> Result> { + let cancellable = gio::Cancellable::NONE; + let refs = repo.list_refs_ext( + Some(IMAGE_PREFIX), + ostree::RepoListRefsExtFlags::empty(), + cancellable, + )?; + refs.keys() + .map(|imgname| refescape::unprefix_unescape_ref(IMAGE_PREFIX, imgname)) + .collect() +} + +/// Attempt to query metadata for a pulled image; if it is corrupted, +/// the error is printed to stderr and None is returned. +fn try_query_image( + repo: &ostree::Repo, + imgref: &ImageReference, +) -> Result>> { + let ostree_ref = &ref_for_image(imgref)?; + if let Some(merge_rev) = repo.resolve_rev(ostree_ref, true)? { + match query_image_commit(repo, merge_rev.as_str()) { + Ok(r) => Ok(Some(r)), + Err(e) => { + eprintln!("error: failed to query image commit: {e}"); + Ok(None) + } + } + } else { + Ok(None) + } +} + +/// Query metadata for a pulled image. +#[context("Querying image {imgref}")] +pub fn query_image( + repo: &ostree::Repo, + imgref: &ImageReference, +) -> Result>> { + let ostree_ref = &ref_for_image(imgref)?; + let merge_rev = repo.resolve_rev(ostree_ref, true)?; + merge_rev + .map(|r| query_image_commit(repo, r.as_str())) + .transpose() +} + +/// Given detached commit metadata, parse the data that we serialized for a pending update (if any). +fn parse_cached_update(meta: &glib::VariantDict) -> Result> { + // Try to retrieve the manifest digest key from the commit detached metadata. + let manifest_digest = + if let Some(d) = meta.lookup::(ImageImporter::CACHED_KEY_MANIFEST_DIGEST)? { + d + } else { + // It's possible that something *else* wrote detached metadata, but without + // our key; gracefully handle that. + return Ok(None); + }; + let manifest_digest = Digest::from_str(&manifest_digest)?; + // If we found the cached manifest digest key, then we must have the manifest and config; + // otherwise that's an error. + let manifest = meta.lookup_value(ImageImporter::CACHED_KEY_MANIFEST, None); + let manifest: oci_image::ImageManifest = manifest + .as_ref() + .and_then(|v| v.str()) + .map(serde_json::from_str) + .transpose()? + .ok_or_else(|| { + anyhow!( + "Expected cached manifest {}", + ImageImporter::CACHED_KEY_MANIFEST + ) + })?; + let config = meta.lookup_value(ImageImporter::CACHED_KEY_CONFIG, None); + let config: oci_image::ImageConfiguration = config + .as_ref() + .and_then(|v| v.str()) + .map(serde_json::from_str) + .transpose()? + .ok_or_else(|| { + anyhow!( + "Expected cached manifest {}", + ImageImporter::CACHED_KEY_CONFIG + ) + })?; + Ok(Some(CachedImageUpdate { + manifest, + config, + manifest_digest, + })) +} + +/// Remove any cached +#[context("Clearing cached update {imgref}")] +pub fn clear_cached_update(repo: &ostree::Repo, imgref: &ImageReference) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let ostree_ref = ref_for_image(imgref)?; + let rev = repo.require_rev(&ostree_ref)?; + let Some(commitmeta) = repo.read_commit_detached_metadata(&rev, cancellable)? else { + return Ok(()); + }; + + // SAFETY: We know this is an a{sv} + let mut commitmeta: BTreeMap = + BTreeMap::from_variant(&commitmeta).unwrap(); + let mut changed = false; + for key in [ + ImageImporter::CACHED_KEY_CONFIG, + ImageImporter::CACHED_KEY_MANIFEST, + ImageImporter::CACHED_KEY_MANIFEST_DIGEST, + ] { + if commitmeta.remove(key).is_some() { + changed = true; + } + } + if !changed { + return Ok(()); + } + let commitmeta = glib::Variant::from(commitmeta); + repo.write_commit_detached_metadata(&rev, Some(&commitmeta), cancellable)?; + Ok(()) +} + +/// Query metadata for a pulled image via an OSTree commit digest. +/// The digest must refer to a pulled container image's merge commit. +pub fn query_image_commit(repo: &ostree::Repo, commit: &str) -> Result> { + let merge_commit = commit.to_string(); + let merge_commit_obj = repo.load_commit(commit)?.0; + let commit_meta = &merge_commit_obj.child_value(0); + let commit_meta = &ostree::glib::VariantDict::new(Some(commit_meta)); + let (manifest, manifest_digest) = manifest_data_from_commitmeta(commit_meta)?; + let configuration = image_config_from_commitmeta(commit_meta)?; + let mut layers = manifest.layers().iter().cloned(); + // We require a base layer. + let base_layer = layers.next().ok_or_else(|| anyhow!("No layers found"))?; + let base_layer = query_layer(repo, base_layer)?; + let ostree_ref = base_layer.ostree_ref.as_str(); + let base_commit = base_layer + .commit + .ok_or_else(|| anyhow!("Missing base image ref {ostree_ref}"))?; + + let detached_commitmeta = + repo.read_commit_detached_metadata(&merge_commit, gio::Cancellable::NONE)?; + let detached_commitmeta = detached_commitmeta + .as_ref() + .map(|v| glib::VariantDict::new(Some(v))); + let cached_update = detached_commitmeta + .as_ref() + .map(parse_cached_update) + .transpose()? + .flatten(); + let state = Box::new(LayeredImageState { + base_commit, + merge_commit, + manifest_digest, + manifest, + configuration, + cached_update, + // we can't cross-reference with a remote here + verify_text: None, + filtered_files: None, + }); + tracing::debug!("Wrote merge commit {}", state.merge_commit); + Ok(state) +} + +fn manifest_for_image(repo: &ostree::Repo, imgref: &ImageReference) -> Result { + let ostree_ref = ref_for_image(imgref)?; + let rev = repo.require_rev(&ostree_ref)?; + let (commit_obj, _) = repo.load_commit(rev.as_str())?; + let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0))); + Ok(manifest_data_from_commitmeta(commit_meta)?.0) +} + +/// Copy a downloaded image from one repository to another, while also +/// optionally changing the image reference type. +#[context("Copying image")] +pub async fn copy( + src_repo: &ostree::Repo, + src_imgref: &ImageReference, + dest_repo: &ostree::Repo, + dest_imgref: &ImageReference, +) -> Result<()> { + let src_ostree_ref = ref_for_image(src_imgref)?; + let src_commit = src_repo.require_rev(&src_ostree_ref)?; + let manifest = manifest_for_image(src_repo, src_imgref)?; + // Create a task to copy each layer, plus the final ref + let layer_refs = manifest + .layers() + .iter() + .map(ref_for_layer) + .chain(std::iter::once(Ok(src_commit.to_string()))); + for ostree_ref in layer_refs { + let ostree_ref = ostree_ref?; + let src_repo = src_repo.clone(); + let dest_repo = dest_repo.clone(); + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> { + let cancellable = Some(cancellable); + let srcfd = &format!("file:///proc/self/fd/{}", src_repo.dfd()); + let flags = ostree::RepoPullFlags::MIRROR; + let opts = glib::VariantDict::new(None); + let refs = [ostree_ref.as_str()]; + // Some older archives may have bindings, we don't need to verify them. + opts.insert("disable-verify-bindings", true); + opts.insert("refs", &refs[..]); + opts.insert("flags", flags.bits() as i32); + let options = opts.to_variant(); + dest_repo.pull_with_options(srcfd, &options, None, cancellable)?; + Ok(()) + }) + .await?; + } + + let dest_ostree_ref = ref_for_image(dest_imgref)?; + dest_repo.set_ref_immediate( + None, + &dest_ostree_ref, + Some(&src_commit), + gio::Cancellable::NONE, + )?; + + Ok(()) +} + +/// Options controlling commit export into OCI +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct ExportToOCIOpts { + /// If true, do not perform gzip compression of the tar layers. + pub skip_compression: bool, + /// Path to Docker-formatted authentication file. + pub authfile: Option, + /// Output progress to stdout + pub progress_to_stdout: bool, +} + +/// The way we store "chunk" layers in ostree is by writing a commit +/// whose filenames are their own object identifier. This function parses +/// what is written by the `ImporterMode::ObjectSet` logic, turning +/// it back into a "chunked" structure that is used by the export code. +fn chunking_from_layer_committed( + repo: &ostree::Repo, + l: &Descriptor, + chunking: &mut chunking::Chunking, +) -> Result<()> { + let mut chunk = Chunk::default(); + let layer_ref = &ref_for_layer(l)?; + let root = repo.read_commit(layer_ref, gio::Cancellable::NONE)?.0; + let e = root.enumerate_children( + "standard::name,standard::size", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + gio::Cancellable::NONE, + )?; + for child in e.clone() { + let child = &child?; + // The name here should be a valid checksum + let name = child.name(); + // SAFETY: ostree doesn't give us non-UTF8 filenames + let name = Utf8Path::from_path(&name).unwrap(); + ostree::validate_checksum_string(name.as_str())?; + chunking.remainder.move_obj(&mut chunk, name.as_str()); + } + chunking.chunks.push(chunk); + Ok(()) +} + +/// Export an imported container image to a target OCI directory. +#[context("Copying image")] +pub(crate) fn export_to_oci( + repo: &ostree::Repo, + imgref: &ImageReference, + dest_oci: &Dir, + tag: Option<&str>, + opts: ExportToOCIOpts, +) -> Result { + let srcinfo = query_image(repo, imgref)?.ok_or_else(|| anyhow!("No such image"))?; + let (commit_layer, component_layers, remaining_layers) = + parse_manifest_layout(&srcinfo.manifest, &srcinfo.configuration)?; + let commit_layer = commit_layer.ok_or_else(|| anyhow!("Missing {DIFFID_LABEL}"))?; + let commit_chunk_ref = ref_for_layer(commit_layer)?; + let commit_chunk_rev = repo.require_rev(&commit_chunk_ref)?; + let mut chunking = chunking::Chunking::new(repo, &commit_chunk_rev)?; + for layer in component_layers { + chunking_from_layer_committed(repo, layer, &mut chunking)?; + } + // Unfortunately today we can't guarantee we reserialize the same tar stream + // or compression, so we'll need to generate a new copy of the manifest and config + // with the layers reset. + let mut new_manifest = srcinfo.manifest.clone(); + new_manifest.layers_mut().clear(); + let mut new_config = srcinfo.configuration.clone(); + if let Some(history) = new_config.history_mut() { + history.clear(); + } + new_config.rootfs_mut().diff_ids_mut().clear(); + + let mut dest_oci = ocidir::OciDir::ensure(dest_oci.try_clone()?)?; + + let opts = ExportOpts { + skip_compression: opts.skip_compression, + authfile: opts.authfile, + ..Default::default() + }; + + let mut labels = HashMap::new(); + + // Given the object chunking information we recomputed from what + // we found on disk, re-serialize to layers (tarballs). + export_chunked( + repo, + &srcinfo.base_commit, + &mut dest_oci, + &mut new_manifest, + &mut new_config, + &mut labels, + chunking, + &opts, + "", + )?; + + // Now, handle the non-ostree layers; this is a simple conversion of + // + let compression = opts.skip_compression.then_some(Compression::none()); + for (i, layer) in remaining_layers.iter().enumerate() { + let layer_ref = &ref_for_layer(layer)?; + let mut target_blob = dest_oci.create_gzip_layer(compression)?; + // Sadly the libarchive stuff isn't exposed via Rust due to type unsafety, + // so we'll just fork off the CLI. + let repo_dfd = repo.dfd_borrow(); + let repo_dir = cap_std_ext::cap_std::fs::Dir::reopen_dir(&repo_dfd)?; + let mut subproc = std::process::Command::new("ostree") + .args(["--repo=.", "export", layer_ref.as_str()]) + .stdout(std::process::Stdio::piped()) + .cwd_dir(repo_dir) + .spawn()?; + // SAFETY: we piped just above + let mut stdout = subproc.stdout.take().unwrap(); + std::io::copy(&mut stdout, &mut target_blob).context("Creating blob")?; + let layer = target_blob.complete()?; + let previous_annotations = srcinfo + .manifest + .layers() + .get(i) + .and_then(|l| l.annotations().as_ref()) + .cloned(); + let history = srcinfo.configuration.history().as_ref(); + let history_entry = history.and_then(|v| v.get(i)); + let previous_description = history_entry + .clone() + .and_then(|h| h.comment().as_deref()) + .unwrap_or_default(); + + let previous_created = history_entry + .and_then(|h| h.created().as_deref()) + .and_then(bootc_utils::try_deserialize_timestamp) + .unwrap_or_default(); + + dest_oci.push_layer_full( + &mut new_manifest, + &mut new_config, + layer, + previous_annotations, + previous_description, + previous_created, + ) + } + + let new_config = dest_oci.write_config(new_config)?; + new_manifest.set_config(new_config); + + Ok(dest_oci.insert_manifest(new_manifest, tag, oci_image::Platform::default())?) +} + +/// Given a container image reference which is stored in `repo`, export it to the +/// target image location. +#[context("Export")] +pub async fn export( + repo: &ostree::Repo, + src_imgref: &ImageReference, + dest_imgref: &ImageReference, + opts: Option, +) -> Result { + let opts = opts.unwrap_or_default(); + let target_oci = dest_imgref.transport == Transport::OciDir; + let tempdir = if !target_oci { + let vartmp = cap_std::fs::Dir::open_ambient_dir("/var/tmp", cap_std::ambient_authority())?; + let td = cap_std_ext::cap_tempfile::TempDir::new_in(&vartmp)?; + // Always skip compression when making a temporary copy + let opts = ExportToOCIOpts { + skip_compression: true, + progress_to_stdout: opts.progress_to_stdout, + ..Default::default() + }; + export_to_oci(repo, src_imgref, &td, None, opts)?; + td + } else { + let (path, tag) = parse_oci_path_and_tag(dest_imgref.name.as_str()); + tracing::debug!("using OCI path={path} tag={tag:?}"); + let path = Dir::open_ambient_dir(path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + let descriptor = export_to_oci(repo, src_imgref, &path, tag, opts)?; + return Ok(descriptor.digest().clone()); + }; + // Pass the temporary oci directory as the current working directory for the skopeo process + let target_fd = 3i32; + let tempoci = ImageReference { + transport: Transport::OciDir, + name: format!("/proc/self/fd/{target_fd}"), + }; + let authfile = opts.authfile.as_deref(); + skopeo::copy( + &tempoci, + dest_imgref, + authfile, + Some((std::sync::Arc::new(tempdir.try_clone()?.into()), target_fd)), + opts.progress_to_stdout, + ) + .await +} + +/// Iterate over deployment commits, returning the manifests from +/// commits which point to a container image. +#[context("Listing deployment manifests")] +fn list_container_deployment_manifests( + repo: &ostree::Repo, + cancellable: Option<&gio::Cancellable>, +) -> Result> { + // Gather all refs which start with ostree/0/ or ostree/1/ or rpmostree/base/ + // and create a set of the commits which they reference. + let commits = OSTREE_BASE_DEPLOYMENT_REFS + .iter() + .chain(RPMOSTREE_BASE_REFS) + .chain(std::iter::once(&BASE_IMAGE_PREFIX)) + .try_fold( + std::collections::HashSet::new(), + |mut acc, &p| -> Result<_> { + let refs = repo.list_refs_ext( + Some(p), + ostree::RepoListRefsExtFlags::empty(), + cancellable, + )?; + for (_, v) in refs { + acc.insert(v); + } + Ok(acc) + }, + )?; + // Loop over the commits - if they refer to a container image, add that to our return value. + let mut r = Vec::new(); + for commit in commits { + let commit_obj = repo.load_commit(&commit)?.0; + let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0))); + if commit_meta + .lookup::(META_MANIFEST_DIGEST)? + .is_some() + { + tracing::trace!("Commit {commit} is a container image"); + let manifest = manifest_data_from_commitmeta(commit_meta)?.0; + r.push(manifest); + } + } + Ok(r) +} + +/// Garbage collect unused image layer references. +/// +/// This function assumes no transaction is active on the repository. +/// The underlying objects are *not* pruned; that requires a separate invocation +/// of [`ostree::Repo::prune`]. +pub fn gc_image_layers(repo: &ostree::Repo) -> Result { + gc_image_layers_impl(repo, gio::Cancellable::NONE) +} + +#[context("Pruning image layers")] +fn gc_image_layers_impl( + repo: &ostree::Repo, + cancellable: Option<&gio::Cancellable>, +) -> Result { + let all_images = list_images(repo)?; + let deployment_commits = list_container_deployment_manifests(repo, cancellable)?; + let all_manifests = all_images + .into_iter() + .map(|img| { + ImageReference::try_from(img.as_str()).and_then(|ir| manifest_for_image(repo, &ir)) + }) + .chain(deployment_commits.into_iter().map(Ok)) + .collect::>>()?; + tracing::debug!("Images found: {}", all_manifests.len()); + let mut referenced_layers = BTreeSet::new(); + for m in all_manifests.iter() { + for layer in m.layers() { + referenced_layers.insert(layer.digest().to_string()); + } + } + tracing::debug!("Referenced layers: {}", referenced_layers.len()); + let found_layers = repo + .list_refs_ext( + Some(LAYER_PREFIX), + ostree::RepoListRefsExtFlags::empty(), + cancellable, + )? + .into_iter() + .map(|v| v.0); + tracing::debug!("Found layers: {}", found_layers.len()); + let mut pruned = 0u32; + for layer_ref in found_layers { + let layer_digest = refescape::unprefix_unescape_ref(LAYER_PREFIX, &layer_ref)?; + if referenced_layers.remove(layer_digest.as_str()) { + continue; + } + pruned += 1; + tracing::debug!("Pruning: {}", layer_ref.as_str()); + repo.set_ref_immediate(None, layer_ref.as_str(), None, cancellable)?; + } + + Ok(pruned) +} + +#[cfg(feature = "internal-testing-api")] +/// Return how many container blobs (layers) are stored +pub fn count_layer_references(repo: &ostree::Repo) -> Result { + let cancellable = gio::Cancellable::NONE; + let n = repo + .list_refs_ext( + Some(LAYER_PREFIX), + ostree::RepoListRefsExtFlags::empty(), + cancellable, + )? + .len(); + Ok(n as u32) +} + +/// Generate a suitable warning message from given list of filtered files, if any. +pub fn image_filtered_content_warning( + filtered_files: &Option, +) -> Result> { + use std::fmt::Write; + + let r = filtered_files.as_ref().map(|v| { + let mut filtered = BTreeMap::<&String, u32>::new(); + for paths in v.values() { + for (k, v) in paths { + let e = filtered.entry(k).or_default(); + *e += v; + } + } + let mut buf = "Image contains non-ostree compatible file paths:".to_string(); + for (k, v) in filtered { + write!(buf, " {k}: {v}").unwrap(); + } + buf + }); + Ok(r) +} + +/// Remove the specified image reference. If the image is already +/// not present, this function will successfully perform no operation. +/// +/// This function assumes no transaction is active on the repository. +/// The underlying layers are *not* pruned; that requires a separate invocation +/// of [`gc_image_layers`]. +#[context("Pruning {img}")] +pub fn remove_image(repo: &ostree::Repo, img: &ImageReference) -> Result { + let ostree_ref = &ref_for_image(img)?; + let found = repo.resolve_rev(ostree_ref, true)?.is_some(); + // Note this API is already idempotent, but we might as well avoid another + // trip into ostree. + if found { + repo.set_ref_immediate(None, ostree_ref, None, gio::Cancellable::NONE)?; + } + Ok(found) +} + +/// Remove the specified image references. If an image is not found, further +/// images will be removed, but an error will be returned. +/// +/// This function assumes no transaction is active on the repository. +/// The underlying layers are *not* pruned; that requires a separate invocation +/// of [`gc_image_layers`]. +pub fn remove_images<'a>( + repo: &ostree::Repo, + imgs: impl IntoIterator, +) -> Result<()> { + let mut missing = Vec::new(); + for img in imgs.into_iter() { + let found = remove_image(repo, img)?; + if !found { + missing.push(img); + } + } + if !missing.is_empty() { + let missing = missing.into_iter().fold("".to_string(), |mut a, v| { + a.push_str(&v.to_string()); + a + }); + return Err(anyhow::anyhow!("Missing images: {missing}")); + } + Ok(()) +} + +#[derive(Debug, Default)] +struct CompareState { + verified: BTreeSet, + inode_corrupted: BTreeSet, + unknown_corrupted: BTreeSet, +} + +impl CompareState { + fn is_ok(&self) -> bool { + self.inode_corrupted.is_empty() && self.unknown_corrupted.is_empty() + } +} + +fn compare_file_info(src: &gio::FileInfo, target: &gio::FileInfo) -> bool { + if src.file_type() != target.file_type() { + return false; + } + if src.size() != target.size() { + return false; + } + for attr in ["unix::uid", "unix::gid", "unix::mode"] { + if src.attribute_uint32(attr) != target.attribute_uint32(attr) { + return false; + } + } + true +} + +#[context("Querying object inode")] +fn inode_of_object(repo: &ostree::Repo, checksum: &str) -> Result { + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + let (prefix, suffix) = checksum.split_at(2); + let objpath = format!("objects/{prefix}/{suffix}.file"); + let metadata = repodir.symlink_metadata(objpath)?; + Ok(metadata.ino()) +} + +fn compare_commit_trees( + repo: &ostree::Repo, + root: &Utf8Path, + target: &ostree::RepoFile, + expected: &ostree::RepoFile, + exact: bool, + colliding_inodes: &BTreeSet, + state: &mut CompareState, +) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let queryattrs = "standard::name,standard::type"; + let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let expected_iter = expected.enumerate_children(queryattrs, queryflags, cancellable)?; + + while let Some(expected_info) = expected_iter.next_file(cancellable)? { + let expected_child = expected_iter.child(&expected_info); + let name = expected_info.name(); + let name = name.to_str().expect("UTF-8 ostree name"); + let path = Utf8PathBuf::from(format!("{root}{name}")); + let target_child = target.child(name); + let target_info = crate::diff::query_info_optional(&target_child, queryattrs, queryflags) + .context("querying optional to")?; + let is_dir = matches!(expected_info.file_type(), gio::FileType::Directory); + if let Some(target_info) = target_info { + let to_child = target_child + .downcast::() + .expect("downcast"); + to_child.ensure_resolved()?; + let from_child = expected_child + .downcast::() + .expect("downcast"); + from_child.ensure_resolved()?; + + if is_dir { + let from_contents_checksum = from_child.tree_get_contents_checksum(); + let to_contents_checksum = to_child.tree_get_contents_checksum(); + if from_contents_checksum != to_contents_checksum { + let subpath = Utf8PathBuf::from(format!("{path}/")); + compare_commit_trees( + repo, + &subpath, + &from_child, + &to_child, + exact, + colliding_inodes, + state, + )?; + } + } else { + let from_checksum = from_child.checksum(); + let to_checksum = to_child.checksum(); + let matches = if exact { + from_checksum == to_checksum + } else { + compare_file_info(&target_info, &expected_info) + }; + if !matches { + let from_inode = inode_of_object(repo, &from_checksum)?; + let to_inode = inode_of_object(repo, &to_checksum)?; + if colliding_inodes.contains(&from_inode) + || colliding_inodes.contains(&to_inode) + { + state.inode_corrupted.insert(path); + } else { + state.unknown_corrupted.insert(path); + } + } else { + state.verified.insert(path); + } + } + } else { + eprintln!("Missing {path}"); + state.unknown_corrupted.insert(path); + } + } + Ok(()) +} + +#[context("Verifying container image state")] +pub(crate) fn verify_container_image( + sysroot: &SysrootLock, + imgref: &ImageReference, + state: &LayeredImageState, + colliding_inodes: &BTreeSet, + verbose: bool, +) -> Result { + let cancellable = gio::Cancellable::NONE; + let repo = &sysroot.repo(); + let merge_commit = state.merge_commit.as_str(); + let merge_commit_root = repo.read_commit(merge_commit, gio::Cancellable::NONE)?.0; + let merge_commit_root = merge_commit_root + .downcast::() + .expect("downcast"); + merge_commit_root.ensure_resolved()?; + + let (commit_layer, _component_layers, remaining_layers) = + parse_manifest_layout(&state.manifest, &state.configuration)?; + + let mut comparison_state = CompareState::default(); + + let query = |l: &Descriptor| query_layer(repo, l.clone()); + + let base_tree = repo + .read_commit(&state.base_commit, cancellable)? + .0 + .downcast::() + .expect("downcast"); + if let Some(commit_layer) = commit_layer { + println!( + "Verifying with base ostree layer {}", + ref_for_layer(commit_layer)? + ); + } + compare_commit_trees( + repo, + "/".into(), + &merge_commit_root, + &base_tree, + true, + colliding_inodes, + &mut comparison_state, + )?; + + let remaining_layers = remaining_layers + .into_iter() + .map(query) + .collect::>>()?; + + println!("Image has {} derived layers", remaining_layers.len()); + + for layer in remaining_layers.iter().rev() { + let layer_ref = layer.ostree_ref.as_str(); + let layer_commit = layer + .commit + .as_deref() + .ok_or_else(|| anyhow!("Missing layer {layer_ref}"))?; + let layer_tree = repo + .read_commit(layer_commit, cancellable)? + .0 + .downcast::() + .expect("downcast"); + compare_commit_trees( + repo, + "/".into(), + &merge_commit_root, + &layer_tree, + false, + colliding_inodes, + &mut comparison_state, + )?; + } + + let n_verified = comparison_state.verified.len(); + if comparison_state.is_ok() { + println!("OK image {imgref} (verified={n_verified})"); + println!(); + } else { + let n_inode = comparison_state.inode_corrupted.len(); + let n_other = comparison_state.unknown_corrupted.len(); + eprintln!("warning: Found corrupted merge commit"); + eprintln!(" inode clashes: {n_inode}"); + eprintln!(" unknown: {n_other}"); + eprintln!(" ok: {n_verified}"); + if verbose { + eprintln!("Mismatches:"); + for path in comparison_state.inode_corrupted { + eprintln!(" inode: {path}"); + } + for path in comparison_state.unknown_corrupted { + eprintln!(" other: {path}"); + } + } + eprintln!(); + return Ok(false); + } + + Ok(true) +} + +#[cfg(test)] +mod tests { + use cap_std_ext::cap_tempfile; + use oci_image::{DescriptorBuilder, MediaType, Sha256Digest}; + + use super::*; + + #[test] + fn test_ref_for_descriptor() { + let d = DescriptorBuilder::default() + .size(42u64) + .media_type(MediaType::ImageManifest) + .digest( + Sha256Digest::from_str( + "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + ) + .unwrap(), + ) + .build() + .unwrap(); + assert_eq!(ref_for_layer(&d).unwrap(), "ostree/container/blob/sha256_3A_2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); + } + + #[test] + fn test_cleanup_root() -> Result<()> { + let td = cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let usretc = "usr/etc"; + cleanup_root(&td).unwrap(); + td.create_dir_all(usretc)?; + let usretc = &td.open_dir(usretc)?; + usretc.write("hostname", b"hostname")?; + cleanup_root(&td).unwrap(); + assert!(usretc.try_exists("hostname")?); + usretc.write("hostname", b"")?; + cleanup_root(&td).unwrap(); + assert!(!td.try_exists("hostname")?); + + usretc.symlink_contents("../run/systemd/stub-resolv.conf", "resolv.conf")?; + cleanup_root(&td).unwrap(); + assert!(usretc.symlink_metadata("resolv.conf")?.is_symlink()); + usretc.remove_file("resolv.conf")?; + usretc.write("resolv.conf", b"")?; + cleanup_root(&td).unwrap(); + assert!(!usretc.try_exists("resolv.conf")?); + + Ok(()) + } +} diff --git a/crates/ostree-ext/src/container/tests/it/fixtures/exampleos.tar.zst b/crates/ostree-ext/src/container/tests/it/fixtures/exampleos.tar.zst new file mode 100644 index 000000000..8e8969d83 Binary files /dev/null and b/crates/ostree-ext/src/container/tests/it/fixtures/exampleos.tar.zst differ diff --git a/crates/ostree-ext/src/container/unencapsulate.rs b/crates/ostree-ext/src/container/unencapsulate.rs new file mode 100644 index 000000000..c386576d0 --- /dev/null +++ b/crates/ostree-ext/src/container/unencapsulate.rs @@ -0,0 +1,265 @@ +//! APIs for "unencapsulating" OSTree commits from container images +//! +//! This code only operates on container images that were created via +//! [`encapsulate`]. +//! +//! # External dependency on container-image-proxy +//! +//! This code requires +//! installed as a binary in $PATH. +//! +//! The rationale for this is that while there exist Rust crates to speak +//! the Docker distribution API, the Go library +//! supports key things we want for production use like: +//! +//! - Image mirroring and remapping; effectively `man containers-registries.conf` +//! For example, we need to support an administrator mirroring an ostree-container +//! into a disconnected registry, without changing all the pull specs. +//! - Signing +//! +//! Additionally, the proxy "upconverts" manifests into OCI, so we don't need to care +//! about parsing the Docker manifest format (as used by most registries still). +//! +//! [`encapsulate`]: [`super::encapsulate()`] + +// # Implementation +// +// First, we support explicitly fetching just the manifest: https://github.com/opencontainers/image-spec/blob/main/manifest.md +// This will give us information about the layers it contains, and crucially the digest (sha256) of +// the manifest is how higher level software can detect changes. +// +// Once we have the manifest, we expect it to point to a single `application/vnd.oci.image.layer.v1.tar+gzip` layer, +// which is exactly what is exported by the [`crate::tar::export`] process. + +use crate::container::store::LayerProgress; + +use super::*; +use containers_image_proxy::{ImageProxy, OpenedImage}; +use fn_error_context::context; +use futures_util::{Future, FutureExt}; +use oci_spec::image::{self as oci_image, Digest}; +use std::sync::{Arc, Mutex}; +use tokio::{ + io::{AsyncBufRead, AsyncRead}, + sync::watch::{Receiver, Sender}, +}; +use tracing::instrument; + +type Progress = tokio::sync::watch::Sender; + +/// A read wrapper that updates the download progress. +#[pin_project::pin_project] +#[derive(Debug)] +pub(crate) struct ProgressReader { + #[pin] + pub(crate) reader: T, + #[pin] + pub(crate) progress: Arc>, +} + +impl ProgressReader { + pub(crate) fn new(reader: T) -> (Self, Receiver) { + let (progress, r) = tokio::sync::watch::channel(1); + let progress = Arc::new(Mutex::new(progress)); + (ProgressReader { reader, progress }, r) + } +} + +impl AsyncRead for ProgressReader { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let this = self.project(); + let len = buf.filled().len(); + match this.reader.poll_read(cx, buf) { + v @ std::task::Poll::Ready(Ok(_)) => { + let progress = this.progress.lock().unwrap(); + let state = { + let mut state = *progress.borrow(); + let newlen = buf.filled().len(); + debug_assert!(newlen >= len); + let read = (newlen - len) as u64; + state += read; + state + }; + // Ignore errors, if the caller disconnected from progress that's OK. + let _ = progress.send(state); + v + } + o => o, + } + } +} + +async fn fetch_manifest_impl( + proxy: &mut ImageProxy, + imgref: &OstreeImageReference, +) -> Result<(oci_image::ImageManifest, oci_image::Digest)> { + let oi = &proxy.open_image(&imgref.imgref.to_string()).await?; + let (digest, manifest) = proxy.fetch_manifest(oi).await?; + proxy.close_image(oi).await?; + Ok((manifest, oci_image::Digest::from_str(digest.as_str())?)) +} + +/// Download the manifest for a target image and its sha256 digest. +#[context("Fetching manifest")] +pub async fn fetch_manifest( + imgref: &OstreeImageReference, +) -> Result<(oci_image::ImageManifest, oci_image::Digest)> { + let mut proxy = ImageProxy::new().await?; + fetch_manifest_impl(&mut proxy, imgref).await +} + +/// Download the manifest for a target image and its sha256 digest, as well as the image configuration. +#[context("Fetching manifest and config")] +pub async fn fetch_manifest_and_config( + imgref: &OstreeImageReference, +) -> Result<( + oci_image::ImageManifest, + oci_image::Digest, + oci_image::ImageConfiguration, +)> { + let proxy = ImageProxy::new().await?; + let oi = &proxy.open_image(&imgref.imgref.to_string()).await?; + let (digest, manifest) = proxy.fetch_manifest(oi).await?; + let digest = oci_image::Digest::from_str(&digest)?; + let config = proxy.fetch_config(oi).await?; + Ok((manifest, digest, config)) +} + +/// The result of an import operation +#[derive(Debug)] +pub struct Import { + /// The ostree commit that was imported + pub ostree_commit: String, + /// The image digest retrieved + pub image_digest: Digest, + + /// Any deprecation warning + pub deprecated_warning: Option, +} + +/// Use this to process potential errors from a worker and a driver. +/// This is really a brutal hack around the fact that an error can occur +/// on either our side or in the proxy. But if an error occurs on our +/// side, then we will close the pipe, which will *also* cause the proxy +/// to error out. +/// +/// What we really want is for the proxy to tell us when it got an +/// error from us closing the pipe. Or, we could store that state +/// on our side. Both are slightly tricky, so we have this (again) +/// hacky thing where we just search for `broken pipe` in the error text. +/// +/// Or to restate all of the above - what this function does is check +/// to see if the worker function had an error *and* if the proxy +/// had an error, but if the proxy's error ends in `broken pipe` +/// then it means the real only error is from the worker. +pub(crate) async fn join_fetch( + worker: impl Future>, + driver: impl Future>, +) -> Result { + let (worker, driver) = tokio::join!(worker, driver); + match (worker, driver) { + (Ok(t), Ok(())) => Ok(t), + (Err(worker), Err(driver)) => { + let text = driver.root_cause().to_string(); + if text.ends_with("broken pipe") { + tracing::trace!("Ignoring broken pipe failure from driver"); + Err(worker) + } else { + Err(worker.context(format!("proxy failure: {text} and client error"))) + } + } + (Ok(_), Err(driver)) => Err(driver), + (Err(worker), Ok(())) => Err(worker), + } +} + +/// Fetch a container image and import its embedded OSTree commit. +#[context("Importing {}", imgref)] +#[instrument(level = "debug", skip(repo))] +pub async fn unencapsulate(repo: &ostree::Repo, imgref: &OstreeImageReference) -> Result { + let importer = super::store::ImageImporter::new(repo, imgref, Default::default()).await?; + importer.unencapsulate().await +} + +/// A wrapper for [`get_blob`] which fetches a layer and decompresses it. +pub(crate) async fn fetch_layer<'a>( + proxy: &'a ImageProxy, + img: &OpenedImage, + manifest: &oci_image::ImageManifest, + layer: &'a oci_image::Descriptor, + progress: Option<&'a Sender>>, + layer_info: Option<&Vec>, + transport_src: Transport, +) -> Result<( + Box, + impl Future> + 'a, + oci_image::MediaType, +)> { + use futures_util::future::Either; + tracing::debug!("fetching {}", layer.digest()); + let layer_index = manifest.layers().iter().position(|x| x == layer).unwrap(); + let (blob, driver, size); + let mut media_type: oci_image::MediaType; + match transport_src { + // Both containers-storage and docker-daemon store layers uncompressed in their + // local storage, even though the manifest may indicate they are compressed. + // We need to use the actual media type from layer_info to avoid decompression errors. + Transport::ContainerStorage | Transport::DockerDaemon => { + let layer_info = layer_info.ok_or_else(|| { + anyhow!("skopeo too old to pull from containers-storage or docker-daemon") + })?; + let n_layers = layer_info.len(); + let layer_blob = layer_info.get(layer_index).ok_or_else(|| { + anyhow!("blobid position {layer_index} exceeds diffid count {n_layers}") + })?; + size = layer_blob.size; + media_type = layer_blob.media_type.clone(); + + // docker-daemon stores layers uncompressed even when the media type + // indicates gzip compression. Translate to the uncompressed variant. + if transport_src == Transport::DockerDaemon { + if let oci_image::MediaType::Other(t) = &media_type { + if t.as_str() == "application/vnd.docker.image.rootfs.diff.tar.gzip" { + media_type = oci_image::MediaType::Other( + "application/vnd.docker.image.rootfs.diff.tar".to_string(), + ); + } + } + } + + (blob, driver) = proxy.get_blob(img, &layer_blob.digest, size).await?; + } + _ => { + size = layer.size(); + media_type = layer.media_type().clone(); + (blob, driver) = proxy.get_blob(img, layer.digest(), size).await?; + } + }; + + let driver = async { driver.await.map_err(Into::into) }; + + if let Some(progress) = progress { + let (readprogress, mut readwatch) = ProgressReader::new(blob); + let readprogress = tokio::io::BufReader::new(readprogress); + let readproxy = async move { + while let Ok(()) = readwatch.changed().await { + let fetched = readwatch.borrow_and_update(); + let status = LayerProgress { + layer_index, + fetched: *fetched, + total: size, + }; + progress.send_replace(Some(status)); + } + }; + let reader = Box::new(readprogress); + let driver = futures_util::future::join(readproxy, driver).map(|r| r.1); + Ok((reader, Either::Left(driver), media_type)) + } else { + Ok((Box::new(blob), Either::Right(driver), media_type)) + } +} diff --git a/crates/ostree-ext/src/container/update_detachedmeta.rs b/crates/ostree-ext/src/container/update_detachedmeta.rs new file mode 100644 index 000000000..2ad02c382 --- /dev/null +++ b/crates/ostree-ext/src/container/update_detachedmeta.rs @@ -0,0 +1,137 @@ +use super::ImageReference; +use crate::container::{skopeo, DIFFID_LABEL}; +use crate::container::{store as container_store, Transport}; +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use containers_image_proxy::oci_spec::image as oci_image; +use std::io::{BufReader, BufWriter}; + +/// Given an OSTree container image reference, update the detached metadata (e.g. GPG signature) +/// while preserving all other container image metadata. +/// +/// The return value is the manifest digest of (e.g. `@sha256:`) the image. +pub async fn update_detached_metadata( + src: &ImageReference, + dest: &ImageReference, + detached_buf: Option<&[u8]>, +) -> Result { + // For now, convert the source to a temporary OCI directory, so we can directly + // parse and manipulate it. In the future this will be replaced by https://github.com/ostreedev/ostree-rs-ext/issues/153 + // and other work to directly use the containers/image API via containers-image-proxy. + let tempdir = tempfile::tempdir_in("/var/tmp")?; + let tempsrc = tempdir.path().join("src"); + let tempsrc_utf8 = Utf8Path::from_path(&tempsrc).ok_or_else(|| anyhow!("Invalid tempdir"))?; + let tempsrc_ref = ImageReference { + transport: Transport::OciDir, + name: tempsrc_utf8.to_string(), + }; + + // Full copy of the source image + let pulled_digest = skopeo::copy(src, &tempsrc_ref, None, None, false) + .await + .context("Creating temporary copy to OCI dir")?; + + // Copy to the thread + let detached_buf = detached_buf.map(Vec::from); + let tempsrc_ref_path = tempsrc_ref.name.clone(); + // Fork a thread to do the heavy lifting of filtering the tar stream, rewriting the manifest/config. + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + // Open the temporary OCI directory. + let tempsrc = Dir::open_ambient_dir(tempsrc_ref_path, cap_std::ambient_authority()) + .context("Opening src")?; + let tempsrc = ocidir::OciDir::open(tempsrc)?; + + // Load the manifest, platform, and config + let idx = tempsrc.read_index()?; + let manifest_descriptor = idx + .manifests() + .first() + .ok_or(anyhow!("No manifests in index"))?; + let mut manifest: oci_image::ImageManifest = tempsrc + .read_json_blob(manifest_descriptor) + .context("Reading manifest json blob")?; + + anyhow::ensure!(manifest_descriptor.digest() == &pulled_digest); + let platform = manifest_descriptor + .platform() + .as_ref() + .cloned() + .unwrap_or_default(); + let mut config: oci_image::ImageConfiguration = + tempsrc.read_json_blob(manifest.config())?; + let mut ctrcfg = config + .config() + .as_ref() + .cloned() + .ok_or_else(|| anyhow!("Image is missing container configuration"))?; + + // Find the OSTree commit layer we want to replace + let (commit_layer, _, _) = + container_store::parse_ostree_manifest_layout(&manifest, &config)?; + let commit_layer_idx = manifest + .layers() + .iter() + .position(|x| x == commit_layer) + .unwrap(); + + // Create a new layer + let out_layer = { + // Create tar streams for source and destination + let src_layer = BufReader::new(tempsrc.read_blob(commit_layer)?); + let mut src_layer = flate2::read::GzDecoder::new(src_layer); + let mut out_layer = BufWriter::new(tempsrc.create_gzip_layer(None)?); + + // Process the tar stream and inject our new detached metadata + crate::tar::update_detached_metadata( + &mut src_layer, + &mut out_layer, + detached_buf.as_deref(), + Some(cancellable), + )?; + + // Flush all wrappers, and finalize the layer + out_layer + .into_inner() + .map_err(|_| anyhow!("Failed to flush buffer"))? + .complete()? + }; + // Get the diffid and descriptor for our new tar layer + let out_layer_diffid = format!("sha256:{}", out_layer.uncompressed_sha256.digest()); + let out_layer_descriptor = out_layer + .descriptor() + .media_type(oci_image::MediaType::ImageLayerGzip) + .build() + .unwrap(); // SAFETY: We pass all required fields + + // Splice it into both the manifest and config + manifest.layers_mut()[commit_layer_idx] = out_layer_descriptor; + config.rootfs_mut().diff_ids_mut()[commit_layer_idx].clone_from(&out_layer_diffid); + + let labels = ctrcfg.labels_mut().get_or_insert_with(Default::default); + // Nothing to do except in the special case where there's somehow only one + // chunked layer. + if manifest.layers().len() == 1 { + labels.insert(DIFFID_LABEL.into(), out_layer_diffid); + } + config.set_config(Some(ctrcfg)); + + // Write the config and manifest + let new_config_descriptor = tempsrc.write_config(config)?; + manifest.set_config(new_config_descriptor); + // This entirely replaces the single entry in the OCI directory, which skopeo will find by default. + tempsrc + .replace_with_single_manifest(manifest, platform) + .context("Writing manifest")?; + Ok(()) + }) + .await + .context("Regenerating commit layer")?; + + // Finally, copy the mutated image back to the target. For chunked images, + // because we only changed one layer, skopeo should know not to re-upload shared blobs. + crate::container::skopeo::copy(&tempsrc_ref, dest, None, None, false) + .await + .context("Copying to destination") +} diff --git a/crates/ostree-ext/src/container_utils.rs b/crates/ostree-ext/src/container_utils.rs new file mode 100644 index 000000000..7737e986c --- /dev/null +++ b/crates/ostree-ext/src/container_utils.rs @@ -0,0 +1,106 @@ +//! Helpers for interacting with containers at runtime. + +use std::io; +use std::io::Read; +use std::path::Path; + +use anyhow::Result; +use ocidir::cap_std::fs::Dir; +use ostree::glib; + +use crate::keyfileext::KeyFileExt; + +/// The relative path to the stamp file which signals this is an ostree-booted system. +pub const OSTREE_BOOTED: &str = "run/ostree-booted"; + +// See https://github.com/coreos/rpm-ostree/pull/3285#issuecomment-999101477 +// For compatibility with older ostree, we stick this in /sysroot where +// it will be ignored. +const V0_REPO_CONFIG: &str = "/sysroot/config"; +const V1_REPO_CONFIG: &str = "/sysroot/ostree/repo/config"; + +/// Attempts to detect if the current process is running inside a container. +/// This looks for the `container` environment variable or the presence +/// of Docker or podman's more generic `/run/.containerenv`. +/// This is a best-effort function, as there is not a 100% reliable way +/// to determine this. +pub fn running_in_container() -> bool { + if std::env::var_os("container").is_some() { + return true; + } + // https://stackoverflow.com/questions/20010199/how-to-determine-if-a-process-runs-inside-lxc-docker + for p in ["/run/.containerenv", "/.dockerenv"] { + if std::path::Path::new(p).exists() { + return true; + } + } + false +} + +// https://docs.rs/openat-ext/0.1.10/openat_ext/trait.OpenatDirExt.html#tymethod.open_file_optional +// https://users.rust-lang.org/t/why-i-use-anyhow-error-even-in-libraries/68592 +pub(crate) fn open_optional(path: impl AsRef) -> std::io::Result> { + match std::fs::File::open(path.as_ref()) { + Ok(r) => Ok(Some(r)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Returns `true` if the current root filesystem has an ostree repository in `bare-split-xattrs` mode. +/// This will be the case in a running ostree-native container. +pub fn is_bare_split_xattrs() -> Result { + if let Some(configf) = open_optional(V1_REPO_CONFIG) + .transpose() + .or_else(|| open_optional(V0_REPO_CONFIG).transpose()) + { + let configf = configf?; + let mut bufr = std::io::BufReader::new(configf); + let mut s = String::new(); + bufr.read_to_string(&mut s)?; + let kf = glib::KeyFile::new(); + kf.load_from_data(&s, glib::KeyFileFlags::NONE)?; + let r = if let Some(mode) = kf.optional_string("core", "mode")? { + mode == crate::tar::BARE_SPLIT_XATTRS_MODE + } else { + false + }; + Ok(r) + } else { + Ok(false) + } +} + +/// Returns true if the system appears to have been booted via ostree. +/// This accesses global state in /run. +pub fn ostree_booted() -> io::Result { + Path::new(&format!("/{OSTREE_BOOTED}")).try_exists() +} + +/// Returns true if the target root appears to have been booted via ostree. +pub fn is_ostree_booted_in(rootfs: &Dir) -> io::Result { + rootfs.try_exists(OSTREE_BOOTED) +} + +/// Returns `true` if the current booted filesystem appears to be an ostree-native container. +/// +/// This just invokes [`is_bare_split_xattrs`] and [`running_in_container`]. +pub fn is_ostree_container() -> Result { + let is_container_ostree = is_bare_split_xattrs()?; + let running_in_systemd = std::env::var_os("INVOCATION_ID").is_some(); + // If we have a container-ostree repo format, then we'll assume we're + // running in a container unless there's strong evidence not (we detect + // we're part of a systemd unit or are in a booted ostree system). + let maybe_container = running_in_container() || (!running_in_systemd && !ostree_booted()?); + Ok(is_container_ostree && maybe_container) +} + +/// Returns an error unless the current filesystem is an ostree-based container +/// +/// This just wraps [`is_ostree_container`]. +pub fn require_ostree_container() -> Result<()> { + if !is_ostree_container()? { + anyhow::bail!("Not in an ostree-based container environment"); + } + Ok(()) +} diff --git a/crates/ostree-ext/src/diff.rs b/crates/ostree-ext/src/diff.rs new file mode 100644 index 000000000..aafdb2e70 --- /dev/null +++ b/crates/ostree-ext/src/diff.rs @@ -0,0 +1,181 @@ +//! Compute the difference between two OSTree commits. + +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use anyhow::{Context, Result}; +use fn_error_context::context; +use gio::prelude::*; +use ostree::gio; +use std::collections::BTreeSet; +use std::fmt; + +/// Like `g_file_query_info()`, but return None if the target doesn't exist. +pub(crate) fn query_info_optional( + f: &gio::File, + queryattrs: &str, + queryflags: gio::FileQueryInfoFlags, +) -> Result> { + let cancellable = gio::Cancellable::NONE; + match f.query_info(queryattrs, queryflags, cancellable) { + Ok(i) => Ok(Some(i)), + Err(e) => { + if let Some(ref e2) = e.kind::() { + match e2 { + gio::IOErrorEnum::NotFound => Ok(None), + _ => Err(e.into()), + } + } else { + Err(e.into()) + } + } + } +} + +/// A set of file paths. +pub type FileSet = BTreeSet; + +/// Diff between two ostree commits. +#[derive(Debug, Default)] +pub struct FileTreeDiff { + /// The prefix passed for diffing, e.g. /usr + pub subdir: Option, + /// Files that are new in an existing directory + pub added_files: FileSet, + /// New directories + pub added_dirs: FileSet, + /// Files removed + pub removed_files: FileSet, + /// Directories removed (recursively) + pub removed_dirs: FileSet, + /// Files that changed (in any way, metadata or content) + pub changed_files: FileSet, + /// Directories that changed mode/permissions + pub changed_dirs: FileSet, +} + +impl fmt::Display for FileTreeDiff { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "files(added:{} removed:{} changed:{}) dirs(added:{} removed:{} changed:{})", + self.added_files.len(), + self.removed_files.len(), + self.changed_files.len(), + self.added_dirs.len(), + self.removed_dirs.len(), + self.changed_dirs.len() + ) + } +} + +fn diff_recurse( + prefix: &str, + diff: &mut FileTreeDiff, + from: &ostree::RepoFile, + to: &ostree::RepoFile, +) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let queryattrs = "standard::name,standard::type"; + let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let from_iter = from.enumerate_children(queryattrs, queryflags, cancellable)?; + + // Iterate over the source (from) directory, and compare with the + // target (to) directory. This generates removals and changes. + while let Some(from_info) = from_iter.next_file(cancellable)? { + let from_child = from_iter.child(&from_info); + let name = from_info.name(); + let name = name.to_str().expect("UTF-8 ostree name"); + let path = format!("{prefix}{name}"); + let to_child = to.child(name); + let to_info = query_info_optional(&to_child, queryattrs, queryflags) + .context("querying optional to")?; + let is_dir = matches!(from_info.file_type(), gio::FileType::Directory); + if to_info.is_some() { + let to_child = to_child.downcast::().expect("downcast"); + to_child.ensure_resolved()?; + let from_child = from_child.downcast::().expect("downcast"); + from_child.ensure_resolved()?; + + if is_dir { + let from_contents_checksum = from_child.tree_get_contents_checksum(); + let to_contents_checksum = to_child.tree_get_contents_checksum(); + if from_contents_checksum != to_contents_checksum { + let subpath = format!("{path}/"); + diff_recurse(&subpath, diff, &from_child, &to_child)?; + } + let from_meta_checksum = from_child.tree_get_metadata_checksum(); + let to_meta_checksum = to_child.tree_get_metadata_checksum(); + if from_meta_checksum != to_meta_checksum { + diff.changed_dirs.insert(path); + } + } else { + let from_checksum = from_child.checksum(); + let to_checksum = to_child.checksum(); + if from_checksum != to_checksum { + diff.changed_files.insert(path); + } + } + } else if is_dir { + diff.removed_dirs.insert(path); + } else { + diff.removed_files.insert(path); + } + } + // Iterate over the target (to) directory, and find any + // files/directories which were not present in the source. + let to_iter = to.enumerate_children(queryattrs, queryflags, cancellable)?; + while let Some(to_info) = to_iter.next_file(cancellable)? { + let name = to_info.name(); + let name = name.to_str().expect("UTF-8 ostree name"); + let path = format!("{prefix}{name}"); + let from_child = from.child(name); + let from_info = query_info_optional(&from_child, queryattrs, queryflags) + .context("querying optional from")?; + if from_info.is_some() { + continue; + } + let is_dir = matches!(to_info.file_type(), gio::FileType::Directory); + if is_dir { + diff.added_dirs.insert(path); + } else { + diff.added_files.insert(path); + } + } + Ok(()) +} + +/// Given two ostree commits, compute the diff between them. +#[context("Computing ostree diff")] +pub fn diff>( + repo: &ostree::Repo, + from: &str, + to: &str, + subdir: Option

, +) -> Result { + let subdir = subdir.as_ref(); + let subdir = subdir.map(|s| s.as_ref()); + let (fromroot, _) = repo.read_commit(from, gio::Cancellable::NONE)?; + let (toroot, _) = repo.read_commit(to, gio::Cancellable::NONE)?; + let (fromroot, toroot) = if let Some(subdir) = subdir { + ( + fromroot.resolve_relative_path(subdir), + toroot.resolve_relative_path(subdir), + ) + } else { + (fromroot, toroot) + }; + let fromroot = fromroot.downcast::().expect("downcast"); + fromroot.ensure_resolved()?; + let toroot = toroot.downcast::().expect("downcast"); + toroot.ensure_resolved()?; + let mut diff = FileTreeDiff { + subdir: subdir.map(|s| s.to_string()), + ..Default::default() + }; + diff_recurse("/", &mut diff, &fromroot, &toroot)?; + Ok(diff) +} diff --git a/crates/ostree-ext/src/docgen.rs b/crates/ostree-ext/src/docgen.rs new file mode 100644 index 000000000..0e2d12df0 --- /dev/null +++ b/crates/ostree-ext/src/docgen.rs @@ -0,0 +1,46 @@ +// Copyright 2022 Red Hat, Inc. +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use clap::{Command, CommandFactory}; +use std::fs::OpenOptions; +use std::io::Write; + +pub fn generate_manpages(directory: &Utf8Path) -> Result<()> { + generate_one(directory, crate::cli::Opt::command()) +} + +fn generate_one(directory: &Utf8Path, cmd: Command) -> Result<()> { + let version = env!("CARGO_PKG_VERSION"); + let name = cmd.get_name(); + let path = directory.join(format!("{name}.8")); + println!("Generating {path}..."); + + let mut out = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&path) + .with_context(|| format!("opening {path}")) + .map(std::io::BufWriter::new)?; + clap_mangen::Man::new(cmd.clone()) + .title("ostree-ext") + .section("8") + .source(format!("ostree-ext {version}")) + .render(&mut out) + .with_context(|| format!("rendering {name}.8"))?; + out.flush().context("flushing man page")?; + drop(out); + + for subcmd in cmd.get_subcommands().filter(|c| !c.is_hide_set()) { + let subname = format!("{}-{}", name, subcmd.get_name()); + // SAFETY: Latest clap 4 requires names are &'static - this is + // not long-running production code, so we just leak the names here. + let subname = &*std::boxed::Box::leak(subname.into_boxed_str()); + let subcmd = subcmd.clone().name(subname).alias(subname).version(version); + generate_one(directory, subcmd)?; + } + Ok(()) +} diff --git a/crates/ostree-ext/src/fixture.rs b/crates/ostree-ext/src/fixture.rs new file mode 100644 index 000000000..9a31a7777 --- /dev/null +++ b/crates/ostree-ext/src/fixture.rs @@ -0,0 +1,1065 @@ +//! Test suite fixture. Should only be used by this library. + +#![allow(missing_docs)] + +use crate::chunking::ObjectMetaSized; +use crate::container::store::{self, LayeredImageState}; +use crate::container::{Config, ExportOpts, ImageReference, Transport}; +use crate::objectsource::{ObjectMeta, ObjectSourceMeta}; +use crate::objgv::gv_dirtree; +use crate::prelude::*; +use crate::tar::SECURITY_SELINUX_XATTR_C; +use crate::{gio, glib}; +use anyhow::{anyhow, Context, Result}; +use bootc_utils::CommandRunExt; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::prelude::CapStdExtCommandExt; +use chrono::TimeZone; +use containers_image_proxy::oci_spec::image as oci_image; +use fn_error_context::context; +use gvariant::aligned_bytes::TryAsAligned; +use gvariant::{Marker, Structure}; +use io_lifetimes::AsFd; +use ocidir::cap_std::fs::{DirBuilder, DirBuilderExt as _}; +use ocidir::oci_spec::image::ImageConfigurationBuilder; +use regex::Regex; +use std::borrow::Cow; +use std::collections::HashMap; +use std::ffi::CString; +use std::fmt::Write as _; +use std::io::{self, Write}; +use std::ops::Add; +use std::process::{Command, Stdio}; +use std::rc::Rc; +use std::sync::{Arc, LazyLock}; +use tempfile::TempDir; + +const OSTREE_GPG_HOME: &[u8] = include_bytes!("fixtures/ostree-gpg-test-home.tar.gz"); +const TEST_GPG_KEYID_1: &str = "7FCA23D8472CDAFA"; +#[allow(dead_code)] +const TEST_GPG_KEYFPR_1: &str = "5E65DE75AB1C501862D476347FCA23D8472CDAFA"; +const TESTREF: &str = "exampleos/x86_64/stable"; + +#[derive(Debug)] +enum FileDefType { + Regular(Cow<'static, str>), + Symlink(Cow<'static, Utf8Path>), + Directory, +} + +#[derive(Debug)] +struct Xattr { + key: CString, + value: Box<[u8]>, +} + +#[derive(Debug)] +pub struct FileDef { + uid: u32, + gid: u32, + mode: u32, + path: Cow<'static, Utf8Path>, + xattrs: Box<[Xattr]>, + ty: FileDefType, +} + +impl TryFrom<&'static str> for FileDef { + type Error = anyhow::Error; + + fn try_from(value: &'static str) -> Result { + let mut parts = value.split(' '); + let tydef = parts + .next() + .ok_or_else(|| anyhow!("Missing type definition"))?; + let name = parts.next().ok_or_else(|| anyhow!("Missing file name"))?; + let contents = parts.next(); + let contents = move || contents.ok_or_else(|| anyhow!("Missing file contents: {}", value)); + let xattrs: Result> = parts + .map(|xattr| -> Result { + let (k, v) = xattr + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("Invalid xattr: {xattr}"))?; + let mut k: Vec = k.to_owned().into(); + k.push(0); + let r = Xattr { + key: CString::from_vec_with_nul(k).unwrap(), + value: Vec::from(v.to_owned()).into(), + }; + Ok(r) + }) + .collect(); + let xattrs = xattrs?.into(); + let ty = match tydef { + "r" => FileDefType::Regular(contents()?.into()), + "l" => FileDefType::Symlink(Cow::Borrowed(contents()?.into())), + "d" => FileDefType::Directory, + _ => anyhow::bail!("Invalid filedef type: {}", value), + }; + Ok(FileDef { + uid: 0, + gid: 0, + mode: 0o644, + path: Cow::Borrowed(name.into()), + xattrs, + ty, + }) + } +} + +fn parse_mode(line: &str) -> Result<(u32, u32, u32)> { + let mut parts = line.split(' ').skip(1); + // An empty mode resets to defaults + let uid = if let Some(u) = parts.next() { + u + } else { + return Ok((0, 0, 0o644)); + }; + let gid = parts.next().ok_or_else(|| anyhow!("Missing gid"))?; + let mode = parts.next().ok_or_else(|| anyhow!("Missing mode"))?; + if parts.next().is_some() { + anyhow::bail!("Invalid mode: {}", line); + } + Ok((uid.parse()?, gid.parse()?, u32::from_str_radix(mode, 8)?)) +} + +impl FileDef { + /// Parse a list of newline-separated file definitions. + pub fn iter_from(defs: &'static str) -> impl Iterator> { + let mut uid = 0; + let mut gid = 0; + let mut mode = 0o644; + defs.lines() + .filter(|v| !(v.is_empty() || v.starts_with('#'))) + .filter_map(move |line| { + if line.starts_with('m') { + match parse_mode(line) { + Ok(r) => { + uid = r.0; + gid = r.1; + mode = r.2; + None + } + Err(e) => Some(Err(e)), + } + } else { + Some(FileDef::try_from(line).map(|mut def| { + def.uid = uid; + def.gid = gid; + def.mode = mode; + def + })) + } + }) + } + + pub fn append_tar(&self, w: &mut tar::Builder) -> Result<()> { + let mut h = tar::Header::new_ustar(); + h.set_mtime(0); + h.set_uid(self.uid.into()); + h.set_gid(self.gid.into()); + h.set_mode(self.mode); + match &self.ty { + FileDefType::Regular(data) => { + let data = data.as_bytes(); + h.set_entry_type(tar::EntryType::Regular); + h.set_size(data.len().try_into().unwrap()); + w.append_data(&mut h, &*self.path, std::io::Cursor::new(data))?; + } + FileDefType::Symlink(target) => { + h.set_entry_type(tar::EntryType::Symlink); + h.set_size(0); + w.append_link(&mut h, &*self.path, target.as_std_path())?; + } + FileDefType::Directory => { + h.set_entry_type(tar::EntryType::Directory); + h.set_size(0); + w.append_data(&mut h, &*self.path, std::io::empty())?; + } + } + Ok(()) + } +} + +/// This is like a package database, mapping our test fixture files to package names +static OWNERS: LazyLock> = LazyLock::new(|| { + [ + ("usr/lib/modules/.*/initramfs", "initramfs"), + ("usr/lib/modules", "kernel"), + ("usr/bin/(ba)?sh", "bash"), + ("usr/bin/arping", "arping"), + ("usr/lib.*/emptyfile.*", "bash"), + ("usr/bin/hardlink.*", "testlink"), + ("usr/etc/someconfig.conf", "someconfig"), + ("usr/etc/polkit.conf", "a-polkit-config"), + ("opt", "filesystem"), + ("usr/lib/pkgdb", "pkgdb"), + ("usr/lib/sysimage/pkgdb", "pkgdb"), + ] + .iter() + .map(|(k, v)| (Regex::new(k).unwrap(), *v)) + .collect() +}); + +pub static CONTENTS_V0: &str = indoc::indoc! { r##" +r usr/lib/modules/5.10.18-200.x86_64/vmlinuz this-is-a-kernel +r usr/lib/modules/5.10.18-200.x86_64/initramfs this-is-an-initramfs +m 0 0 755 +r usr/bin/bash the-bash-shell +l usr/bin/sh bash +r usr/bin/arping arping-binary security.capability=0sAAAAAgAgAAAAAAAAAAAAAAAAAAA= +m 0 0 644 +# Some empty files +r usr/lib/emptyfile +r usr/lib64/emptyfile2 +# Should be the same object +r usr/bin/hardlink-a testlink +r usr/bin/hardlink-b testlink +r usr/etc/someconfig.conf someconfig +m 10 10 644 +r usr/etc/polkit.conf a-polkit-config +m 0 0 644 +# See https://github.com/coreos/fedora-coreos-tracker/issues/1258 +r usr/lib/sysimage/pkgdb some-package-database +r usr/lib/pkgdb/pkgdb some-package-database +m +d boot +d run +l opt var/opt +m 0 0 1755 +d tmp +"## }; +pub const CONTENTS_CHECKSUM_V0: &str = + "bd3d13c3059e63e6f8a3d6d046923ded730d90bd7a055c9ad93312111ea7d395"; +// 1 for ostree commit, 2 for max frequency packages, 3 as empty layer +pub const LAYERS_V0_LEN: usize = 3usize; +pub const PKGS_V0_LEN: usize = 7usize; + +#[derive(Debug, PartialEq, Eq)] +enum SeLabel { + Root, + Usr, + UsrLibSystemd, + Boot, + Etc, + EtcSystemConf, +} + +impl SeLabel { + pub fn from_path(p: &Utf8Path) -> Self { + let rootdir = p.components().find_map(|v| { + if let Utf8Component::Normal(name) = v { + Some(name) + } else { + None + } + }); + let rootdir = if let Some(r) = rootdir { + r + } else { + return SeLabel::Root; + }; + if rootdir == "usr" { + if p.as_str().contains("systemd") { + SeLabel::UsrLibSystemd + } else { + SeLabel::Usr + } + } else if rootdir == "boot" { + SeLabel::Boot + } else if rootdir == "etc" { + // Arbitrarily give some files in /etc some label and others another + if p.as_str().len() % 2 == 0 { + SeLabel::Etc + } else { + SeLabel::EtcSystemConf + } + } else { + SeLabel::Usr + } + } + + pub fn to_str(&self) -> &'static str { + match self { + SeLabel::Root => "system_u:object_r:root_t:s0", + SeLabel::Usr => "system_u:object_r:usr_t:s0", + SeLabel::UsrLibSystemd => "system_u:object_r:systemd_unit_file_t:s0", + SeLabel::Boot => "system_u:object_r:boot_t:s0", + SeLabel::Etc => "system_u:object_r:etc_t:s0", + SeLabel::EtcSystemConf => "system_u:object_r:system_conf_t:s0", + } + } + + pub fn xattrs(&self) -> Vec<(&[u8], &[u8])> { + vec![( + SECURITY_SELINUX_XATTR_C.to_bytes_with_nul(), + self.to_str().as_bytes(), + )] + } +} + +/// Generate directory metadata variant for root/root 0755 directory with an optional SELinux label +pub fn create_dirmeta(path: &Utf8Path, selinux: bool) -> glib::Variant { + let finfo = gio::FileInfo::new(); + finfo.set_attribute_uint32("unix::uid", 0); + finfo.set_attribute_uint32("unix::gid", 0); + finfo.set_attribute_uint32("unix::mode", libc::S_IFDIR | 0o755); + let label = if selinux { + Some(SeLabel::from_path(path)) + } else { + None + }; + let xattrs = label.map(|v| v.xattrs().to_variant()); + ostree::create_directory_metadata(&finfo, xattrs.as_ref()) +} + +/// Wraps [`create_dirmeta`] and commits it. +#[context("Init dirmeta for {path}")] +pub fn require_dirmeta(repo: &ostree::Repo, path: &Utf8Path, selinux: bool) -> Result { + let v = create_dirmeta(path, selinux); + ostree::validate_structureof_dirmeta(&v).context("Validating dirmeta")?; + let r = repo.write_metadata( + ostree::ObjectType::DirMeta, + None, + &v, + gio::Cancellable::NONE, + )?; + Ok(r.to_hex()) +} + +fn ensure_parent_dirs( + mt: &ostree::MutableTree, + path: &Utf8Path, + metadata_checksum: &str, +) -> Result { + let parts = relative_path_components(path) + .map(|s| s.as_str()) + .collect::>(); + mt.ensure_parent_dirs(&parts, metadata_checksum) + .map_err(Into::into) +} + +fn relative_path_components(p: &Utf8Path) -> impl Iterator> { + p.components() + .filter(|p| matches!(p, Utf8Component::Normal(_))) +} + +/// Walk over the whole filesystem, and generate mappings from content object checksums +/// to the package that owns them. +/// +/// In the future, we could compute this much more efficiently by walking that +/// instead. But this design is currently oriented towards accepting a single ostree +/// commit as input. +fn build_mapping_recurse( + path: &mut Utf8PathBuf, + dir: &gio::File, + ret: &mut ObjectMeta, +) -> Result<()> { + use indexmap::map::Entry; + let cancellable = gio::Cancellable::NONE; + let e = dir.enumerate_children( + "standard::name,standard::type", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + cancellable, + )?; + for child in e { + let childi = child?; + let name: Utf8PathBuf = childi.name().try_into()?; + let child = dir.child(&name); + path.push(&name); + match childi.file_type() { + gio::FileType::Regular | gio::FileType::SymbolicLink => { + let child = child.downcast::().unwrap(); + + let owner = OWNERS + .iter() + .find_map(|(r, owner)| { + if r.is_match(path.as_str()) { + Some(Rc::from(*owner)) + } else { + None + } + }) + .ok_or_else(|| anyhow!("Unowned path {}", path))?; + + if !ret.set.contains(&*owner) { + ret.set.insert(ObjectSourceMeta { + identifier: Rc::clone(&owner), + name: Rc::clone(&owner), + srcid: Rc::clone(&owner), + change_time_offset: u32::MAX, + change_frequency: u32::MAX, + }); + } + + let checksum = child.checksum().to_string(); + match ret.map.entry(checksum) { + Entry::Vacant(v) => { + v.insert(owner); + } + Entry::Occupied(v) => { + let prev_owner = v.get(); + if **prev_owner != *owner { + anyhow::bail!( + "Duplicate object ownership {} ({} and {})", + path.as_str(), + prev_owner, + owner + ); + } + } + } + } + gio::FileType::Directory => { + build_mapping_recurse(path, &child, ret)?; + } + o => anyhow::bail!("Unhandled file type: {o:?}"), + } + path.pop(); + } + Ok(()) +} + +/// Thin wrapper for `ostree ls -RXC` to show the full file contents +pub fn recursive_ostree_ls_text(repo: &ostree::Repo, refspec: &str) -> Result { + let o = Command::new("ostree") + .cwd_dir(Dir::reopen_dir(&repo.dfd_borrow())?) + .args(["--repo=.", "ls", "-RXC", refspec]) + .output()?; + let st = o.status; + if !st.success() { + anyhow::bail!("ostree ls failed: {st:?}"); + } + let r = String::from_utf8(o.stdout)?; + Ok(r) +} + +pub fn assert_commits_content_equal( + a_repo: &ostree::Repo, + a: &str, + b_repo: &ostree::Repo, + b: &str, +) { + let a = a_repo.require_rev(a).unwrap(); + let b = a_repo.require_rev(b).unwrap(); + let a_commit = a_repo.load_commit(&a).unwrap().0; + let b_commit = b_repo.load_commit(&b).unwrap().0; + let a_contentid = ostree::commit_get_content_checksum(&a_commit).unwrap(); + let b_contentid = ostree::commit_get_content_checksum(&b_commit).unwrap(); + if a_contentid == b_contentid { + return; + } + let a_contents = recursive_ostree_ls_text(a_repo, &a).unwrap(); + let b_contents = recursive_ostree_ls_text(b_repo, &b).unwrap(); + similar_asserts::assert_eq!(a_contents, b_contents); + panic!("Should not be reached; had different content hashes but same recursive ls") +} + +fn ls_recurse( + repo: &ostree::Repo, + path: &mut Utf8PathBuf, + buf: &mut String, + dt: &glib::Variant, +) -> Result<()> { + let dt = dt.data_as_bytes(); + let dt = dt.try_as_aligned()?; + let dt = gv_dirtree!().cast(dt); + let (files, dirs) = dt.to_tuple(); + // A reusable buffer to avoid heap allocating these + let mut hexbuf = [0u8; 64]; + for file in files { + let (name, csum) = file.to_tuple(); + path.push(name.to_str()); + hex::encode_to_slice(csum, &mut hexbuf)?; + let checksum = std::str::from_utf8(&hexbuf)?; + let meta = repo.query_file(checksum, gio::Cancellable::NONE)?.0; + let size = meta.size() as u64; + writeln!(buf, "r {path} {size}").unwrap(); + assert!(path.pop()); + } + for item in dirs { + let (name, contents_csum, _) = item.to_tuple(); + let name = name.to_str(); + // Extend our current path + path.push(name); + hex::encode_to_slice(contents_csum, &mut hexbuf)?; + let checksum_s = std::str::from_utf8(&hexbuf)?; + let child_v = repo.load_variant(ostree::ObjectType::DirTree, checksum_s)?; + ls_recurse(repo, path, buf, &child_v)?; + // We did a push above, so pop must succeed. + assert!(path.pop()); + } + Ok(()) +} + +pub fn ostree_ls(repo: &ostree::Repo, r: &str) -> Result { + let root = repo.read_commit(r, gio::Cancellable::NONE).unwrap().0; + // SAFETY: Must be a repofile + let root = root.downcast_ref::().unwrap(); + // SAFETY: must be a tree root + let root_contents = root.tree_get_contents_checksum().unwrap(); + let root_contents = repo + .load_variant(ostree::ObjectType::DirTree, &root_contents) + .unwrap(); + + let mut contents_buf = String::new(); + let mut pathbuf = Utf8PathBuf::from("/"); + ls_recurse(repo, &mut pathbuf, &mut contents_buf, &root_contents)?; + Ok(contents_buf) +} + +/// Verify the filenames (but not metadata) are the same between two commits. +/// We unfortunately need to do this because the current commit merge path +/// sets ownership of directories to the current user, which breaks in unit tests. +pub fn assert_commits_filenames_equal( + a_repo: &ostree::Repo, + a: &str, + b_repo: &ostree::Repo, + b: &str, +) { + let a_contents_buf = ostree_ls(a_repo, a).unwrap(); + let b_contents_buf = ostree_ls(b_repo, b).unwrap(); + similar_asserts::assert_eq!(a_contents_buf, b_contents_buf); +} + +fn clear_ostree_repo(repo: &ostree::Repo) -> Result<()> { + for (r, _) in repo.list_refs(None, gio::Cancellable::NONE)? { + repo.set_ref_immediate(None, &r, None, gio::Cancellable::NONE)?; + } + repo.prune(ostree::RepoPruneFlags::REFS_ONLY, 0, gio::Cancellable::NONE)?; + Ok(()) +} + +#[derive(Debug)] +pub struct Fixture { + // Just holds a reference + tempdir: tempfile::TempDir, + pub dir: Arc

, + pub path: Utf8PathBuf, + srcrepo: ostree::Repo, + destrepo: ostree::Repo, + + pub selinux: bool, + pub bootable: bool, +} + +impl Fixture { + #[context("Initializing fixture")] + pub fn new_base() -> Result { + // Basic setup, allocate a tempdir + let tempdir = tempfile::tempdir_in("/var/tmp")?; + let dir = Arc::new(cap_std::fs::Dir::open_ambient_dir( + tempdir.path(), + cap_std::ambient_authority(), + )?); + let path: &Utf8Path = tempdir.path().try_into().unwrap(); + let path = path.to_path_buf(); + + // Create the src/ directory + dir.create_dir("src")?; + let srcdir_dfd = &dir.open_dir("src")?; + + // Initialize the src/gpghome/ directory + let gpgtarname = "gpghome.tgz"; + srcdir_dfd.write(gpgtarname, OSTREE_GPG_HOME)?; + let gpgtar = srcdir_dfd.open(gpgtarname)?; + srcdir_dfd.remove_file(gpgtarname)?; + srcdir_dfd.create_dir("gpghome")?; + let gpghome = srcdir_dfd.open_dir("gpghome")?; + let st = std::process::Command::new("tar") + .cwd_dir(gpghome) + .stdin(Stdio::from(gpgtar)) + .args(["-azxf", "-"]) + .status()?; + assert!(st.success()); + + let srcrepo = ostree::Repo::create_at_dir( + srcdir_dfd.as_fd(), + "repo", + ostree::RepoMode::Archive, + None, + ) + .context("Creating src/ repo")?; + + dir.create_dir("dest")?; + let destrepo = ostree::Repo::create_at_dir( + dir.as_fd(), + "dest/repo", + ostree::RepoMode::BareUser, + None, + )?; + Ok(Self { + tempdir, + dir, + path, + srcrepo, + destrepo, + selinux: true, + bootable: true, + }) + } + + pub fn srcrepo(&self) -> &ostree::Repo { + &self.srcrepo + } + + pub fn destrepo(&self) -> &ostree::Repo { + &self.destrepo + } + + pub fn new_shell(&self) -> Result { + let sh = xshell::Shell::new()?; + sh.change_dir(&self.path); + Ok(sh) + } + + /// Given the input image reference, import it into destrepo using the default + /// import config. The image must not exist already in the store. + pub async fn must_import(&self, imgref: &ImageReference) -> Result> { + let ostree_imgref = crate::container::OstreeImageReference { + sigverify: crate::container::SignatureSource::ContainerPolicyAllowInsecure, + imgref: imgref.clone(), + }; + let mut imp = + store::ImageImporter::new(self.destrepo(), &ostree_imgref, Default::default()) + .await + .unwrap(); + assert!(store::query_image(self.destrepo(), &imgref) + .unwrap() + .is_none()); + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + imp.import(prep).await + } + + // Delete all objects in the destrepo + pub fn clear_destrepo(&self) -> Result<()> { + clear_ostree_repo(self.destrepo()) + } + + #[context("Writing filedef {}", def.path.as_str())] + pub fn write_filedef(&self, root: &ostree::MutableTree, def: &FileDef) -> Result<()> { + let parent_path = def.path.parent(); + let parent = if let Some(parent_path) = parent_path { + let meta = require_dirmeta(&self.srcrepo, parent_path, self.selinux)?; + Some(ensure_parent_dirs(root, &def.path, meta.as_str())?) + } else { + None + }; + let parent = parent.as_ref().unwrap_or(root); + let name = def.path.file_name().expect("file name"); + let label = if self.selinux { + Some(SeLabel::from_path(&def.path)) + } else { + None + }; + let mut xattrs = label.as_ref().map(|v| v.xattrs()).unwrap_or_default(); + xattrs.extend( + def.xattrs + .iter() + .map(|xattr| (xattr.key.as_bytes_with_nul(), &xattr.value[..])), + ); + let xattrs = if xattrs.is_empty() { + None + } else { + xattrs.sort_by(|a, b| a.0.cmp(b.0)); + Some(xattrs.to_variant()) + }; + let xattrs = xattrs.as_ref(); + let checksum = match &def.ty { + FileDefType::Regular(contents) => self + .srcrepo + .write_regfile_inline( + None, + 0, + 0, + libc::S_IFREG | def.mode, + xattrs, + contents.as_bytes(), + gio::Cancellable::NONE, + ) + .context("Writing regfile inline")?, + FileDefType::Symlink(target) => self.srcrepo.write_symlink( + None, + def.uid, + def.gid, + xattrs, + target.as_str(), + gio::Cancellable::NONE, + )?, + FileDefType::Directory => { + let d = parent.ensure_dir(name)?; + let meta = require_dirmeta(&self.srcrepo, &def.path, self.selinux)?; + d.set_metadata_checksum(meta.as_str()); + return Ok(()); + } + }; + parent + .replace_file(name, checksum.as_str()) + .context("Setting file")?; + Ok(()) + } + + pub fn commit_filedefs(&self, defs: impl IntoIterator>) -> Result<()> { + let root = ostree::MutableTree::new(); + let cancellable = gio::Cancellable::NONE; + let tx = self.srcrepo.auto_transaction(cancellable)?; + for def in defs { + let def = def?; + self.write_filedef(&root, &def)?; + } + let root = self.srcrepo.write_mtree(&root, cancellable)?; + let root = root.downcast_ref::().unwrap(); + // You win internet points if you understand this date reference + let ts = chrono::DateTime::parse_from_rfc2822("Fri, 29 Aug 1997 10:30:42 PST")?.timestamp(); + // Some default metadata fixtures + let metadata = glib::VariantDict::new(None); + metadata.insert( + "buildsys.checksum", + &"41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3", + ); + metadata.insert("ostree.container-cmd", &vec!["/usr/bin/bash"]); + metadata.insert("version", &"42.0"); + #[allow(clippy::explicit_auto_deref)] + if self.bootable { + metadata.insert(ostree::METADATA_KEY_BOOTABLE, &true); + } + let metadata = metadata.to_variant(); + let commit = self.srcrepo.write_commit_with_time( + None, + None, + None, + Some(&metadata), + root, + ts as u64, + cancellable, + )?; + self.srcrepo + .transaction_set_ref(None, self.testref(), Some(commit.as_str())); + tx.commit(cancellable)?; + + // Add detached metadata so we can verify it makes it through + let detached = glib::VariantDict::new(None); + detached.insert("my-detached-key", &"my-detached-value"); + let detached = detached.to_variant(); + self.srcrepo.write_commit_detached_metadata( + commit.as_str(), + Some(&detached), + gio::Cancellable::NONE, + )?; + + let gpghome = self.path.join("src/gpghome"); + self.srcrepo.sign_commit( + &commit, + TEST_GPG_KEYID_1, + Some(gpghome.as_str()), + gio::Cancellable::NONE, + )?; + + // Verify that this is what is expected. + let commit_object = self.srcrepo.load_commit(&commit)?.0; + let content_checksum = ostree::commit_get_content_checksum(&commit_object).unwrap(); + if content_checksum != CONTENTS_CHECKSUM_V0 { + // Only spew this once + static DUMP_OSTREE: std::sync::Once = std::sync::Once::new(); + DUMP_OSTREE.call_once(|| { + let _ = Command::new("ostree") + .arg(format!("--repo={}", self.path.join("src/repo"))) + .args(["ls", "-X", "-C", "-R", commit.as_str()]) + .run_capture_stderr(); + }); + } + assert_eq!(CONTENTS_CHECKSUM_V0, content_checksum.as_str()); + + Ok(()) + } + + pub fn new_v1() -> Result { + let r = Self::new_base()?; + r.commit_filedefs(FileDef::iter_from(CONTENTS_V0))?; + Ok(r) + } + + pub fn testref(&self) -> &'static str { + TESTREF + } + + #[context("Updating test repo")] + pub fn update( + &mut self, + additions: impl Iterator>, + removals: impl Iterator>, + ) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + + // Load our base commit + let rev = &self.srcrepo().require_rev(self.testref())?; + let (commit, _) = self.srcrepo.load_commit(rev)?; + let metadata = commit.child_value(0); + let root = ostree::MutableTree::from_commit(self.srcrepo(), rev)?; + // Bump the commit timestamp by one day + let ts = chrono::Utc + .timestamp_opt(ostree::commit_get_timestamp(&commit) as i64, 0) + .single() + .unwrap(); + let new_ts = ts + .add(chrono::TimeDelta::try_days(1).expect("one day does not overflow")) + .timestamp() as u64; + + // Prepare a transaction + let tx = self.srcrepo.auto_transaction(cancellable)?; + for def in additions { + let def = def?; + self.write_filedef(&root, &def)?; + } + for removal in removals { + let filename = removal + .file_name() + .ok_or_else(|| anyhow!("Invalid path {}", removal))?; + // Notice that we're traversing the whole path, because that's how the walk() API works. + let p = relative_path_components(&removal); + let parts = p.map(|s| s.as_str()).collect::>(); + let parent = &root.walk(&parts, 0)?; + parent.remove(filename, false)?; + self.srcrepo.write_mtree(parent, cancellable)?; + } + let root = self + .srcrepo + .write_mtree(&root, cancellable) + .context("Writing mtree")?; + let root = root.downcast_ref::().unwrap(); + let commit = self + .srcrepo + .write_commit_with_time( + Some(rev), + None, + None, + Some(&metadata), + root, + new_ts, + cancellable, + ) + .context("Writing commit")?; + self.srcrepo + .transaction_set_ref(None, self.testref(), Some(commit.as_str())); + tx.commit(cancellable)?; + Ok(()) + } + + /// Gather object metadata for the current commit. + pub fn get_object_meta(&self) -> Result { + let cancellable = gio::Cancellable::NONE; + + // Load our base commit + let root = self.srcrepo.read_commit(self.testref(), cancellable)?.0; + + let mut ret = ObjectMeta::default(); + build_mapping_recurse(&mut Utf8PathBuf::from("/"), &root, &mut ret)?; + + Ok(ret) + } + + /// Unload all in-memory data, and return the underlying temporary directory without deleting it. + pub fn into_tempdir(self) -> tempfile::TempDir { + self.tempdir + } + + #[context("Exporting tar")] + pub fn export_tar(&self) -> Result<&'static Utf8Path> { + let cancellable = gio::Cancellable::NONE; + let (_, rev) = self.srcrepo.read_commit(self.testref(), cancellable)?; + let path = "exampleos-export.tar"; + let mut outf = std::io::BufWriter::new(self.dir.create(path)?); + #[allow(clippy::needless_update)] + let options = crate::tar::ExportOptions { + ..Default::default() + }; + crate::tar::export_commit(&self.srcrepo, rev.as_str(), &mut outf, Some(options))?; + outf.flush()?; + Ok(path.into()) + } + + /// Export the current ref as a container image. + /// This defaults to using chunking. + #[context("Exporting container")] + pub async fn export_container(&self) -> Result<(ImageReference, oci_image::Digest)> { + let name = "oci-v1"; + let container_path = &self.path.join(name); + if container_path.exists() { + std::fs::remove_dir_all(container_path)?; + } + let imgref = ImageReference { + transport: Transport::OciDir, + name: container_path.as_str().to_string(), + }; + let config = Config { + labels: Some( + [("foo", "bar"), ("test", "value")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ), + ..Default::default() + }; + let contentmeta = self.get_object_meta().context("Computing object meta")?; + let contentmeta = ObjectMetaSized::compute_sizes(self.srcrepo(), contentmeta) + .context("Computing sizes")?; + let opts = ExportOpts { + max_layers: std::num::NonZeroU32::new(PKGS_V0_LEN as u32), + package_contentmeta: Some(&contentmeta), + ..Default::default() + }; + let digest = crate::container::encapsulate( + self.srcrepo(), + self.testref(), + &config, + Some(opts), + &imgref, + ) + .await + .context("exporting")?; + Ok((imgref, digest)) + } + + // Generate a directory with some test contents + #[context("Generating temp content")] + pub fn generate_test_derived_oci( + &self, + derived_path: impl AsRef, + tag: Option<&str>, + ) -> Result<()> { + let temproot = TempDir::new_in(&self.path)?; + let temprootd = Dir::open_ambient_dir(&temproot, cap_std::ambient_authority())?; + let mut db = DirBuilder::new(); + db.mode(0o755); + db.recursive(true); + temprootd.create_dir_with("usr/bin", &db)?; + temprootd.write("usr/bin/newderivedfile", "newderivedfile v0")?; + temprootd.write("usr/bin/newderivedfile3", "newderivedfile3 v0")?; + temprootd.create_dir_with("run", &db)?; + temprootd.write("run/filtered", "data")?; + crate::integrationtest::generate_derived_oci(derived_path, temproot, tag)?; + Ok(()) + } +} + +#[derive(Debug)] +pub struct NonOstreeFixture { + // Just holds a reference + _tempdir: tempfile::TempDir, + pub dir: Arc, + pub path: Utf8PathBuf, + pub src_oci: ocidir::OciDir, + destrepo: ostree::Repo, + + pub bootable: bool, +} + +impl NonOstreeFixture { + const SRCOCI: &'static str = "src/oci"; + + #[context("Initializing fixture")] + pub fn new_base() -> Result { + // Basic setup, allocate a tempdir + let tempdir = tempfile::tempdir_in("/var/tmp")?; + let dir = Arc::new(cap_std::fs::Dir::open_ambient_dir( + tempdir.path(), + cap_std::ambient_authority(), + )?); + let path: &Utf8Path = tempdir.path().try_into().unwrap(); + let path = path.to_path_buf(); + + // Create the src/ directory + dir.create_dir_all(Self::SRCOCI)?; + let src_oci = dir.open_dir(Self::SRCOCI)?; + let src_oci = ocidir::OciDir::ensure(src_oci)?; + + dir.create_dir("dest")?; + let destrepo = ostree::Repo::create_at_dir( + dir.as_fd(), + "dest/repo", + ostree::RepoMode::BareUser, + None, + )?; + Ok(Self { + _tempdir: tempdir, + dir, + path, + src_oci, + destrepo, + bootable: true, + }) + } + + pub fn destrepo(&self) -> &ostree::Repo { + &self.destrepo + } + + #[context("Exporting container")] + pub async fn export_container(&self) -> Result<(ImageReference, oci_image::Digest)> { + let imgref = ImageReference { + transport: Transport::OciDir, + name: self.path.join(Self::SRCOCI).to_string(), + }; + + let mut config = ImageConfigurationBuilder::default().build().unwrap(); + let mut manifest = self.src_oci.new_empty_manifest()?.build().unwrap(); + + let bw = self.src_oci.create_gzip_layer(None)?; + let mut bw = tar::Builder::new(bw); + for def in FileDef::iter_from(CONTENTS_V0) { + let def = def.unwrap(); + def.append_tar(&mut bw)?; + } + let bw = bw.into_inner()?; + let new_layer = bw.complete()?; + + let created = config + .created() + .as_deref() + .and_then(bootc_utils::try_deserialize_timestamp) + .unwrap_or_default(); + + self.src_oci.push_layer_full( + &mut manifest, + &mut config, + new_layer, + None::>, + "root", + created, + ); + let config = self.src_oci.write_config(config)?; + + manifest.set_config(config); + self.src_oci + .replace_with_single_manifest(manifest, oci_image::Platform::default())?; + let idx = self.src_oci.read_index()?; + let descriptor = idx.manifests().first().unwrap(); + + Ok((imgref, descriptor.digest().to_owned())) + } + + /// Given the input image reference, import it into destrepo using the default + /// import config. The image must not exist already in the store. + pub async fn must_import(&self, imgref: &ImageReference) -> Result> { + let ostree_imgref = crate::container::OstreeImageReference { + sigverify: crate::container::SignatureSource::ContainerPolicyAllowInsecure, + imgref: imgref.clone(), + }; + let mut imp = + store::ImageImporter::new(self.destrepo(), &ostree_imgref, Default::default()) + .await + .unwrap(); + assert!(store::query_image(self.destrepo(), &imgref) + .unwrap() + .is_none()); + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + imp.import(prep).await + } +} diff --git a/crates/ostree-ext/src/fixtures/fedora-coreos-contentmeta.json.gz b/crates/ostree-ext/src/fixtures/fedora-coreos-contentmeta.json.gz new file mode 100644 index 000000000..285d587a7 Binary files /dev/null and b/crates/ostree-ext/src/fixtures/fedora-coreos-contentmeta.json.gz differ diff --git a/crates/ostree-ext/src/fixtures/ostree-gpg-test-home.tar.gz b/crates/ostree-ext/src/fixtures/ostree-gpg-test-home.tar.gz new file mode 100644 index 000000000..1160f474f Binary files /dev/null and b/crates/ostree-ext/src/fixtures/ostree-gpg-test-home.tar.gz differ diff --git a/crates/ostree-ext/src/fsverity.rs b/crates/ostree-ext/src/fsverity.rs new file mode 100644 index 000000000..87a4b6d3b --- /dev/null +++ b/crates/ostree-ext/src/fsverity.rs @@ -0,0 +1,135 @@ +//! Integration with fsverity + +use std::os::fd::AsFd; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use composefs::fsverity as composefs_fsverity; +use composefs_fsverity::Sha256HashValue; +use ostree::gio; + +use crate::keyfileext::KeyFileExt; +use crate::ostree_prepareroot::Tristate; + +/// The relative path to the repository config file. +const CONFIG_PATH: &str = "config"; + +/// The ostree integrity config section +pub const INTEGRITY_SECTION: &str = "ex-integrity"; +/// The ostree repo config option to enable fsverity +pub const INTEGRITY_FSVERITY: &str = "fsverity"; + +/// State of fsverity in a repo +#[derive(Debug, Clone)] +pub struct RepoVerityState { + /// True if fsverity is desired to be enabled + pub desired: Tristate, + /// True if fsverity is known to be enabled on all objects + pub enabled: bool, +} + +/// Check if fsverity is fully enabled for the target repository. +pub fn is_verity_enabled(repo: &ostree::Repo) -> Result { + let desired = repo + .config() + .optional_string(INTEGRITY_SECTION, INTEGRITY_FSVERITY)? + .map(|s| Tristate::from_str(s.as_str())) + .transpose()? + .unwrap_or_default(); + let repo_dir = &Dir::reopen_dir(&repo.dfd_borrow())?; + let config = repo_dir + .open(CONFIG_PATH) + .with_context(|| format!("Opening repository {CONFIG_PATH}"))?; + // We use the flag of having fsverity set on the repository config as a flag to say that + // fsverity is fully enabled; all objects have it. + let enabled = composefs_fsverity::measure_verity::(config.as_fd()).is_ok(); + Ok(RepoVerityState { desired, enabled }) +} + +/// Enable fsverity on regular file objects in this directory. +fn enable_fsverity_in_objdir(d: &Dir) -> anyhow::Result<()> { + for ent in d.entries()? { + let ent = ent?; + if !ent.file_type()?.is_file() { + continue; + } + let name = ent.file_name(); + let Some(b"file") = Path::new(&name).extension().map(|e| e.as_bytes()) else { + continue; + }; + let f = d.open(&name)?; + let enabled = + composefs::fsverity::measure_verity_opt::(f.as_fd())?.is_some(); + if !enabled { + // NOTE: We're not using the _with_copy API here because for us it'd require + // copying all the metadata too which is mildly tedious. + // For main composefs we don't need to care about the per-file metadata + // in general which simplifies a lot. + composefs_fsverity::enable_verity_with_retry::(f.as_fd())?; + } + } + Ok(()) +} + +/// Ensure that fsverity is enabled on this repository. +/// +/// - Walk over all regular file objects and ensure that fsverity is enabled on them +/// - Update the repo config if necessary to ensure that future objects have it by default +/// - Update the repo config to enable fsverity on the file itself as a completion flag +pub async fn ensure_verity(repo: &ostree::Repo) -> Result<()> { + let state = is_verity_enabled(repo)?; + // If we're already enabled, then we're done. + if state.enabled { + return Ok(()); + } + + // Limit concurrency here + const MAX_CONCURRENT: usize = 3; + + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + + // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically. + let mut joinset = tokio::task::JoinSet::new(); + + // Walk over all objects + for ent in repodir.read_dir("objects")? { + // Block here if the queue is full + while joinset.len() >= MAX_CONCURRENT { + // SAFETY: We just checked the length so we know there's something pending + let _: () = joinset.join_next().await.unwrap()??; + } + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let objdir = ent.open_dir()?; + // Spawn a thread for each object directory just on general principle + // of doing multi-threading. + joinset.spawn_blocking(move || enable_fsverity_in_objdir(&objdir)); + } + + // Drain the remaining tasks. + while let Some(output) = joinset.join_next().await { + let _: () = output??; + } + + // Ensure the flag is set in the config file, which is what libostree parses. + if state.desired != Tristate::Enabled { + let config = repo.copy_config(); + config.set_boolean(INTEGRITY_SECTION, INTEGRITY_FSVERITY, true); + repo.write_config(&config)?; + repo.reload_config(gio::Cancellable::NONE)?; + } + // And finally, enable fsverity as a flag that we have successfully + // enabled fsverity on all objects. + let f = repodir.open(CONFIG_PATH)?; + match composefs_fsverity::enable_verity_raw::(f.as_fd()) { + Ok(()) => Ok(()), + Err(composefs_fsverity::EnableVerityError::AlreadyEnabled) => Ok(()), + Err(e) => Err(e.into()), + } +} diff --git a/crates/ostree-ext/src/generic_decompress.rs b/crates/ostree-ext/src/generic_decompress.rs new file mode 100644 index 000000000..df0fb05dd --- /dev/null +++ b/crates/ostree-ext/src/generic_decompress.rs @@ -0,0 +1,231 @@ +//! This module primarily contains the `Decompressor` struct which is +//! used to decompress a stream based on its OCI media type. +//! +//! It also contains the `ReadWithGetInnerMut` trait and related +//! concrete implementations thereof. These provide a means for each +//! specific decompressor to give mutable access to the inner reader. +//! +//! For example, the GzipDecompressor would give the underlying +//! compressed stream. +//! +//! We need a common way to access this stream so that we can flush +//! the data during cleanup. +//! +//! See: + +use std::io::Read; + +use crate::oci_spec::image as oci_image; + +/// The legacy MIME type returned by the skopeo/(containers/storage) code +/// when we have local uncompressed docker-formatted image. +/// TODO: change the skopeo code to shield us from this correctly +const DOCKER_TYPE_LAYER_TAR: &str = "application/vnd.docker.image.rootfs.diff.tar"; + +/// Extends the `Read` trait with another method to get mutable access to the inner reader +trait ReadWithGetInnerMut: Read + Send + 'static { + fn get_inner_mut(&mut self) -> &mut dyn Read; +} + +// TransparentDecompressor + +struct TransparentDecompressor(R); + +impl Read for TransparentDecompressor { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } +} + +impl ReadWithGetInnerMut for TransparentDecompressor { + fn get_inner_mut(&mut self) -> &mut dyn Read { + &mut self.0 + } +} + +// GzipDecompressor + +struct GzipDecompressor(flate2::bufread::GzDecoder); + +impl Read for GzipDecompressor { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } +} + +impl ReadWithGetInnerMut for GzipDecompressor { + fn get_inner_mut(&mut self) -> &mut dyn Read { + self.0.get_mut() + } +} + +// ZstdDecompressor + +struct ZstdDecompressor<'a, R: std::io::BufRead>(zstd::stream::read::Decoder<'a, R>); + +impl<'a: 'static, R: std::io::BufRead + Send + 'static> Read for ZstdDecompressor<'a, R> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } +} + +impl<'a: 'static, R: std::io::BufRead + Send + 'static> ReadWithGetInnerMut + for ZstdDecompressor<'a, R> +{ + fn get_inner_mut(&mut self) -> &mut dyn Read { + self.0.get_mut() + } +} + +pub(crate) struct Decompressor { + inner: Box, + finished: bool, +} + +impl Read for Decompressor { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.read(buf) + } +} + +impl Drop for Decompressor { + fn drop(&mut self) { + if self.finished { + return; + } + + // Ideally we should not get here; users should call + // `finish()` to clean up the stream. But in reality there's + // codepaths that can and will short-circuit error out while + // processing the stream, and the Decompressor will get + // dropped before it's finished in those cases. We'll give + // best-effort to clean things up nonetheless. If things go + // wrong, then panic, because we're in a bad state and it's + // likely that we end up with a broken pipe error or a + // deadlock. + self._finish() + .expect("Failed to flush pipe while dropping Decompressor") + } +} + +impl Decompressor { + /// Create a decompressor for this MIME type, given a stream of input. + pub(crate) fn new( + media_type: &oci_image::MediaType, + src: impl Read + Send + 'static, + ) -> anyhow::Result { + let r: Box = match media_type { + oci_image::MediaType::ImageLayerZstd => { + Box::new(ZstdDecompressor(zstd::stream::read::Decoder::new(src)?)) + } + oci_image::MediaType::ImageLayerGzip => Box::new(GzipDecompressor( + flate2::bufread::GzDecoder::new(std::io::BufReader::new(src)), + )), + oci_image::MediaType::ImageLayer => Box::new(TransparentDecompressor(src)), + oci_image::MediaType::Other(t) if t.as_str() == DOCKER_TYPE_LAYER_TAR => { + Box::new(TransparentDecompressor(src)) + } + o => anyhow::bail!("Unhandled layer type: {}", o), + }; + Ok(Self { + inner: r, + finished: false, + }) + } + + pub(crate) fn finish(mut self) -> anyhow::Result<()> { + self._finish() + } + + fn _finish(&mut self) -> anyhow::Result<()> { + self.finished = true; + + // We need to make sure to flush out the decompressor and/or + // tar stream here. For tar, we might not read through the + // entire stream, because the archive has zero-block-markers + // at the end; or possibly because the final entry is filtered + // in filter_tar so we don't advance to read the data. For + // decompressor, zstd:chunked layers will have + // metadata/skippable frames at the end of the stream. That + // data isn't relevant to the tar stream, but if we don't read + // it here then on the skopeo proxy we'll block trying to + // write the end of the stream. That in turn will block our + // client end trying to call FinishPipe, and we end up + // deadlocking ourselves through skopeo. + // + // https://github.com/bootc-dev/bootc/issues/1204 + + let mut sink = std::io::sink(); + let n = std::io::copy(self.inner.get_inner_mut(), &mut sink)?; + + if n > 0 { + tracing::debug!("Read extra {n} bytes at end of decompressor stream"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct BrokenPipe; + + impl Read for BrokenPipe { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + std::io::Result::Err(std::io::ErrorKind::BrokenPipe.into()) + } + } + + #[test] + #[should_panic(expected = "Failed to flush pipe while dropping Decompressor")] + fn test_drop_decompressor_with_finish_error_should_panic() { + let broken = BrokenPipe; + let d = Decompressor::new(&oci_image::MediaType::ImageLayer, broken).unwrap(); + drop(d) + } + + #[test] + fn test_drop_decompressor_with_successful_finish() { + let empty = std::io::empty(); + let d = Decompressor::new(&oci_image::MediaType::ImageLayer, empty).unwrap(); + drop(d) + } + + #[test] + fn test_drop_decompressor_with_incomplete_gzip_data() { + let empty = std::io::empty(); + let d = Decompressor::new(&oci_image::MediaType::ImageLayerGzip, empty).unwrap(); + drop(d) + } + + #[test] + fn test_drop_decompressor_with_incomplete_zstd_data() { + let empty = std::io::empty(); + let d = Decompressor::new(&oci_image::MediaType::ImageLayerZstd, empty).unwrap(); + drop(d) + } + + #[test] + fn test_gzip_decompressor_with_garbage_input() { + let garbage = b"This is not valid gzip data"; + let mut d = Decompressor::new(&oci_image::MediaType::ImageLayerGzip, &garbage[..]).unwrap(); + let mut buf = [0u8; 32]; + let e = d.read(&mut buf).unwrap_err(); + assert!(matches!(e.kind(), std::io::ErrorKind::InvalidInput)); + assert_eq!(e.to_string(), "invalid gzip header".to_string()); + drop(d) + } + + #[test] + fn test_zstd_decompressor_with_garbage_input() { + let garbage = b"This is not valid zstd data"; + let mut d = Decompressor::new(&oci_image::MediaType::ImageLayerZstd, &garbage[..]).unwrap(); + let mut buf = [0u8; 32]; + let e = d.read(&mut buf).unwrap_err(); + assert!(matches!(e.kind(), std::io::ErrorKind::Other)); + assert_eq!(e.to_string(), "Unknown frame descriptor".to_string()); + drop(d) + } +} diff --git a/crates/ostree-ext/src/globals.rs b/crates/ostree-ext/src/globals.rs new file mode 100644 index 000000000..8f3377022 --- /dev/null +++ b/crates/ostree-ext/src/globals.rs @@ -0,0 +1,196 @@ +//! Module containing access to global state. + +use super::Result; +use anyhow::Context; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::RootDir; +use fn_error_context::context; +use ostree::glib; +use std::fs::File; +use std::sync::OnceLock; + +struct ConfigPaths { + persistent: Utf8PathBuf, + runtime: Utf8PathBuf, + system: Option, +} + +/// Get the runtime and persistent config directories. In the system (root) case, these +/// system(root) case: /run/ostree /etc/ostree /usr/lib/ostree +/// user(nonroot) case: /run/user/$uid/ostree ~/.config/ostree +fn get_config_paths(root: bool) -> &'static ConfigPaths { + if root { + static PATHS_ROOT: OnceLock = OnceLock::new(); + PATHS_ROOT.get_or_init(|| ConfigPaths::new("etc", "run", Some("usr/lib"))) + } else { + static PATHS_USER: OnceLock = OnceLock::new(); + PATHS_USER.get_or_init(|| { + ConfigPaths::new( + Utf8PathBuf::try_from(glib::user_config_dir()).unwrap(), + Utf8PathBuf::try_from(glib::user_runtime_dir()).unwrap(), + None, + ) + }) + } +} + +impl ConfigPaths { + fn new>(persistent: P, runtime: P, system: Option

) -> Self { + fn relative_owned(p: &Utf8Path) -> Utf8PathBuf { + p.as_str().trim_start_matches('/').into() + } + let mut r = ConfigPaths { + persistent: relative_owned(persistent.as_ref()), + runtime: relative_owned(runtime.as_ref()), + system: system.as_ref().map(|s| relative_owned(s.as_ref())), + }; + let path = "ostree"; + r.persistent.push(path); + r.runtime.push(path); + if let Some(system) = r.system.as_mut() { + system.push(path); + } + r + } + + /// Return the path and an open fd for a config file, if it exists. + pub(crate) fn open_file( + &self, + root: &RootDir, + p: impl AsRef, + ) -> Result> { + let p = p.as_ref(); + let mut runtime = self.runtime.clone(); + runtime.push(p); + if let Some(f) = root + .open_optional(&runtime) + .context("Opening runtime auth file")? + { + return Ok(Some((runtime, f))); + } + let mut persistent = self.persistent.clone(); + persistent.push(p); + if let Some(f) = root + .open_optional(&persistent) + .context("Opening persistent auth file")? + { + return Ok(Some((persistent, f))); + } + if let Some(mut system) = self.system.clone() { + system.push(p); + if let Some(f) = root + .open_optional(&system) + .context("Opening system auth file")? + { + return Ok(Some((system, f))); + } + } + Ok(None) + } +} + +/// Return the path to the global container authentication file, if it exists. +#[context("Loading global authfile")] +pub fn get_global_authfile(root: &Dir) -> Result> { + let root = &RootDir::new(root, ".").context("Opening RootDir")?; + let am_uid0 = rustix::process::getuid() == rustix::process::Uid::ROOT; + get_global_authfile_impl(root, am_uid0) +} + +/// Return the path to the global container authentication file, if it exists. +fn get_global_authfile_impl(root: &RootDir, am_uid0: bool) -> Result> { + let paths = get_config_paths(am_uid0); + paths.open_file(root, "auth.json") +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use super::*; + use camino::Utf8PathBuf; + use cap_std_ext::{cap_std, cap_tempfile}; + + fn read_authfile( + root: &cap_std_ext::RootDir, + am_uid0: bool, + ) -> Result> { + let r = get_global_authfile_impl(root, am_uid0)?; + if let Some((path, mut f)) = r { + let mut s = String::new(); + f.read_to_string(&mut s)?; + Ok(Some((path.try_into()?, s))) + } else { + Ok(None) + } + } + + #[test] + fn test_config_paths() -> Result<()> { + let root = &cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let rootdir = &RootDir::new(root, ".")?; + assert!(read_authfile(rootdir, true).unwrap().is_none()); + root.create_dir_all("etc/ostree")?; + root.write("etc/ostree/auth.json", "etc ostree auth")?; + let (p, authdata) = read_authfile(rootdir, true).unwrap().unwrap(); + assert_eq!(p, "etc/ostree/auth.json"); + assert_eq!(authdata, "etc ostree auth"); + root.create_dir_all("usr/lib/ostree")?; + root.write("usr/lib/ostree/auth.json", "usrlib ostree auth")?; + // We should see /etc content still + let (p, authdata) = read_authfile(rootdir, true).unwrap().unwrap(); + assert_eq!(p, "etc/ostree/auth.json"); + assert_eq!(authdata, "etc ostree auth"); + // Now remove the /etc content, unveiling the /usr content + root.remove_file("etc/ostree/auth.json")?; + let (p, authdata) = read_authfile(rootdir, true).unwrap().unwrap(); + assert_eq!(p, "usr/lib/ostree/auth.json"); + assert_eq!(authdata, "usrlib ostree auth"); + + // Verify symlinks work, both relative... + root.create_dir_all("etc/containers")?; + root.write("etc/containers/auth.json", "etc containers ostree auth")?; + root.symlink_contents("../containers/auth.json", "etc/ostree/auth.json")?; + let (p, authdata) = read_authfile(rootdir, true).unwrap().unwrap(); + assert_eq!(p, "etc/ostree/auth.json"); + assert_eq!(authdata, "etc containers ostree auth"); + // And an absolute link + root.remove_file("etc/ostree/auth.json")?; + root.symlink_contents("/etc/containers/auth.json", "etc/ostree/auth.json")?; + assert_eq!(p, "etc/ostree/auth.json"); + assert_eq!(authdata, "etc containers ostree auth"); + + // Non-root + let mut user_runtime_dir = + Utf8Path::from_path(glib::user_runtime_dir().strip_prefix("/").unwrap()) + .unwrap() + .to_path_buf(); + user_runtime_dir.push("ostree"); + root.create_dir_all(&user_runtime_dir)?; + user_runtime_dir.push("auth.json"); + root.write(&user_runtime_dir, "usr_runtime_dir ostree auth")?; + + let mut user_config_dir = + Utf8Path::from_path(glib::user_config_dir().strip_prefix("/").unwrap()) + .unwrap() + .to_path_buf(); + user_config_dir.push("ostree"); + root.create_dir_all(&user_config_dir)?; + user_config_dir.push("auth.json"); + root.write(&user_config_dir, "usr_config_dir ostree auth")?; + + // We should see runtime_dir content still + let (p, authdata) = read_authfile(rootdir, false).unwrap().unwrap(); + assert_eq!(p, user_runtime_dir); + assert_eq!(authdata, "usr_runtime_dir ostree auth"); + + // Now remove the runtime_dir content, unveiling the config_dir content + root.remove_file(&user_runtime_dir)?; + let (p, authdata) = read_authfile(rootdir, false).unwrap().unwrap(); + assert_eq!(p, user_config_dir); + assert_eq!(authdata, "usr_config_dir ostree auth"); + + Ok(()) + } +} diff --git a/crates/ostree-ext/src/ima.rs b/crates/ostree-ext/src/ima.rs new file mode 100644 index 000000000..a40341c84 --- /dev/null +++ b/crates/ostree-ext/src/ima.rs @@ -0,0 +1,286 @@ +//! Write IMA signatures to an ostree commit + +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::objgv::*; +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use fn_error_context::context; +use gio::glib; +use gio::prelude::*; +use glib::Variant; +use gvariant::aligned_bytes::TryAsAligned; +use gvariant::{gv, Marker, Structure}; +use ostree::gio; +use rustix::fd::BorrowedFd; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::CString; +use std::fs::File; +use std::io::Seek; +use std::os::unix::io::AsRawFd; +use std::process::{Command, Stdio}; + +/// Extended attribute keys used for IMA. +const IMA_XATTR: &str = "security.ima"; + +/// Attributes to configure IMA signatures. +#[derive(Debug, Clone)] +pub struct ImaOpts { + /// Digest algorithm + pub algorithm: String, + + /// Path to IMA key + pub key: Utf8PathBuf, + + /// Replace any existing IMA signatures. + pub overwrite: bool, +} + +/// Convert a GVariant of type `a(ayay)` to a mutable map +fn xattrs_to_map(v: &glib::Variant) -> BTreeMap, Vec> { + let v = v.data_as_bytes(); + let v = v.try_as_aligned().unwrap(); + let v = gv!("a(ayay)").cast(v); + let mut map: BTreeMap, Vec> = BTreeMap::new(); + for e in v.iter() { + let (k, v) = e.to_tuple(); + map.insert(k.into(), v.into()); + } + map +} + +/// Create a new GVariant of type a(ayay). This is used by OSTree's extended attributes. +pub(crate) fn new_variant_a_ayay<'a, T: 'a + AsRef<[u8]>>( + items: impl IntoIterator, +) -> glib::Variant { + let children = items.into_iter().map(|(a, b)| { + let a = a.as_ref(); + let b = b.as_ref(); + Variant::tuple_from_iter([a.to_variant(), b.to_variant()]) + }); + Variant::array_from_iter::<(&[u8], &[u8])>(children) +} + +struct CommitRewriter<'a> { + repo: &'a ostree::Repo, + ima: &'a ImaOpts, + tempdir: tempfile::TempDir, + /// Maps content object sha256 hex string to a signed object sha256 hex string + rewritten_files: HashMap, +} + +#[allow(unsafe_code)] +#[context("Gathering xattr {}", k)] +fn steal_xattr(f: &File, k: &str) -> Result> { + let k = &CString::new(k)?; + unsafe { + let k = k.as_ptr() as *const _; + let r = libc::fgetxattr(f.as_raw_fd(), k, std::ptr::null_mut(), 0); + if r < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let sz: usize = r.try_into()?; + let mut buf = vec![0u8; sz]; + let r = libc::fgetxattr(f.as_raw_fd(), k, buf.as_mut_ptr() as *mut _, sz); + if r < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let r = libc::fremovexattr(f.as_raw_fd(), k); + if r < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(buf) + } +} + +impl<'a> CommitRewriter<'a> { + fn new(repo: &'a ostree::Repo, ima: &'a ImaOpts) -> Result { + Ok(Self { + repo, + ima, + tempdir: tempfile::tempdir_in(format!("/proc/self/fd/{}/tmp", repo.dfd()))?, + rewritten_files: Default::default(), + }) + } + + /// Use `evmctl` to generate an IMA signature on a file, then + /// scrape the xattr value out of it (removing it). + /// + /// evmctl can write a separate file but it picks the name...so + /// we do this hacky dance of `--xattr-user` instead. + #[allow(unsafe_code)] + #[context("IMA signing object")] + fn ima_sign(&self, instream: &gio::InputStream) -> Result, Vec>> { + let mut tempf = tempfile::NamedTempFile::new_in(self.tempdir.path())?; + // If we're operating on a bare repo, we can clone the file (copy_file_range) directly. + if let Ok(instream) = instream.clone().downcast::() { + use cap_std_ext::cap_std::io_lifetimes::AsFilelike; + // View the fd as a File + let instream_fd = unsafe { BorrowedFd::borrow_raw(instream.as_raw_fd()) }; + let instream_fd = instream_fd.as_filelike_view::(); + std::io::copy(&mut (&*instream_fd), tempf.as_file_mut())?; + } else { + // If we're operating on an archive repo, then we need to uncompress + // and recompress... + let mut instream = instream.clone().into_read(); + let _n = std::io::copy(&mut instream, tempf.as_file_mut())?; + } + tempf.seek(std::io::SeekFrom::Start(0))?; + + let mut proc = Command::new("evmctl"); + proc.current_dir(self.tempdir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .args(["ima_sign", "--xattr-user", "--key", self.ima.key.as_str()]) + .args(["--hashalgo", self.ima.algorithm.as_str()]) + .arg(tempf.path().file_name().unwrap()); + let status = proc.output().context("Spawning evmctl")?; + if !status.status.success() { + return Err(anyhow::anyhow!( + "evmctl failed: {:?}\n{}", + status.status, + String::from_utf8_lossy(&status.stderr), + )); + } + let mut r = HashMap::new(); + let user_k = IMA_XATTR.replace("security.", "user."); + let v = steal_xattr(tempf.as_file(), user_k.as_str())?; + r.insert(Vec::from(IMA_XATTR.as_bytes()), v); + Ok(r) + } + + #[context("Content object {}", checksum)] + fn map_file(&mut self, checksum: &str) -> Result> { + let cancellable = gio::Cancellable::NONE; + let (instream, meta, xattrs) = self.repo.load_file(checksum, cancellable)?; + let instream = if let Some(i) = instream { + i + } else { + return Ok(None); + }; + let mut xattrs = xattrs_to_map(&xattrs); + let existing_sig = xattrs.remove(IMA_XATTR.as_bytes()); + if existing_sig.is_some() && !self.ima.overwrite { + return Ok(None); + } + + // Now inject the IMA xattr + let xattrs = { + let signed = self.ima_sign(&instream)?; + xattrs.extend(signed); + new_variant_a_ayay(&xattrs) + }; + // Now reload the input stream + let (instream, _, _) = self.repo.load_file(checksum, cancellable)?; + let instream = instream.unwrap(); + let (ostream, size) = + ostree::raw_file_to_content_stream(&instream, &meta, Some(&xattrs), cancellable)?; + let new_checksum = self + .repo + .write_content(None, &ostream, size, cancellable)? + .to_hex(); + + Ok(Some(new_checksum)) + } + + /// Write a dirtree object. + fn map_dirtree(&mut self, checksum: &str) -> Result { + let src = &self + .repo + .load_variant(ostree::ObjectType::DirTree, checksum)?; + let src = src.data_as_bytes(); + let src = src.try_as_aligned()?; + let src = gv_dirtree!().cast(src); + let (files, dirs) = src.to_tuple(); + + // A reusable buffer to avoid heap allocating these + let mut hexbuf = [0u8; 64]; + + let mut new_files = Vec::new(); + for file in files { + let (name, csum) = file.to_tuple(); + let name = name.to_str(); + hex::encode_to_slice(csum, &mut hexbuf)?; + let checksum = std::str::from_utf8(&hexbuf)?; + if let Some(mapped) = self.rewritten_files.get(checksum) { + new_files.push((name, hex::decode(mapped)?)); + } else if let Some(mapped) = self.map_file(checksum)? { + let mapped_bytes = hex::decode(&mapped)?; + self.rewritten_files.insert(checksum.into(), mapped); + new_files.push((name, mapped_bytes)); + } else { + new_files.push((name, Vec::from(csum))); + } + } + + let mut new_dirs = Vec::new(); + for item in dirs { + let (name, contents_csum, meta_csum_bytes) = item.to_tuple(); + let name = name.to_str(); + hex::encode_to_slice(contents_csum, &mut hexbuf)?; + let contents_csum = std::str::from_utf8(&hexbuf)?; + let mapped = self.map_dirtree(contents_csum)?; + let mapped = hex::decode(mapped)?; + new_dirs.push((name, mapped, meta_csum_bytes)); + } + + let new_dirtree = (new_files, new_dirs).to_variant(); + + let mapped = self + .repo + .write_metadata( + ostree::ObjectType::DirTree, + None, + &new_dirtree, + gio::Cancellable::NONE, + )? + .to_hex(); + + Ok(mapped) + } + + /// Write a commit object. + #[context("Mapping {}", rev)] + fn map_commit(&mut self, rev: &str) -> Result { + let checksum = self.repo.require_rev(rev)?; + let cancellable = gio::Cancellable::NONE; + let (commit_v, _) = self.repo.load_commit(&checksum)?; + let commit_v = &commit_v; + + let commit_bytes = commit_v.data_as_bytes(); + let commit_bytes = commit_bytes.try_as_aligned()?; + let commit = gv_commit!().cast(commit_bytes); + let commit = commit.to_tuple(); + let contents = &hex::encode(commit.6); + + let new_dt = self.map_dirtree(contents)?; + + let n_parts = 8; + let mut parts = Vec::with_capacity(n_parts); + for i in 0..n_parts { + parts.push(commit_v.child_value(i)); + } + let new_dt = hex::decode(new_dt)?; + parts[6] = new_dt.to_variant(); + let new_commit = Variant::tuple_from_iter(&parts); + + let new_commit_checksum = self + .repo + .write_metadata(ostree::ObjectType::Commit, None, &new_commit, cancellable)? + .to_hex(); + + Ok(new_commit_checksum) + } +} + +/// Given an OSTree commit and an IMA configuration, generate a new commit object with IMA signatures. +/// +/// The generated commit object will inherit all metadata from the existing commit object +/// such as version, etc. +/// +/// This function does not create an ostree transaction; it's recommended to use outside the call +/// to this function. +pub fn ima_sign(repo: &ostree::Repo, ostree_ref: &str, opts: &ImaOpts) -> Result { + let writer = &mut CommitRewriter::new(repo, opts)?; + writer.map_commit(ostree_ref) +} diff --git a/crates/ostree-ext/src/integrationtest.rs b/crates/ostree-ext/src/integrationtest.rs new file mode 100644 index 000000000..bc72271f6 --- /dev/null +++ b/crates/ostree-ext/src/integrationtest.rs @@ -0,0 +1,242 @@ +//! Module used for integration tests; should not be public. + +use std::path::Path; + +use crate::container_utils::{is_ostree_container, ostree_booted}; +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use containers_image_proxy::oci_spec; +use flate2::write::GzEncoder; +use fn_error_context::context; +use gio::prelude::*; +use oci_spec::image as oci_image; +use ocidir::{ + oci_spec::image::{Arch, Platform}, + LayerWriter, +}; +use ostree::gio; +use xshell::cmd; + +pub(crate) fn detectenv() -> Result<&'static str> { + let r = if is_ostree_container()? { + "ostree-container" + } else if ostree_booted()? { + "ostree" + } else if crate::container_utils::running_in_container() { + "container" + } else { + "none" + }; + Ok(r) +} + +/// Using `src` as a base, take append `dir` into OCI image. +/// Should only be enabled for testing. +#[context("Generating derived oci")] +pub fn generate_derived_oci( + src: impl AsRef, + dir: impl AsRef, + tag: Option<&str>, +) -> Result<()> { + generate_derived_oci_from_tar( + src, + move |w| { + let dir = dir.as_ref(); + let mut layer_tar = tar::Builder::new(w); + layer_tar.append_dir_all("./", dir)?; + layer_tar.finish()?; + Ok(()) + }, + tag, + None, + ) +} + +/// Using `src` as a base, take append `dir` into OCI image. +/// Should only be enabled for testing. +#[context("Generating derived oci")] +pub fn generate_derived_oci_from_tar( + src: impl AsRef, + f: F, + tag: Option<&str>, + arch: Option, +) -> Result<()> +where + F: for<'a> FnOnce(&mut LayerWriter<'a, GzEncoder>>) -> Result<()>, +{ + let src = src.as_ref(); + let src = Dir::open_ambient_dir(src, cap_std::ambient_authority())?; + let src = ocidir::OciDir::open(src)?; + + let idx = src.read_index()?; + let manifest_descriptor = idx + .manifests() + .first() + .ok_or(anyhow!("No manifests in index"))?; + let mut manifest: oci_image::ImageManifest = src + .read_json_blob(manifest_descriptor) + .context("Reading manifest json blob")?; + let mut config: oci_image::ImageConfiguration = src.read_json_blob(manifest.config())?; + + if let Some(arch) = arch.as_ref() { + config.set_architecture(arch.clone()); + } + + let mut bw = src.create_gzip_layer(None)?; + f(&mut bw)?; + let new_layer = bw.complete()?; + + manifest.layers_mut().push( + new_layer + .blob + .descriptor() + .media_type(oci_spec::image::MediaType::ImageLayerGzip) + .build() + .unwrap(), + ); + config.history_mut().get_or_insert_default().push( + oci_spec::image::HistoryBuilder::default() + .created_by("generate_derived_oci") + .build() + .unwrap(), + ); + config + .rootfs_mut() + .diff_ids_mut() + .push(new_layer.uncompressed_sha256.digest().to_string()); + let new_config_desc = src.write_config(config)?; + manifest.set_config(new_config_desc); + + let mut platform = Platform::default(); + if let Some(arch) = arch.as_ref() { + platform.set_architecture(arch.clone()); + } + + if let Some(tag) = tag { + src.insert_manifest(manifest, Some(tag), platform)?; + } else { + src.replace_with_single_manifest(manifest, platform)?; + } + Ok(()) +} + +fn test_proxy_auth() -> Result<()> { + use containers_image_proxy::ImageProxyConfig; + let merge = crate::container::merge_default_container_proxy_opts; + let mut c = ImageProxyConfig::default(); + merge(&mut c)?; + assert_eq!(c.authfile, None); + std::fs::create_dir_all("/etc/ostree")?; + let authpath = Path::new("/etc/ostree/auth.json"); + std::fs::write(authpath, "{}")?; + let mut c = ImageProxyConfig::default(); + merge(&mut c)?; + if rustix::process::getuid().is_root() { + assert!(c.auth_data.is_some()); + } else { + assert_eq!(c.authfile.unwrap().as_path(), authpath,); + } + let c = ImageProxyConfig { + auth_anonymous: true, + ..Default::default() + }; + assert_eq!(c.authfile, None); + std::fs::remove_file(authpath)?; + let mut c = ImageProxyConfig::default(); + merge(&mut c)?; + assert_eq!(c.authfile, None); + Ok(()) +} + +/// Create a test fixture in the same way our unit tests does, and print +/// the location of the temporary directory. Also export a chunked image. +/// Useful for debugging things interactively. +pub(crate) async fn create_fixture() -> Result<()> { + let fixture = crate::fixture::Fixture::new_v1()?; + let imgref = fixture.export_container().await?.0; + println!("Wrote: {imgref:?}"); + let path = fixture.into_tempdir().keep(); + println!("Wrote: {path:?}"); + Ok(()) +} + +pub(crate) fn test_ima() -> Result<()> { + use gvariant::aligned_bytes::TryAsAligned; + use gvariant::{gv, Marker, Structure}; + + let cancellable = gio::Cancellable::NONE; + let fixture = crate::fixture::Fixture::new_v1()?; + + let config = indoc::indoc! { r#" + [ req ] + default_bits = 3048 + distinguished_name = req_distinguished_name + prompt = no + string_mask = utf8only + x509_extensions = myexts + [ req_distinguished_name ] + O = Test + CN = Test key + emailAddress = example@example.com + [ myexts ] + basicConstraints=critical,CA:FALSE + keyUsage=digitalSignature + subjectKeyIdentifier=hash + authorityKeyIdentifier=keyid + "#}; + std::fs::write(fixture.path.join("genkey.config"), config)?; + let sh = xshell::Shell::new()?; + sh.change_dir(&fixture.path); + cmd!( + sh, + "openssl req -new -nodes -utf8 -sha256 -days 36500 -batch -x509 -config genkey.config -outform DER -out ima.der -keyout privkey_ima.pem" + ) + .ignore_stderr() + .ignore_stdout() + .run()?; + + let imaopts = crate::ima::ImaOpts { + algorithm: "sha256".into(), + key: fixture.path.join("privkey_ima.pem"), + overwrite: false, + }; + let rewritten_commit = + crate::ima::ima_sign(fixture.srcrepo(), fixture.testref(), &imaopts).unwrap(); + + let root = fixture + .srcrepo() + .read_commit(&rewritten_commit, cancellable)? + .0; + let bash = root.resolve_relative_path("/usr/bin/bash"); + let bash = bash.downcast_ref::().unwrap(); + let xattrs = bash.xattrs(cancellable).unwrap(); + let v = xattrs.data_as_bytes(); + let v = v.try_as_aligned().unwrap(); + let v = gv!("a(ayay)").cast(v); + let mut found_ima = false; + for xattr in v.iter() { + let k = xattr.to_tuple().0; + if k != b"security.ima" { + continue; + } + found_ima = true; + break; + } + if !found_ima { + anyhow::bail!("Failed to find IMA xattr"); + } + println!("ok IMA"); + Ok(()) +} + +#[cfg(feature = "internal-testing-api")] +#[context("Running integration tests")] +pub(crate) fn run_tests() -> Result<()> { + crate::container_utils::require_ostree_container()?; + // When there's a new integration test to run, add it here. + test_proxy_auth()?; + println!("integration tests succeeded."); + Ok(()) +} diff --git a/crates/ostree-ext/src/isolation.rs b/crates/ostree-ext/src/isolation.rs new file mode 100644 index 000000000..d96cbfedd --- /dev/null +++ b/crates/ostree-ext/src/isolation.rs @@ -0,0 +1,47 @@ +use std::process::Command; +use std::sync::OnceLock; + +pub(crate) const DEFAULT_UNPRIVILEGED_USER: &str = "nobody"; + +/// Checks if the current process is (apparently at least) +/// running under systemd. We use this in various places +/// to e.g. log to the journal instead of printing to stdout. +pub(crate) fn running_in_systemd() -> bool { + static RUNNING_IN_SYSTEMD: OnceLock = OnceLock::new(); + *RUNNING_IN_SYSTEMD.get_or_init(|| { + // See https://www.freedesktop.org/software/systemd/man/systemd.exec.html#%24INVOCATION_ID + std::env::var_os("INVOCATION_ID") + .filter(|s| !s.is_empty()) + .is_some() + }) +} + +/// Return a prepared subprocess configuration that will run as an unprivileged user if possible. +/// +/// This currently only drops privileges when run under systemd with DynamicUser. +pub(crate) fn unprivileged_subprocess(binary: &str, user: &str) -> Command { + // TODO: if we detect we're running in a container as uid 0, perhaps at least switch to the + // "bin" user if we can? + if !running_in_systemd() { + return Command::new(binary); + } + let mut cmd = Command::new("setpriv"); + // Clear some strategic environment variables that may cause the containers/image stack + // to look in the wrong places for things. + cmd.env_remove("HOME"); + cmd.env_remove("XDG_DATA_DIR"); + cmd.env_remove("USER"); + cmd.args([ + "--no-new-privs", + "--init-groups", + "--reuid", + user, + "--bounding-set", + "-all", + "--pdeathsig", + "TERM", + "--", + binary, + ]); + cmd +} diff --git a/crates/ostree-ext/src/keyfileext.rs b/crates/ostree-ext/src/keyfileext.rs new file mode 100644 index 000000000..8d6e3a6ea --- /dev/null +++ b/crates/ostree-ext/src/keyfileext.rs @@ -0,0 +1,61 @@ +//! Helper methods for [`glib::KeyFile`]. + +use glib::GString; +use ostree::glib; + +/// Helper methods for [`glib::KeyFile`]. +pub trait KeyFileExt { + /// Get a string value, but return `None` if the key does not exist. + fn optional_string(&self, group: &str, key: &str) -> Result, glib::Error>; + /// Get a boolean value, but return `None` if the key does not exist. + fn optional_bool(&self, group: &str, key: &str) -> Result, glib::Error>; +} + +/// Consume a keyfile error, mapping the case where group or key is not found to `Ok(None)`. +pub fn map_keyfile_optional(res: Result) -> Result, glib::Error> { + match res { + Ok(v) => Ok(Some(v)), + Err(e) => { + if let Some(t) = e.kind::() { + match t { + glib::KeyFileError::GroupNotFound | glib::KeyFileError::KeyNotFound => Ok(None), + _ => Err(e), + } + } else { + Err(e) + } + } + } +} + +impl KeyFileExt for glib::KeyFile { + fn optional_string(&self, group: &str, key: &str) -> Result, glib::Error> { + map_keyfile_optional(self.string(group, key)) + } + + fn optional_bool(&self, group: &str, key: &str) -> Result, glib::Error> { + map_keyfile_optional(self.boolean(group, key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_optional() { + let kf = glib::KeyFile::new(); + assert_eq!(kf.optional_string("foo", "bar").unwrap(), None); + kf.set_string("foo", "baz", "someval"); + assert_eq!(kf.optional_string("foo", "bar").unwrap(), None); + assert_eq!( + kf.optional_string("foo", "baz").unwrap().unwrap(), + "someval" + ); + + assert!(kf.optional_bool("foo", "baz").is_err()); + assert_eq!(kf.optional_bool("foo", "bar").unwrap(), None); + kf.set_boolean("foo", "somebool", false); + assert_eq!(kf.optional_bool("foo", "somebool").unwrap(), Some(false)); + } +} diff --git a/crates/ostree-ext/src/lib.rs b/crates/ostree-ext/src/lib.rs new file mode 100644 index 000000000..0c53ec618 --- /dev/null +++ b/crates/ostree-ext/src/lib.rs @@ -0,0 +1,91 @@ +//! # Extension APIs for ostree +//! +//! This crate builds on top of the core ostree C library +//! and the Rust bindings to it, adding new functionality +//! written in Rust. + +// See https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html +#![deny(missing_docs)] +#![deny(missing_debug_implementations)] +#![forbid(unused_must_use)] +#![deny(unsafe_code)] +#![cfg_attr(feature = "dox", feature(doc_cfg))] +#![deny(clippy::dbg_macro)] +#![deny(clippy::todo)] + +// Re-export our dependencies. See https://gtk-rs.org/blog/2021/06/22/new-release.html +// "Dependencies are re-exported". Users will need e.g. `gio::File`, so this avoids +// them needing to update matching versions. +pub use composefs; +pub use composefs_boot; +pub use composefs_oci; +pub use containers_image_proxy; +pub use containers_image_proxy::oci_spec; +pub use ostree; +pub use ostree::gio; +pub use ostree::gio::glib; + +/// Our generic catchall fatal error, expected to be converted +/// to a string to output to a terminal or logs. +type Result = anyhow::Result; + +// Import global functions. +pub mod globals; + +mod isolation; + +pub mod bootabletree; +pub mod cli; +pub mod container; +pub mod container_utils; +pub mod diff; +pub(crate) mod generic_decompress; +pub mod ima; +pub mod keyfileext; +pub(crate) mod logging; +pub mod ostree_prepareroot; +pub mod refescape; +#[doc(hidden)] +pub mod repair; +pub mod sysroot; +pub mod tar; +pub mod tokio_util; + +pub mod selinux; + +pub mod chunking; +pub mod commit; +pub mod objectsource; +pub(crate) mod objgv; +#[cfg(feature = "internal-testing-api")] +pub mod ostree_manual; +#[cfg(not(feature = "internal-testing-api"))] +pub(crate) mod ostree_manual; + +pub(crate) mod statistics; + +mod utils; + +#[cfg(feature = "docgen")] +mod docgen; +pub mod fsverity; + +/// Prelude, intended for glob import. +pub mod prelude { + #[doc(hidden)] + pub use ostree::prelude::*; +} + +#[cfg(feature = "internal-testing-api")] +pub mod fixture; +#[cfg(feature = "internal-testing-api")] +pub mod integrationtest; + +/// Check if the system has the soft reboot target, which signals +/// systemd support for soft reboots. +pub fn systemd_has_soft_reboot() -> bool { + const UNIT: &str = "/usr/lib/systemd/system/soft-reboot.target"; + use std::sync::OnceLock; + static EXISTS: OnceLock = OnceLock::new(); + *EXISTS.get_or_init(|| std::path::Path::new(UNIT).exists()) +} diff --git a/crates/ostree-ext/src/logging.rs b/crates/ostree-ext/src/logging.rs new file mode 100644 index 000000000..b80f30ebd --- /dev/null +++ b/crates/ostree-ext/src/logging.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Set to true if we failed to write to the journal once +static EMITTED_JOURNAL_ERROR: AtomicBool = AtomicBool::new(false); + +/// Wrapper for systemd structured logging which only emits a message +/// if we're targeting the system repository, and it's booted. +pub(crate) fn system_repo_journal_send( + repo: &ostree::Repo, + priority: libsystemd::logging::Priority, + msg: &str, + vars: impl Iterator, +) where + K: AsRef, + V: AsRef, +{ + if !libsystemd::daemon::booted() { + return; + } + if !repo.is_system() { + return; + } + if let Err(e) = libsystemd::logging::journal_send(priority, msg, vars) { + if !EMITTED_JOURNAL_ERROR.swap(true, Ordering::SeqCst) { + eprintln!("failed to write to journal: {e}"); + } + } +} + +/// Wrapper for systemd structured logging which only emits a message +/// if we're targeting the system repository, and it's booted. +pub(crate) fn system_repo_journal_print( + repo: &ostree::Repo, + priority: libsystemd::logging::Priority, + msg: &str, +) { + let vars: HashMap<&str, &str> = HashMap::new(); + system_repo_journal_send(repo, priority, msg, vars.into_iter()) +} diff --git a/crates/ostree-ext/src/objectsource.rs b/crates/ostree-ext/src/objectsource.rs new file mode 100644 index 000000000..489ea6177 --- /dev/null +++ b/crates/ostree-ext/src/objectsource.rs @@ -0,0 +1,91 @@ +//! Metadata about the source of an object: a component or package. +//! +//! This is used to help split up containers into distinct layers. + +use indexmap::IndexMap; +use std::borrow::Borrow; +use std::collections::HashSet; +use std::hash::Hash; +use std::rc::Rc; + +use serde::{Deserialize, Serialize, Serializer}; + +mod rcstr_serialize { + use serde::Deserializer; + + use super::*; + + pub(crate) fn serialize(v: &Rc, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(v) + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let v = String::deserialize(deserializer)?; + Ok(Rc::from(v.into_boxed_str())) + } +} + +/// Identifier for content (e.g. package/layer). Not necessarily human readable. +/// For example in RPMs, this may be a full "NEVRA" i.e. name-epoch:version-release.architecture e.g. kernel-6.2-2.fc38.aarch64 +/// But that's not strictly required as this string should only live in memory and not be persisted. +pub type ContentID = Rc; + +/// Metadata about a component/package. +#[derive(Debug, Clone, Eq, Deserialize, Serialize)] +pub struct ObjectSourceMeta { + /// Unique identifier, does not need to be human readable, but can be. + #[serde(with = "rcstr_serialize")] + pub identifier: ContentID, + /// Just the name of the package (no version), needs to be human readable. + #[serde(with = "rcstr_serialize")] + pub name: Rc, + /// Identifier for the *source* of this content; for example, if multiple binary + /// packages derive from a single git repository or source package. + #[serde(with = "rcstr_serialize")] + pub srcid: Rc, + /// Unitless, relative offset of last change time. + /// One suggested way to generate this number is to have it be in units of hours or days + /// since the earliest changed item. + pub change_time_offset: u32, + /// Change frequency + pub change_frequency: u32, +} + +impl PartialEq for ObjectSourceMeta { + fn eq(&self, other: &Self) -> bool { + *self.identifier == *other.identifier + } +} + +impl Hash for ObjectSourceMeta { + fn hash(&self, state: &mut H) { + self.identifier.hash(state); + } +} + +impl Borrow for ObjectSourceMeta { + fn borrow(&self) -> &str { + &self.identifier + } +} + +/// Maps from e.g. "bash" or "kernel" to metadata about that content +pub type ObjectMetaSet = HashSet; + +/// Maps from an ostree content object digest to the `ContentSet` key. +pub type ObjectMetaMap = IndexMap; + +/// Grouping of metadata about an object. +#[derive(Debug, Default)] +pub struct ObjectMeta { + /// The set of object sources with their metadata. + pub set: ObjectMetaSet, + /// Mapping from content object to source. + pub map: ObjectMetaMap, +} diff --git a/crates/ostree-ext/src/objgv.rs b/crates/ostree-ext/src/objgv.rs new file mode 100644 index 000000000..3be5c94cd --- /dev/null +++ b/crates/ostree-ext/src/objgv.rs @@ -0,0 +1,31 @@ +/// Type representing an ostree commit object. +macro_rules! gv_commit { + () => { + gvariant::gv!("(a{sv}aya(say)sstayay)") + }; +} +pub(crate) use gv_commit; + +/// Type representing an ostree DIRTREE object. +macro_rules! gv_dirtree { + () => { + gvariant::gv!("(a(say)a(sayay))") + }; +} +pub(crate) use gv_dirtree; + +#[cfg(test)] +mod tests { + use gvariant::aligned_bytes::TryAsAligned; + use gvariant::Marker; + + use super::*; + #[test] + fn test_dirtree() { + // Just a compilation test + let data = b"".try_as_aligned().ok(); + if let Some(data) = data { + let _t = gv_dirtree!().cast(data); + } + } +} diff --git a/crates/ostree-ext/src/ostree_manual.rs b/crates/ostree-ext/src/ostree_manual.rs new file mode 100644 index 000000000..26a122109 --- /dev/null +++ b/crates/ostree-ext/src/ostree_manual.rs @@ -0,0 +1,33 @@ +//! Manual workarounds for ostree bugs + +use std::io::Read; +use std::ptr; + +use ostree::prelude::{Cast, InputStreamExtManual}; +use ostree::{gio, glib}; + +/// Equivalent of `g_file_read()` for ostree::RepoFile to work around https://github.com/ostreedev/ostree/issues/2703 +#[allow(unsafe_code)] +pub fn repo_file_read(f: &ostree::RepoFile) -> Result { + use glib::translate::*; + let stream = unsafe { + let f = f.upcast_ref::(); + let mut error = ptr::null_mut(); + let stream = gio::ffi::g_file_read(f.to_glib_none().0, ptr::null_mut(), &mut error); + if !error.is_null() { + return Err(from_glib_full(error)); + } + // Upcast to GInputStream here + from_glib_full(stream as *mut gio::ffi::GInputStream) + }; + + Ok(stream) +} + +/// Read a repo file to a string. +pub fn repo_file_read_to_string(f: &ostree::RepoFile) -> anyhow::Result { + let mut r = String::new(); + let mut s = repo_file_read(f)?.into_read(); + s.read_to_string(&mut r)?; + Ok(r) +} diff --git a/crates/ostree-ext/src/ostree_prepareroot.rs b/crates/ostree-ext/src/ostree_prepareroot.rs new file mode 100644 index 000000000..d6178cde2 --- /dev/null +++ b/crates/ostree-ext/src/ostree_prepareroot.rs @@ -0,0 +1,249 @@ +//! Logic related to parsing ostree-prepare-root.conf. +//! + +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::io::Read; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ocidir::cap_std::fs::Dir; +use ostree::glib::object::Cast; +use ostree::prelude::FileExt; +use ostree::{gio, glib}; + +use crate::keyfileext::KeyFileExt; +use crate::ostree_manual; +use bootc_utils::ResultExt; + +/// The relative path to ostree-prepare-root's config. +pub const CONF_PATH: &str = "ostree/prepare-root.conf"; + +/// Load the ostree prepare-root config from the given ostree repository. +pub fn load_config(root: &ostree::RepoFile) -> Result> { + let cancellable = gio::Cancellable::NONE; + let kf = glib::KeyFile::new(); + for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) { + let path = &path.join(CONF_PATH); + let f = root.resolve_relative_path(path); + if !f.query_exists(cancellable) { + continue; + } + let f = f.downcast_ref::().unwrap(); + let contents = ostree_manual::repo_file_read_to_string(f)?; + kf.load_from_data(&contents, glib::KeyFileFlags::NONE) + .with_context(|| format!("Parsing {path}"))?; + tracing::debug!("Loaded {path}"); + return Ok(Some(kf)); + } + tracing::debug!("No {CONF_PATH} found"); + Ok(None) +} + +/// Load the configuration from the target root. +pub fn load_config_from_root(root: &Dir) -> Result> { + for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) { + let path = path.join(CONF_PATH); + let Some(mut f) = root.open_optional(&path)? else { + continue; + }; + let mut contents = String::new(); + f.read_to_string(&mut contents)?; + let kf = glib::KeyFile::new(); + kf.load_from_data(&contents, glib::KeyFileFlags::NONE) + .with_context(|| format!("Parsing {path}"))?; + return Ok(Some(kf)); + } + Ok(None) +} + +/// Require the configuration in the target root. +pub fn require_config_from_root(root: &Dir) -> Result { + load_config_from_root(root)? + .ok_or_else(|| anyhow::anyhow!("Failed to find {CONF_PATH} in /usr/lib or /etc")) +} + +/// Query whether the target root has the `root.transient` key +/// which sets up a transient overlayfs. +pub fn overlayfs_root_enabled(root: &ostree::RepoFile) -> Result { + if let Some(config) = load_config(root)? { + overlayfs_enabled_in_config(&config) + } else { + Ok(false) + } +} + +/// An option which can be enabled, disabled, or possibly enabled. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Tristate { + /// Enabled + Enabled, + /// Disabled + Disabled, + /// Maybe + Maybe, +} + +impl FromStr for Tristate { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let r = match s { + // Keep this in sync with ot_keyfile_get_tristate_with_default from ostree + "yes" | "true" | "1" => Tristate::Enabled, + "no" | "false" | "0" => Tristate::Disabled, + "maybe" => Tristate::Maybe, + o => anyhow::bail!("Invalid tristate value: {o}"), + }; + Ok(r) + } +} + +impl Default for Tristate { + fn default() -> Self { + Self::Disabled + } +} + +impl Tristate { + pub(crate) fn maybe_enabled(&self) -> bool { + match self { + Self::Enabled | Self::Maybe => true, + Self::Disabled => false, + } + } +} + +/// The state of a composefs for ostree +#[derive(Debug, PartialEq, Eq)] +pub enum ComposefsState { + /// The composefs must be signed and use fsverity + Signed, + /// The composefs must use fsverity + Verity, + /// The composefs may or may not be enabled. + Tristate(Tristate), +} + +impl Default for ComposefsState { + fn default() -> Self { + Self::Tristate(Tristate::default()) + } +} + +impl FromStr for ComposefsState { + type Err = anyhow::Error; + + #[context("Parsing composefs.enabled value {s}")] + fn from_str(s: &str) -> Result { + let r = match s { + "signed" => Self::Signed, + "verity" => Self::Verity, + o => Self::Tristate(Tristate::from_str(o)?), + }; + Ok(r) + } +} + +impl ComposefsState { + pub(crate) fn maybe_enabled(&self) -> bool { + match self { + ComposefsState::Signed | ComposefsState::Verity => true, + ComposefsState::Tristate(t) => t.maybe_enabled(), + } + } + + /// This configuration requires fsverity on the target filesystem. + pub fn requires_fsverity(&self) -> bool { + matches!(self, ComposefsState::Signed | ComposefsState::Verity) + } +} + +/// Query whether the config uses an overlayfs model (composefs or plain overlayfs). +pub fn overlayfs_enabled_in_config(config: &glib::KeyFile) -> Result { + let root_transient = config + .optional_bool("root", "transient")? + .unwrap_or_default(); + let composefs = config + .optional_string("composefs", "enabled")? + .map(|s| ComposefsState::from_str(s.as_str())) + .transpose() + .log_err_default() + .unwrap_or_default(); + Ok(root_transient || composefs.maybe_enabled()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tristate() { + for v in ["yes", "true", "1"] { + assert_eq!(Tristate::from_str(v).unwrap(), Tristate::Enabled); + } + assert_eq!(Tristate::from_str("maybe").unwrap(), Tristate::Maybe); + for v in ["no", "false", "0"] { + assert_eq!(Tristate::from_str(v).unwrap(), Tristate::Disabled); + } + for v in ["", "junk", "fal", "tr1"] { + assert!(Tristate::from_str(v).is_err()); + } + } + + #[test] + fn test_composefs_state() { + assert_eq!( + ComposefsState::from_str("signed").unwrap(), + ComposefsState::Signed + ); + for v in ["yes", "true", "1"] { + assert_eq!( + ComposefsState::from_str(v).unwrap(), + ComposefsState::Tristate(Tristate::Enabled) + ); + } + assert_eq!(Tristate::from_str("maybe").unwrap(), Tristate::Maybe); + for v in ["no", "false", "0"] { + assert_eq!( + ComposefsState::from_str(v).unwrap(), + ComposefsState::Tristate(Tristate::Disabled) + ); + } + } + + #[test] + fn test_overlayfs_enabled() { + let d0 = indoc::indoc! { r#" +[foo] +bar = baz +[root] +"# }; + let d1 = indoc::indoc! { r#" +[root] +transient = false + "# }; + let d2 = indoc::indoc! { r#" +[composefs] +enabled = false + "# }; + for v in ["", d0, d1, d2] { + let kf = glib::KeyFile::new(); + kf.load_from_data(v, glib::KeyFileFlags::empty()).unwrap(); + assert!(!overlayfs_enabled_in_config(&kf).unwrap()); + } + + let e0 = format!("{d0}\n[root]\ntransient = true"); + let e1 = format!("{d1}\n[composefs]\nenabled = true\n[other]\nsomekey = someval"); + let e2 = format!("{d1}\n[composefs]\nenabled = yes"); + let e3 = format!("{d1}\n[composefs]\nenabled = signed"); + for v in [e0, e1, e2, e3] { + let kf = glib::KeyFile::new(); + kf.load_from_data(&v, glib::KeyFileFlags::empty()).unwrap(); + assert!(overlayfs_enabled_in_config(&kf).unwrap()); + } + } +} diff --git a/crates/ostree-ext/src/refescape.rs b/crates/ostree-ext/src/refescape.rs new file mode 100644 index 000000000..cb53aa62c --- /dev/null +++ b/crates/ostree-ext/src/refescape.rs @@ -0,0 +1,198 @@ +//! Escape strings for use in ostree refs. +//! +//! It can be desirable to map arbitrary identifiers, such as RPM/dpkg +//! package names or container image references (e.g. `docker://quay.io/examplecorp/os:latest`) +//! into ostree refs (branch names) which have a quite restricted set +//! of valid characters; basically alphanumeric, plus `/`, `-`, `_`. +//! +//! This escaping scheme uses `_` in a similar way as a `\` character is +//! used in Rust unicode escaped values. For example, `:` is `_3A_` (hexadecimal). +//! Because the empty path is not valid, `//` is escaped as `/_2F_` (i.e. the second `/` is escaped). + +use anyhow::Result; +use std::fmt::Write; + +/// Escape a single string; this is a backend of [`prefix_escape_for_ref`]. +fn escape_for_ref(s: &str) -> Result { + if s.is_empty() { + return Err(anyhow::anyhow!("Invalid empty string for ref")); + } + fn escape_c(r: &mut String, c: char) { + write!(r, "_{:02X}_", c as u32).unwrap() + } + let mut r = String::new(); + let mut it = s + .chars() + .map(|c| { + if c == '\0' { + Err(anyhow::anyhow!( + "Invalid embedded NUL in string for ostree ref" + )) + } else { + Ok(c) + } + }) + .peekable(); + + let mut previous_alphanumeric = false; + while let Some(c) = it.next() { + let has_next = it.peek().is_some(); + let c = c?; + let current_alphanumeric = c.is_ascii_alphanumeric(); + match c { + c if current_alphanumeric => r.push(c), + '/' if previous_alphanumeric && has_next => r.push(c), + // Pass through `-` unconditionally + '-' => r.push(c), + // The underscore `_` quotes itself `__`. + '_' => r.push_str("__"), + o => escape_c(&mut r, o), + } + previous_alphanumeric = current_alphanumeric; + } + Ok(r) +} + +/// Compute a string suitable for use as an OSTree ref, where `s` can be a (nearly) +/// arbitrary UTF-8 string. This requires a non-empty prefix. +/// +/// The restrictions on `s` are: +/// - The empty string is not supported +/// - There may not be embedded `NUL` (`\0`) characters. +/// +/// The intention behind requiring a prefix is that a common need is to use e.g. +/// [`ostree::Repo::list_refs`] to find refs of a certain "type". +/// +/// # Examples: +/// +/// ```rust +/// # fn test() -> anyhow::Result<()> { +/// use ostree_ext::refescape; +/// let s = "registry:quay.io/coreos/fedora:latest"; +/// assert_eq!(refescape::prefix_escape_for_ref("container", s)?, +/// "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest"); +/// # Ok(()) +/// # } +/// ``` +pub fn prefix_escape_for_ref(prefix: &str, s: &str) -> Result { + Ok(format!("{}/{}", prefix, escape_for_ref(s)?)) +} + +/// Reverse the effect of [`escape_for_ref()`]. +fn unescape_for_ref(s: &str) -> Result { + let mut r = String::new(); + let mut it = s.chars(); + let mut buf = String::new(); + while let Some(c) = it.next() { + match c { + c if c.is_ascii_alphanumeric() => { + r.push(c); + } + '-' | '/' => r.push(c), + '_' => { + let next = it.next(); + if let Some('_') = next { + r.push('_') + } else if let Some(c) = next { + buf.clear(); + buf.push(c); + for c in &mut it { + if c == '_' { + break; + } + buf.push(c); + } + let v = u32::from_str_radix(&buf, 16)?; + let c: char = v.try_into()?; + r.push(c); + } + } + o => anyhow::bail!("Invalid character {}", o), + } + } + Ok(r) +} + +/// Remove a prefix from an ostree ref, and return the unescaped remainder. +/// +/// # Examples: +/// +/// ```rust +/// # fn test() -> anyhow::Result<()> { +/// use ostree_ext::refescape; +/// let s = "registry:quay.io/coreos/fedora:latest"; +/// assert_eq!(refescape::unprefix_unescape_ref("container", "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest")?, s); +/// # Ok(()) +/// # } +/// ``` +pub fn unprefix_unescape_ref(prefix: &str, ostree_ref: &str) -> Result { + let rest = ostree_ref + .strip_prefix(prefix) + .and_then(|s| s.strip_prefix('/')) + .ok_or_else(|| { + anyhow::anyhow!( + "ref does not match expected prefix {}/: {}", + ostree_ref, + prefix + ) + })?; + unescape_for_ref(rest) +} + +#[cfg(test)] +mod test { + use super::*; + use quickcheck::{quickcheck, TestResult}; + + const TESTPREFIX: &str = "testprefix/blah"; + + const UNCHANGED: &[&str] = &["foo", "foo/bar/baz-blah/foo"]; + const ROUNDTRIP: &[&str] = &[ + "localhost:5000/foo:latest", + "fedora/x86_64/coreos", + "/foo/bar/foo.oci-archive", + "/foo/bar/foo.docker-archive", + "docker://quay.io/exampleos/blah:latest", + "oci-archive:/path/to/foo.ociarchive", + "docker-archive:/path/to/foo.dockerarchive", + ]; + const CORNERCASES: &[&str] = &["/", "blah/", "/foo/"]; + + #[test] + fn escape() { + // These strings shouldn't change + for &v in UNCHANGED { + let escaped = &escape_for_ref(v).unwrap(); + ostree::validate_rev(escaped).unwrap(); + assert_eq!(escaped.as_str(), v); + } + // Roundtrip cases, plus unchanged cases + for &v in UNCHANGED.iter().chain(ROUNDTRIP).chain(CORNERCASES) { + let escaped = &prefix_escape_for_ref(TESTPREFIX, v).unwrap(); + ostree::validate_rev(escaped).unwrap(); + let unescaped = unprefix_unescape_ref(TESTPREFIX, escaped).unwrap(); + assert_eq!(v, unescaped); + } + // Explicit test + assert_eq!( + escape_for_ref(ROUNDTRIP[0]).unwrap(), + "localhost_3A_5000/foo_3A_latest" + ); + } + + fn roundtrip(s: String) -> TestResult { + // Ensure we only try strings which match the predicates. + let r = prefix_escape_for_ref(TESTPREFIX, &s); + let escaped = match r { + Ok(v) => v, + Err(_) => return TestResult::discard(), + }; + let unescaped = unprefix_unescape_ref(TESTPREFIX, &escaped).unwrap(); + TestResult::from_bool(unescaped == s) + } + + #[test] + fn qcheck() { + quickcheck(roundtrip as fn(String) -> TestResult); + } +} diff --git a/crates/ostree-ext/src/repair.rs b/crates/ostree-ext/src/repair.rs new file mode 100644 index 000000000..5f7ff85d3 --- /dev/null +++ b/crates/ostree-ext/src/repair.rs @@ -0,0 +1,261 @@ +//! System repair functionality + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Display; + +use anyhow::{anyhow, Context, Result}; +use cap_std::fs::{Dir, MetadataExt}; +use cap_std_ext::cap_std; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; + +use crate::sysroot::SysrootLock; + +// Find the inode numbers for objects +fn gather_inodes( + prefix: &str, + dir: &Dir, + little_inodes: &mut BTreeMap, + big_inodes: &mut BTreeMap, +) -> Result<()> { + for child in dir.entries()? { + let child = child?; + let metadata = child.metadata()?; + if !(metadata.is_file() || metadata.is_symlink()) { + continue; + } + let name = child.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid {name:?}"))?; + let object_rest = name + .split_once('.') + .ok_or_else(|| anyhow!("Invalid object {name}"))? + .0; + let checksum = format!("{prefix}{object_rest}"); + let inode = metadata.ino(); + if let Ok(little) = u32::try_from(inode) { + little_inodes.insert(little, checksum); + } else { + big_inodes.insert(inode, checksum); + } + } + Ok(()) +} + +#[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct RepairResult { + /// Result of inode checking + pub inodes: InodeCheck, + // Whether we detected a likely corrupted merge commit + pub likely_corrupted_container_image_merges: Vec, + // Whether the booted deployment is likely corrupted + pub booted_is_likely_corrupted: bool, + // Whether the staged deployment is likely corrupted + pub staged_is_likely_corrupted: bool, +} + +#[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct InodeCheck { + // Number of >32 bit inodes found + pub inode64: u64, + // Number of <= 32 bit inodes found + pub inode32: u64, + // Number of collisions found (when 64 bit inode is truncated to 32 bit) + pub collisions: BTreeSet, +} + +impl Display for InodeCheck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ostree inode check:\n 64bit inodes: {}\n 32 bit inodes: {}\n collisions: {}\n", + self.inode64, + self.inode32, + self.collisions.len() + ) + } +} + +impl InodeCheck { + pub fn is_ok(&self) -> bool { + self.collisions.is_empty() + } +} + +#[context("Checking inodes")] +#[doc(hidden)] +/// Detect if any commits are potentially incorrect due to inode truncations. +pub fn check_inode_collision(repo: &ostree::Repo, verbose: bool) -> Result { + let repo_dir = Dir::reopen_dir(&repo.dfd_borrow())?; + let objects = repo_dir.open_dir("objects")?; + + println!( + r#"Attempting analysis of ostree state for files that may be incorrectly linked. +For more information, see https://github.com/ostreedev/ostree/pull/2874/commits/de6fddc6adee09a93901243dc7074090828a1912 +"# + ); + + println!("Gathering inodes for ostree objects..."); + let mut little_inodes = BTreeMap::new(); + let mut big_inodes = BTreeMap::new(); + + for child in objects.entries()? { + let child = child?; + if !child.file_type()?.is_dir() { + continue; + } + let name = child.file_name(); + if name.len() != 2 { + continue; + } + let name = name + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid {name:?}"))?; + let objdir = child.open_dir()?; + gather_inodes(name, &objdir, &mut little_inodes, &mut big_inodes) + .with_context(|| format!("Processing {name:?}"))?; + } + + let mut colliding_inodes = BTreeMap::new(); + for (big_inum, big_inum_checksum) in big_inodes.iter() { + let truncated = *big_inum as u32; + if let Some(small_inum_object) = little_inodes.get(&truncated) { + // Don't output each collision unless verbose mode is enabled. It's actually + // quite interesting to see data, but only for development and deep introspection + // use cases. + if verbose { + eprintln!( + r#"collision: + inode (>32 bit): {big_inum} + object: {big_inum_checksum} + inode (truncated): {truncated} + object: {small_inum_object} +"# + ); + } + colliding_inodes.insert(big_inum, big_inum_checksum); + } + } + + // From here let's just track the possibly-colliding 64 bit inode, not also + // the checksum. + let collisions = colliding_inodes + .keys() + .map(|&&v| v) + .collect::>(); + + let inode32 = little_inodes.len() as u64; + let inode64 = big_inodes.len() as u64; + Ok(InodeCheck { + inode32, + inode64, + collisions, + }) +} + +/// Attempt to automatically repair any corruption from inode collisions. +#[doc(hidden)] +pub fn analyze_for_repair(sysroot: &SysrootLock, verbose: bool) -> Result { + use crate::container::store as container_store; + let repo = &sysroot.repo(); + + // Query booted and pending state + let booted_deployment = sysroot.booted_deployment(); + let booted_checksum = booted_deployment.as_ref().map(|b| b.csum()); + let booted_checksum = booted_checksum.as_ref().map(|s| s.as_str()); + let staged_deployment = sysroot.staged_deployment(); + let staged_checksum = staged_deployment.as_ref().map(|b| b.csum()); + let staged_checksum = staged_checksum.as_ref().map(|s| s.as_str()); + + let inodes = check_inode_collision(repo, verbose)?; + println!("{inodes}"); + if inodes.is_ok() { + println!("OK no colliding inodes found"); + return Ok(RepairResult { + inodes, + ..Default::default() + }); + } + + let all_images = container_store::list_images(repo)?; + let all_images = all_images + .into_iter() + .map(|img| crate::container::ImageReference::try_from(img.as_str())) + .collect::>>()?; + println!("Verifying ostree-container images: {}", all_images.len()); + let mut likely_corrupted_container_image_merges = Vec::new(); + let mut booted_is_likely_corrupted = false; + let mut staged_is_likely_corrupted = false; + for imgref in all_images { + if let Some(state) = container_store::query_image(repo, &imgref)? { + if !container_store::verify_container_image( + sysroot, + &imgref, + &state, + &inodes.collisions, + verbose, + )? { + eprintln!("warning: Corrupted image {imgref}"); + likely_corrupted_container_image_merges.push(imgref.to_string()); + let merge_commit = state.merge_commit.as_str(); + if booted_checksum == Some(merge_commit) { + booted_is_likely_corrupted = true; + eprintln!("warning: booted deployment is likely corrupted"); + } else if staged_checksum == Some(merge_commit) { + staged_is_likely_corrupted = true; + eprintln!("warning: staged deployment is likely corrupted"); + } + } + } else { + // This really shouldn't happen + eprintln!("warning: Image was removed from underneath us: {imgref}"); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + Ok(RepairResult { + inodes, + likely_corrupted_container_image_merges, + booted_is_likely_corrupted, + staged_is_likely_corrupted, + }) +} + +impl RepairResult { + pub fn check(&self) -> anyhow::Result<()> { + if self.booted_is_likely_corrupted { + eprintln!("warning: booted deployment is likely corrupted"); + } + if self.booted_is_likely_corrupted { + eprintln!("warning: staged deployment is likely corrupted"); + } + match self.likely_corrupted_container_image_merges.len() { + 0 => { + println!("OK no corruption found"); + Ok(()) + } + n => { + anyhow::bail!("Found corruption in images: {n}") + } + } + } + + #[context("Repairing")] + pub fn repair(self, sysroot: &SysrootLock) -> Result<()> { + let repo = &sysroot.repo(); + for imgref in self.likely_corrupted_container_image_merges { + let imgref = crate::container::ImageReference::try_from(imgref.as_str())?; + eprintln!("Flushing cached state for corrupted merged image: {imgref}"); + crate::container::store::remove_images(repo, [&imgref])?; + } + if self.booted_is_likely_corrupted { + anyhow::bail!("TODO redeploy and reboot for booted deployment corruption"); + } + if self.staged_is_likely_corrupted { + anyhow::bail!("TODO undeploy for staged deployment corruption"); + } + Ok(()) + } +} diff --git a/crates/ostree-ext/src/selinux.rs b/crates/ostree-ext/src/selinux.rs new file mode 100644 index 000000000..35acb7504 --- /dev/null +++ b/crates/ostree-ext/src/selinux.rs @@ -0,0 +1,39 @@ +//! SELinux-related helper APIs. + +use anyhow::Result; +use fn_error_context::context; +use std::path::Path; + +/// The well-known selinuxfs mount point +const SELINUX_MNT: &str = "/sys/fs/selinux"; +/// Hardcoded value for SELinux domain capable of setting unknown contexts. +const INSTALL_T: &str = "install_t"; + +/// Query for whether or not SELinux is enabled. +pub fn is_selinux_enabled() -> bool { + Path::new(SELINUX_MNT).join("access").exists() +} + +/// Return an error If the current process is not running in the `install_t` domain. +#[context("Verifying self is install_t SELinux domain")] +pub fn verify_install_domain() -> Result<()> { + // If it doesn't look like SELinux is enabled, then nothing to do. + if !is_selinux_enabled() { + return Ok(()); + } + + // If we're not root, there's no need to try to warn because we can only + // do read-only operations anyways. + if !rustix::process::getuid().is_root() { + return Ok(()); + } + + let self_domain = std::fs::read_to_string("/proc/self/attr/current")?; + let is_install_t = self_domain.split(':').any(|x| x == INSTALL_T); + if !is_install_t { + anyhow::bail!( + "Detected SELinux enabled system, but the executing binary is not labeled install_exec_t" + ); + } + Ok(()) +} diff --git a/crates/ostree-ext/src/statistics.rs b/crates/ostree-ext/src/statistics.rs new file mode 100644 index 000000000..f527b5a87 --- /dev/null +++ b/crates/ostree-ext/src/statistics.rs @@ -0,0 +1,113 @@ +//! This module holds implementations of some basic statistical properties, such as mean and standard deviation. + +pub(crate) fn mean(data: &[u64]) -> Option { + if data.is_empty() { + None + } else { + Some(data.iter().sum::() as f64 / data.len() as f64) + } +} + +pub(crate) fn std_deviation(data: &[u64]) -> Option { + match (mean(data), data.len()) { + (Some(data_mean), count) if count > 0 => { + let variance = data + .iter() + .map(|value| { + let diff = data_mean - (*value as f64); + diff * diff + }) + .sum::() + / count as f64; + Some(variance.sqrt()) + } + _ => None, + } +} + +//Assumed sorted +pub(crate) fn median_absolute_deviation(data: &mut [u64]) -> Option<(f64, f64)> { + if data.is_empty() { + None + } else { + //Sort data + //data.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + //Find median of data + let median_data: f64 = match data.len() % 2 { + 1 => data[data.len() / 2] as f64, + _ => 0.5 * (data[data.len() / 2 - 1] + data[data.len() / 2]) as f64, + }; + + //Absolute deviations + let mut absolute_deviations = Vec::new(); + for size in data { + absolute_deviations.push(f64::abs(*size as f64 - median_data)) + } + + absolute_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let l = absolute_deviations.len(); + let mad: f64 = match l % 2 { + 1 => absolute_deviations[l / 2], + _ => 0.5 * (absolute_deviations[l / 2 - 1] + absolute_deviations[l / 2]), + }; + + Some((median_data, mad)) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mean() { + assert_eq!(mean(&[]), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(mean(&[v]), Some(v as f64)); + } + assert_eq!(mean(&[0, 1]), Some(0.5)); + assert_eq!(mean(&[0, 5, 100]), Some(35.0)); + assert_eq!(mean(&[7, 4, 30, 14]), Some(13.75)); + } + + #[test] + fn test_std_deviation() { + assert_eq!(std_deviation(&[]), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(std_deviation(&[v]), Some(0 as f64)); + } + assert_eq!(std_deviation(&[1, 4]), Some(1.5)); + assert_eq!(std_deviation(&[2, 2, 2, 2]), Some(0.0)); + assert_eq!( + std_deviation(&[1, 20, 300, 4000, 50000, 600000, 7000000, 80000000]), + Some(26193874.56387471) + ); + } + + #[test] + fn test_median_absolute_deviation() { + //Assumes sorted + assert_eq!(median_absolute_deviation(&mut []), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(median_absolute_deviation(&mut [v]), Some((v as f64, 0.0))); + } + assert_eq!(median_absolute_deviation(&mut [1, 4]), Some((2.5, 1.5))); + assert_eq!( + median_absolute_deviation(&mut [2, 2, 2, 2]), + Some((2.0, 0.0)) + ); + assert_eq!( + median_absolute_deviation(&mut [ + 1, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 7, 7, 7, 8, 9, 12, 52, 90 + ]), + Some((6.0, 2.0)) + ); + + //if more than half of the data has the same value, MAD = 0, thus any + //value different from the residual median is classified as an outlier + assert_eq!( + median_absolute_deviation(&mut [0, 1, 1, 1, 1, 1, 1, 1, 0]), + Some((1.0, 0.0)) + ); + } +} diff --git a/crates/ostree-ext/src/sysroot.rs b/crates/ostree-ext/src/sysroot.rs new file mode 100644 index 000000000..ec1e0be9f --- /dev/null +++ b/crates/ostree-ext/src/sysroot.rs @@ -0,0 +1,291 @@ +//! Helpers for interacting with sysroots. + +use std::{ops::Deref, os::fd::BorrowedFd, time::SystemTime}; + +use anyhow::Result; +use chrono::Datelike as _; +use ocidir::cap_std::fs_utf8::Dir; +use ostree::gio; + +/// We may automatically allocate stateroots, this string is the prefix. +const AUTO_STATEROOT_PREFIX: &str = "state-"; + +use crate::utils::async_task_with_spinner; + +/// A locked system root. +#[derive(Debug)] +pub struct SysrootLock { + /// The underlying sysroot value. + pub sysroot: ostree::Sysroot, + /// True if we didn't actually lock + unowned: bool, +} + +impl Drop for SysrootLock { + fn drop(&mut self) { + if self.unowned { + return; + } + self.sysroot.unlock(); + } +} + +impl Deref for SysrootLock { + type Target = ostree::Sysroot; + + fn deref(&self) -> &Self::Target { + &self.sysroot + } +} + +/// Access the file descriptor for a sysroot +#[allow(unsafe_code)] +pub fn sysroot_fd(sysroot: &ostree::Sysroot) -> BorrowedFd<'_> { + unsafe { BorrowedFd::borrow_raw(sysroot.fd()) } +} + +/// A stateroot can match our auto "state-" prefix, or be manual. +#[derive(Debug, PartialEq, Eq)] +pub enum StaterootKind { + /// This stateroot has an automatic name + Auto((u64, u64)), + /// This stateroot is manually named + Manual, +} + +/// Metadata about a stateroot. +#[derive(Debug, PartialEq, Eq)] +pub struct Stateroot { + /// The name + pub name: String, + /// Kind + pub kind: StaterootKind, + /// Creation timestamp (from the filesystem) + pub creation: SystemTime, +} + +impl StaterootKind { + fn new(name: &str) -> Self { + if let Some(v) = parse_auto_stateroot_name(name) { + return Self::Auto(v); + } + Self::Manual + } +} + +/// Load metadata for a stateroot +fn read_stateroot(sysroot_dir: &Dir, name: &str) -> Result { + let path = format!("ostree/deploy/{name}"); + let kind = StaterootKind::new(&name); + let creation = sysroot_dir.symlink_metadata(&path)?.created()?.into_std(); + let r = Stateroot { + name: name.to_owned(), + kind, + creation, + }; + Ok(r) +} + +/// Enumerate stateroots, which are basically the default place for `/var`. +pub fn list_stateroots(sysroot: &ostree::Sysroot) -> Result> { + let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + let r = sysroot_dir + .read_dir("ostree/deploy")? + .try_fold(Vec::new(), |mut acc, v| { + let v = v?; + let name = v.file_name()?; + if sysroot_dir.try_exists(format!("ostree/deploy/{name}/deploy"))? { + acc.push(read_stateroot(sysroot_dir, &name)?); + } + anyhow::Ok(acc) + })?; + Ok(r) +} + +/// Given a string, if it matches the form of an automatic state root, parse it into its . pair. +fn parse_auto_stateroot_name(name: &str) -> Option<(u64, u64)> { + let Some(statename) = name.strip_prefix(AUTO_STATEROOT_PREFIX) else { + return None; + }; + let Some((year, serial)) = statename.split_once("-") else { + return None; + }; + let Ok(year) = year.parse::() else { + return None; + }; + let Ok(serial) = serial.parse::() else { + return None; + }; + Some((year, serial)) +} + +/// Given a set of stateroots, allocate a new one +pub fn allocate_new_stateroot( + sysroot: &ostree::Sysroot, + stateroots: &[Stateroot], + now: chrono::DateTime, +) -> Result { + let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + + let current_year = now.year().try_into().unwrap_or_default(); + let (year, serial) = stateroots + .iter() + .filter_map(|v| { + if let StaterootKind::Auto(v) = v.kind { + Some(v) + } else { + None + } + }) + .max() + .map(|(year, serial)| (year, serial + 1)) + .unwrap_or((current_year, 0)); + + let name = format!("state-{year}-{serial}"); + + sysroot.init_osname(&name, gio::Cancellable::NONE)?; + + read_stateroot(sysroot_dir, &name) +} + +impl SysrootLock { + /// Asynchronously acquire a sysroot lock. If the lock cannot be acquired + /// immediately, a status message will be printed to standard output. + /// The lock will be unlocked when this object is dropped. + pub async fn new_from_sysroot(sysroot: &ostree::Sysroot) -> Result { + let sysroot_clone = sysroot.clone(); + let locker = tokio::task::spawn_blocking(move || sysroot_clone.lock()); + async_task_with_spinner("Waiting for sysroot lock...", locker).await??; + Ok(Self { + sysroot: sysroot.clone(), + unowned: false, + }) + } + + /// This function should only be used when you have locked the sysroot + /// externally (e.g. in C/C++ code). This also does not unlock on drop. + pub fn from_assumed_locked(sysroot: &ostree::Sysroot) -> Self { + Self { + sysroot: sysroot.clone(), + unowned: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_auto_stateroot_name_valid() { + let test_cases = [ + // Basic valid cases + ("state-2024-0", Some((2024, 0))), + ("state-2024-1", Some((2024, 1))), + ("state-2023-123", Some((2023, 123))), + // Large numbers + ( + "state-18446744073709551615-18446744073709551615", + Some((18446744073709551615, 18446744073709551615)), + ), + // Zero values + ("state-0-0", Some((0, 0))), + ("state-0-123", Some((0, 123))), + // Leading zeros (should work - u64::parse handles them) + ("state-0002024-001", Some((2024, 1))), + ("state-000-000", Some((0, 0))), + ]; + + for (input, expected) in test_cases { + assert_eq!( + parse_auto_stateroot_name(input), + expected, + "Failed for input: {}", + input + ); + } + } + + #[test] + fn test_parse_auto_stateroot_name_invalid() { + let test_cases = [ + // Missing prefix + "2024-1", + // Wrong prefix + "stat-2024-1", + "states-2024-1", + "prefix-2024-1", + // Empty string + "", + // Only prefix + "state-", + // Missing separator + "state-20241", + // Wrong separator + "state-2024.1", + "state-2024_1", + "state-2024:1", + // Multiple separators + "state-2024-1-2", + // Missing year or serial + "state--1", + "state-2024-", + // Non-numeric year + "state-abc-1", + "state-2024a-1", + // Non-numeric serial + "state-2024-abc", + "state-2024-1a", + // Both non-numeric + "state-abc-def", + // Negative numbers (handled by parse::() failure) + "state--2024-1", + "state-2024--1", + // Floating point numbers + "state-2024.5-1", + "state-2024-1.5", + // Numbers with whitespace + "state- 2024-1", + "state-2024- 1", + "state-2024 -1", + "state-2024- 1 ", + // Case sensitivity (should fail - prefix is lowercase) + "State-2024-1", + "STATE-2024-1", + // Unicode characters + "state-2024-1🦀", + "state-2024🦀-1", + // Hex-like strings (should fail - not decimal) + "state-0x2024-1", + "state-2024-0x1", + ]; + + for input in test_cases { + assert_eq!( + parse_auto_stateroot_name(input), + None, + "Expected None for input: {}", + input + ); + } + } + + #[test] + fn test_stateroot_kind_new() { + let test_cases = [ + ("state-2024-1", StaterootKind::Auto((2024, 1))), + ("manual-name", StaterootKind::Manual), + ("state-invalid", StaterootKind::Manual), + ("", StaterootKind::Manual), + ]; + + for (input, expected) in test_cases { + assert_eq!( + StaterootKind::new(input), + expected, + "Failed for input: {}", + input + ); + } + } +} diff --git a/crates/ostree-ext/src/tar/export.rs b/crates/ostree-ext/src/tar/export.rs new file mode 100644 index 000000000..9262c3d09 --- /dev/null +++ b/crates/ostree-ext/src/tar/export.rs @@ -0,0 +1,907 @@ +//! APIs for creating container images from OSTree commits + +use crate::chunking; +use crate::objgv::*; +use anyhow::{anyhow, ensure, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use fn_error_context::context; +use gio::glib; +use gio::prelude::*; +use gvariant::aligned_bytes::TryAsAligned; +use gvariant::{Marker, Structure}; +use ostree::gio; +use std::borrow::Borrow; +use std::borrow::Cow; +use std::collections::HashSet; +use std::ffi::CStr; +use std::io::BufReader; + +/// The repository mode generated by a tar export stream. +pub const BARE_SPLIT_XATTRS_MODE: &str = "bare-split-xattrs"; + +/// The SELinux xattr. Because the ostree xattrs require an embedded NUL, we +/// store that version as a constant. +pub(crate) const SECURITY_SELINUX_XATTR_C: &CStr = c"security.selinux"; +/// Then derive a string version (without the NUL) from the above. +pub(crate) const SECURITY_SELINUX_XATTR: &str = const { + match SECURITY_SELINUX_XATTR_C.to_str() { + Ok(r) => r, + Err(_) => unreachable!(), + } +}; + +// This is both special in the tar stream *and* it's in the ostree commit. +const SYSROOT: &str = "sysroot"; +// This way the default ostree -> sysroot/ostree symlink works. +const OSTREEDIR: &str = "sysroot/ostree"; +// The ref added (under ostree/) in the exported OSTree repo pointing at the commit. +#[allow(dead_code)] +const OSTREEREF: &str = "encapsulated"; + +/// In v0 format, we use this relative path prefix. I think I chose this by looking +/// at the current Fedora base image tar stream. However, several others don't do +/// this and have paths be relative by simply omitting `./`, i.e. the tar stream +/// contains `usr/bin/bash` and not `./usr/bin/bash`. The former looks cleaner +/// to me, so in v1 we drop it. +const TAR_PATH_PREFIX_V0: &str = "./"; + +/// The base repository configuration that identifies this is a tar export. +// See https://github.com/ostreedev/ostree/issues/2499 +const REPO_CONFIG: &str = r#"[core] +repo_version=1 +mode=bare-split-xattrs +"#; + +/// A decently large buffer, as used by e.g. coreutils `cat`. +/// System calls are expensive. +const BUF_CAPACITY: usize = 131072; + +/// Convert `from` to `to` +fn map_path_inner<'p>( + p: &'p Utf8Path, + from: &'_ str, + to: &'_ str, +) -> std::borrow::Cow<'p, Utf8Path> { + match p.strip_prefix(from) { + Ok(r) => { + if r.components().count() > 0 { + Cow::Owned(Utf8Path::new(to).join(r)) + } else { + Cow::Owned(Utf8PathBuf::from(to)) + } + } + _ => Cow::Borrowed(p), + } +} + +/// Convert /usr/etc back to /etc +fn map_path(p: &Utf8Path) -> std::borrow::Cow<'_, Utf8Path> { + map_path_inner(p, "./usr/etc", "./etc") +} + +/// Convert etc to usr/etc +/// Note: no leading '/' or './' +fn unmap_path(p: &Utf8Path) -> std::borrow::Cow<'_, Utf8Path> { + map_path_inner(p, "etc", "usr/etc") +} + +/// Convert usr/etc back to etc for the tar stream. +fn map_path_v1(p: &Utf8Path) -> &Utf8Path { + debug_assert!(!p.starts_with("/") && !p.starts_with(".")); + if p.starts_with("usr/etc") { + p.strip_prefix("usr/").unwrap() + } else { + p + } +} + +/// Given two paths, which may be absolute (starting with /) or +/// start with `./`, return true if they are equal after removing +/// those prefixes. This is effectively "would these paths be equal" +/// when processed as a tar entry. +pub(crate) fn path_equivalent_for_tar(a: impl AsRef, b: impl AsRef) -> bool { + fn strip_prefix(p: &Utf8Path) -> &Utf8Path { + if let Ok(p) = p.strip_prefix("/") { + return p; + } else if let Ok(p) = p.strip_prefix("./") { + return p; + } + p + } + strip_prefix(a.as_ref()) == strip_prefix(b.as_ref()) +} + +struct OstreeTarWriter<'a, W: std::io::Write> { + repo: &'a ostree::Repo, + commit_checksum: &'a str, + commit_object: glib::Variant, + out: &'a mut tar::Builder, + #[allow(dead_code)] + options: ExportOptions, + wrote_initdirs: bool, + /// True if we're only writing directories + structure_only: bool, + wrote_vartmp: bool, // Set if the ostree commit contains /var/tmp + wrote_dirtree: HashSet, + wrote_dirmeta: HashSet, + wrote_content: HashSet, +} + +pub(crate) fn object_path(objtype: ostree::ObjectType, checksum: &str) -> Utf8PathBuf { + let suffix = match objtype { + ostree::ObjectType::Commit => "commit", + ostree::ObjectType::CommitMeta => "commitmeta", + ostree::ObjectType::DirTree => "dirtree", + ostree::ObjectType::DirMeta => "dirmeta", + ostree::ObjectType::File => "file", + o => panic!("Unexpected object type: {o:?}"), + }; + let (first, rest) = checksum.split_at(2); + format!("{OSTREEDIR}/repo/objects/{first}/{rest}.{suffix}").into() +} + +fn v1_xattrs_object_path(checksum: &str) -> Utf8PathBuf { + let (first, rest) = checksum.split_at(2); + format!("{OSTREEDIR}/repo/objects/{first}/{rest}.file-xattrs-link").into() +} + +/// Check for "denormal" symlinks which contain "//" +// See https://github.com/fedora-sysv/chkconfig/pull/67 +// [root@cosa-devsh ~]# rpm -qf /usr/lib/systemd/systemd-sysv-install +// chkconfig-1.13-2.el8.x86_64 +// [root@cosa-devsh ~]# ll /usr/lib/systemd/systemd-sysv-install +// lrwxrwxrwx. 2 root root 24 Nov 29 18:08 /usr/lib/systemd/systemd-sysv-install -> ../../..//sbin/chkconfig +// [root@cosa-devsh ~]# +fn symlink_is_denormal(target: &str) -> bool { + target.contains("//") +} + +pub(crate) fn tar_append_default_data( + out: &mut tar::Builder, + path: &Utf8Path, + buf: &[u8], +) -> Result<()> { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_uid(0); + h.set_gid(0); + h.set_mode(0o644); + h.set_size(buf.len() as u64); + out.append_data(&mut h, path, buf).map_err(Into::into) +} + +impl<'a, W: std::io::Write> OstreeTarWriter<'a, W> { + fn new( + repo: &'a ostree::Repo, + commit_checksum: &'a str, + out: &'a mut tar::Builder, + options: ExportOptions, + ) -> Result { + let commit_object = repo.load_commit(commit_checksum)?.0; + let r = Self { + repo, + commit_checksum, + commit_object, + out, + options, + wrote_initdirs: false, + structure_only: false, + wrote_vartmp: false, + wrote_dirmeta: HashSet::new(), + wrote_dirtree: HashSet::new(), + wrote_content: HashSet::new(), + }; + Ok(r) + } + + /// Convert the ostree mode to tar mode. + /// The ostree mode bits include the format, tar does not. + /// Historically in format version 0 we injected them, so we need to keep doing so. + fn filter_mode(&self, mode: u32) -> u32 { + mode & !libc::S_IFMT + } + + /// Add a directory entry with default permissions (root/root 0755) + fn append_default_dir(&mut self, path: &Utf8Path) -> Result<()> { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Directory); + h.set_uid(0); + h.set_gid(0); + h.set_mode(0o755); + h.set_size(0); + self.out.append_data(&mut h, path, &mut std::io::empty())?; + Ok(()) + } + + /// Add a regular file entry with default permissions (root/root 0644) + fn append_default_data(&mut self, path: &Utf8Path, buf: &[u8]) -> Result<()> { + tar_append_default_data(self.out, path, buf) + } + + /// Write the initial /sysroot/ostree/repo structure. + fn write_repo_structure(&mut self) -> Result<()> { + if self.wrote_initdirs { + return Ok(()); + } + + let objdir: Utf8PathBuf = format!("{OSTREEDIR}/repo/objects").into(); + // Add all parent directories + let parent_dirs = { + let mut parts: Vec<_> = objdir.ancestors().collect(); + parts.reverse(); + parts + }; + for path in parent_dirs { + match path.as_str() { + "/" | "" => continue, + _ => {} + } + self.append_default_dir(path)?; + } + // Object subdirectories + for d in 0..=0xFF { + let path: Utf8PathBuf = format!("{objdir}/{d:02x}").into(); + self.append_default_dir(&path)?; + } + // Standard repo subdirectories. + let subdirs = [ + "extensions", + "refs", + "refs/heads", + "refs/mirrors", + "refs/remotes", + "state", + "tmp", + "tmp/cache", + ]; + for d in subdirs { + let path: Utf8PathBuf = format!("{OSTREEDIR}/repo/{d}").into(); + self.append_default_dir(&path)?; + } + + // Repository configuration file. + { + let path = format!("{OSTREEDIR}/repo/config"); + self.append_default_data(Utf8Path::new(&path), REPO_CONFIG.as_bytes())?; + } + + self.wrote_initdirs = true; + Ok(()) + } + + /// Recursively serialize a commit object to the target tar stream. + fn write_commit(&mut self) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + + let commit_bytes = self.commit_object.data_as_bytes(); + let commit_bytes = commit_bytes.try_as_aligned()?; + let commit = gv_commit!().cast(commit_bytes); + let commit = commit.to_tuple(); + let contents = hex::encode(commit.6); + let metadata_checksum = &hex::encode(commit.7); + let metadata_v = self + .repo + .load_variant(ostree::ObjectType::DirMeta, metadata_checksum)?; + // Safety: We passed the correct variant type just above + let metadata = &ostree::DirMetaParsed::from_variant(&metadata_v).unwrap(); + let rootpath = Utf8Path::new(TAR_PATH_PREFIX_V0); + + // We need to write the root directory, before we write any objects. This should be the very + // first thing. + self.append_dir(rootpath, metadata)?; + + // Now, we create sysroot/ and everything under it + self.write_repo_structure()?; + + self.append_commit_object()?; + + // The ostree dirmeta object for the root. + self.append(ostree::ObjectType::DirMeta, metadata_checksum, &metadata_v)?; + + // Recurse and write everything else. + self.append_dirtree( + Utf8Path::new(TAR_PATH_PREFIX_V0), + contents, + true, + cancellable, + )?; + + self.append_standard_var(cancellable)?; + + Ok(()) + } + + fn append_commit_object(&mut self) -> Result<()> { + self.append( + ostree::ObjectType::Commit, + self.commit_checksum, + &self.commit_object.clone(), + )?; + if let Some(commitmeta) = self + .repo + .read_commit_detached_metadata(self.commit_checksum, gio::Cancellable::NONE)? + { + self.append( + ostree::ObjectType::CommitMeta, + self.commit_checksum, + &commitmeta, + )?; + } + Ok(()) + } + + fn append( + &mut self, + objtype: ostree::ObjectType, + checksum: &str, + v: &glib::Variant, + ) -> Result<()> { + let set = match objtype { + ostree::ObjectType::Commit | ostree::ObjectType::CommitMeta => None, + ostree::ObjectType::DirTree => Some(&mut self.wrote_dirtree), + ostree::ObjectType::DirMeta => Some(&mut self.wrote_dirmeta), + o => panic!("Unexpected object type: {o:?}"), + }; + if let Some(set) = set { + if set.contains(checksum) { + return Ok(()); + } + let inserted = set.insert(checksum.to_string()); + debug_assert!(inserted); + } + + let data = v.data_as_bytes(); + let data = data.as_ref(); + self.append_default_data(&object_path(objtype, checksum), data) + .with_context(|| format!("Writing object {checksum}"))?; + Ok(()) + } + + /// Export xattrs in ostree-container style format, which is a .xattrs file. + /// This is different from xattrs which may appear as e.g. PAX metadata, which we don't use + /// at the moment. + /// + /// Return whether content was written. + #[context("Writing xattrs")] + fn append_ostree_xattrs(&mut self, checksum: &str, xattrs: &glib::Variant) -> Result { + let xattrs_data = xattrs.data_as_bytes(); + let xattrs_data = xattrs_data.as_ref(); + + let path = v1_xattrs_object_path(&checksum); + self.append_default_data(&path, xattrs_data)?; + + Ok(true) + } + + /// Append all xattrs to the tar stream *except* security.selinux, because + /// that one doesn't become visible in `podman run` anyways, so we couldn't + /// rely on it in some cases. + /// https://github.com/containers/storage/blob/0d4a8d2aaf293c9f0464b888d932ab5147a284b9/pkg/archive/archive.go#L85 + #[context("Writing tar xattrs")] + fn append_tarstream_xattrs(&mut self, xattrs: &glib::Variant) -> Result<()> { + let v = xattrs.data_as_bytes(); + let v = v.try_as_aligned().unwrap(); + let v = gvariant::gv!("a(ayay)").cast(v); + let mut pax_extensions = Vec::new(); + for entry in v { + let (k, v) = entry.to_tuple(); + let k = CStr::from_bytes_with_nul(k).unwrap(); + let k = k + .to_str() + .with_context(|| format!("Found non-UTF8 xattr: {k:?}"))?; + if k == SECURITY_SELINUX_XATTR { + continue; + } + pax_extensions.push((format!("SCHILY.xattr.{k}"), v)); + } + self.out + .append_pax_extensions(pax_extensions.iter().map(|(k, v)| (k.as_str(), *v)))?; + Ok(()) + } + + /// Write a content object, returning the path/header that should be used + /// as a hard link to it in the target path. This matches how ostree checkouts work. + fn append_content(&mut self, checksum: &str) -> Result<(Utf8PathBuf, tar::Header)> { + let path = object_path(ostree::ObjectType::File, checksum); + + let (instream, meta, xattrs) = self.repo.load_file(checksum, gio::Cancellable::NONE)?; + + let mut h = tar::Header::new_gnu(); + h.set_uid(meta.attribute_uint32("unix::uid") as u64); + h.set_gid(meta.attribute_uint32("unix::gid") as u64); + let mode = meta.attribute_uint32("unix::mode"); + h.set_mode(self.filter_mode(mode)); + if instream.is_some() { + h.set_entry_type(tar::EntryType::Regular); + h.set_size(meta.size() as u64); + } else { + h.set_entry_type(tar::EntryType::Symlink); + h.set_size(0); + } + if !self.wrote_content.contains(checksum) { + let inserted = self.wrote_content.insert(checksum.to_string()); + debug_assert!(inserted); + + // The xattrs objects need to be exported before the regular object they + // refer to. Otherwise the importing logic won't have the xattrs available + // when importing file content. + self.append_ostree_xattrs(checksum, &xattrs)?; + self.append_tarstream_xattrs(&xattrs)?; + + if let Some(instream) = instream { + ensure!(meta.file_type() == gio::FileType::Regular); + + let mut instream = BufReader::with_capacity(BUF_CAPACITY, instream.into_read()); + self.out + .append_data(&mut h, &path, &mut instream) + .with_context(|| format!("Writing regfile {checksum}"))?; + } else { + ensure!(meta.file_type() == gio::FileType::SymbolicLink); + + let target = meta + .symlink_target() + .ok_or_else(|| anyhow!("Missing symlink target"))?; + let target = target + .to_str() + .ok_or_else(|| anyhow!("Invalid UTF-8 symlink target: {target:?}"))?; + let context = || format!("Writing content symlink: {checksum}"); + // Handle //chkconfig, see above + if symlink_is_denormal(target) { + h.set_link_name_literal(target).with_context(context)?; + self.out + .append_data(&mut h, &path, &mut std::io::empty()) + .with_context(context)?; + } else { + self.out + .append_link(&mut h, &path, target) + .with_context(context)?; + } + } + } + + Ok((path, h)) + } + + /// Write a directory using the provided metadata. + fn append_dir(&mut self, dirpath: &Utf8Path, meta: &ostree::DirMetaParsed) -> Result<()> { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_uid(meta.uid as u64); + header.set_gid(meta.gid as u64); + header.set_mode(self.filter_mode(meta.mode)); + self.out + .append_data(&mut header, dirpath, std::io::empty())?; + Ok(()) + } + + /// Given a source object (in e.g. ostree/repo/objects/...), write a hardlink to it + /// in its expected target path (e.g. `usr/bin/bash`). + fn append_content_hardlink( + &mut self, + srcpath: &Utf8Path, + mut h: tar::Header, + dest: &Utf8Path, + ) -> Result<()> { + // Don't create hardlinks to zero-sized files, it's much more likely + // to result in generated tar streams from container builds resulting + // in a modified linked-to file in /sysroot, which we don't currently handle. + // And in the case where the input is *not* zero sized, we still output + // a hardlink of size zero, as this is what is normal. + let is_regular_zerosized = if h.entry_type() == tar::EntryType::Regular { + let size = h.size().context("Querying size for hardlink append")?; + size == 0 + } else { + false + }; + // Link sizes shoud always be zero + h.set_size(0); + if is_regular_zerosized { + self.out.append_data(&mut h, dest, &mut std::io::empty())?; + } else { + h.set_entry_type(tar::EntryType::Link); + h.set_link_name(srcpath)?; + self.out.append_data(&mut h, dest, &mut std::io::empty())?; + } + Ok(()) + } + + /// Write a dirtree object. + fn append_dirtree>( + &mut self, + dirpath: &Utf8Path, + checksum: String, + is_root: bool, + cancellable: Option<&C>, + ) -> Result<()> { + let v = &self + .repo + .load_variant(ostree::ObjectType::DirTree, &checksum)?; + self.append(ostree::ObjectType::DirTree, &checksum, v)?; + drop(checksum); + let v = v.data_as_bytes(); + let v = v.try_as_aligned()?; + let v = gv_dirtree!().cast(v); + let (files, dirs) = v.to_tuple(); + + if let Some(c) = cancellable { + c.set_error_if_cancelled()?; + } + + if !self.structure_only { + for file in files { + let (name, csum) = file.to_tuple(); + let name = name.to_str(); + let checksum = &hex::encode(csum); + let (objpath, h) = self.append_content(checksum)?; + let subpath = &dirpath.join(name); + let subpath = map_path(subpath); + self.append_content_hardlink(&objpath, h, &subpath) + .with_context(|| format!("Hardlinking {checksum} to {subpath}"))?; + } + } + + // Record if the ostree commit includes /var/tmp; if so we don't need to synthesize + // it in `append_standard_var()`. + if path_equivalent_for_tar(dirpath, "var/tmp") { + self.wrote_vartmp = true; + } + + for item in dirs { + let (name, contents_csum, meta_csum) = item.to_tuple(); + let name = name.to_str(); + let metadata = { + let meta_csum = &hex::encode(meta_csum); + let meta_v = &self + .repo + .load_variant(ostree::ObjectType::DirMeta, meta_csum)?; + self.append(ostree::ObjectType::DirMeta, meta_csum, meta_v)?; + // Safety: We passed the correct variant type just above + ostree::DirMetaParsed::from_variant(meta_v).unwrap() + }; + // Special hack because tar stream for containers can't have duplicates. + if is_root && name == SYSROOT { + continue; + } + let dirtree_csum = hex::encode(contents_csum); + let subpath = &dirpath.join(name); + let subpath = map_path(subpath); + self.append_dir(&subpath, &metadata)?; + self.append_dirtree(&subpath, dirtree_csum, false, cancellable)?; + } + + Ok(()) + } + + /// Generate e.g. `/var/tmp`. + /// + /// In the OSTree model we expect `/var` to start out empty, and be populated via + /// e.g. `systemd-tmpfiles`. But, systemd doesn't run in Docker-style containers by default. + /// + /// So, this function creates a few critical directories in `/var` by default. + fn append_standard_var(&mut self, cancellable: Option<&gio::Cancellable>) -> Result<()> { + // If the commit included /var/tmp, then it's already in the tar stream. + if self.wrote_vartmp { + return Ok(()); + } + if let Some(c) = cancellable { + c.set_error_if_cancelled()?; + } + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_uid(0); + header.set_gid(0); + header.set_mode(self.filter_mode(libc::S_IFDIR | 0o1777)); + self.out + .append_data(&mut header, "var/tmp", std::io::empty())?; + Ok(()) + } + + fn write_parents_of( + &mut self, + path: &Utf8Path, + root: &gio::File, + cache: &mut HashSet, + ) -> Result<()> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + + if parent.components().count() == 0 { + return Ok(()); + } + + if cache.contains(parent) { + return Ok(()); + } + + self.write_parents_of(parent, root, cache)?; + + let inserted = cache.insert(parent.to_owned()); + debug_assert!(inserted); + + let parent_file = root.resolve_relative_path(unmap_path(parent).as_ref()); + let queryattrs = "unix::*"; + let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let stat = parent_file.query_info(&queryattrs, queryflags, gio::Cancellable::NONE)?; + let uid = stat.attribute_uint32(gio::FILE_ATTRIBUTE_UNIX_UID); + let gid = stat.attribute_uint32(gio::FILE_ATTRIBUTE_UNIX_GID); + let orig_mode = stat.attribute_uint32(gio::FILE_ATTRIBUTE_UNIX_MODE); + let mode = self.filter_mode(orig_mode); + + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_uid(uid as u64); + header.set_gid(gid as u64); + header.set_mode(mode); + self.out + .append_data(&mut header, parent, std::io::empty())?; + Ok(()) + } +} + +/// Recursively walk an OSTree commit and generate data into a `[tar::Builder]` +/// which contains all of the metadata objects, as well as a hardlinked +/// stream that looks like a checkout. Extended attributes are stored specially out +/// of band of tar so that they can be reliably retrieved. +fn impl_export( + repo: &ostree::Repo, + commit_checksum: &str, + out: &mut tar::Builder, + options: ExportOptions, +) -> Result<()> { + let writer = &mut OstreeTarWriter::new(repo, commit_checksum, out, options)?; + writer.write_commit()?; + Ok(()) +} + +/// Configuration for tar export. +#[derive(Debug, PartialEq, Eq, Default)] +pub struct ExportOptions; + +/// Export an ostree commit to an (uncompressed) tar archive stream. +#[context("Exporting commit")] +pub fn export_commit( + repo: &ostree::Repo, + rev: &str, + out: impl std::io::Write, + options: Option, +) -> Result<()> { + let commit = repo.require_rev(rev)?; + let mut tar = tar::Builder::new(out); + let options = options.unwrap_or_default(); + impl_export(repo, commit.as_str(), &mut tar, options)?; + tar.finish()?; + Ok(()) +} + +/// Chunked (or version 1) tar streams don't have a leading `./`. +fn path_for_tar_v1(p: &Utf8Path) -> &Utf8Path { + debug_assert!(!p.starts_with(".")); + map_path_v1(p.strip_prefix("/").unwrap_or(p)) +} + +/// Implementation of chunk writing, assumes that the preliminary structure +/// has been written to the tar stream. +fn write_chunk( + writer: &mut OstreeTarWriter, + chunk: chunking::ChunkMapping, + create_parent_dirs: bool, +) -> Result<()> { + let mut cache = std::collections::HashSet::new(); + let root = writer + .repo + .read_commit(&writer.commit_checksum, gio::Cancellable::NONE)? + .0; + for (checksum, (_size, paths)) in chunk.into_iter() { + let (objpath, h) = writer.append_content(checksum.borrow())?; + for path in paths.iter() { + let path = path_for_tar_v1(path); + let h = h.clone(); + if create_parent_dirs { + writer.write_parents_of(&path, &root, &mut cache)?; + } + writer.append_content_hardlink(&objpath, h, path)?; + } + } + Ok(()) +} + +/// Output a chunk to a tar stream. +pub(crate) fn export_chunk( + repo: &ostree::Repo, + commit: &str, + chunk: chunking::ChunkMapping, + out: &mut tar::Builder, + create_parent_dirs: bool, +) -> Result<()> { + // For chunking, we default to format version 1 + #[allow(clippy::needless_update)] + let opts = ExportOptions; + let writer = &mut OstreeTarWriter::new(repo, commit, out, opts)?; + writer.write_repo_structure()?; + write_chunk(writer, chunk, create_parent_dirs) +} + +/// Output the last chunk in a chunking. +#[context("Exporting final chunk")] +pub(crate) fn export_final_chunk( + repo: &ostree::Repo, + commit_checksum: &str, + remainder: chunking::Chunk, + out: &mut tar::Builder, + create_parent_dirs: bool, +) -> Result<()> { + let options = ExportOptions; + let writer = &mut OstreeTarWriter::new(repo, commit_checksum, out, options)?; + // For the final chunk, output the commit object, plus all ostree metadata objects along with + // the containing directories. + writer.structure_only = true; + writer.write_commit()?; + writer.structure_only = false; + write_chunk(writer, remainder.content, create_parent_dirs) +} + +/// Process an exported tar stream, and update the detached metadata. +#[allow(clippy::while_let_on_iterator)] +#[context("Replacing detached metadata")] +pub(crate) fn reinject_detached_metadata>( + src: &mut tar::Archive, + dest: &mut tar::Builder, + detached_buf: Option<&[u8]>, + cancellable: Option<&C>, +) -> Result<()> { + let mut entries = src.entries()?; + let mut commit_ent = None; + // Loop through the tar stream until we find the commit object; copy all prior entries + // such as the baseline directory structure. + while let Some(entry) = entries.next() { + if let Some(c) = cancellable { + c.set_error_if_cancelled()?; + } + let entry = entry?; + let header = entry.header(); + let path = entry.path()?; + let path: &Utf8Path = (&*path).try_into()?; + if !(header.entry_type() == tar::EntryType::Regular && path.as_str().ends_with(".commit")) { + crate::tar::write::copy_entry(entry, dest, None)?; + } else { + commit_ent = Some(entry); + break; + } + } + let commit_ent = commit_ent.ok_or_else(|| anyhow!("Missing commit object"))?; + let commit_path = commit_ent.path()?; + let commit_path = Utf8Path::from_path(&commit_path) + .ok_or_else(|| anyhow!("Invalid non-utf8 path {:?}", commit_path))?; + let (checksum, objtype) = crate::tar::import::Importer::parse_metadata_entry(commit_path)?; + assert_eq!(objtype, ostree::ObjectType::Commit); // Should have been verified above + crate::tar::write::copy_entry(commit_ent, dest, None)?; + + // If provided, inject our new detached metadata object + if let Some(detached_buf) = detached_buf { + let detached_path = object_path(ostree::ObjectType::CommitMeta, &checksum); + tar_append_default_data(dest, &detached_path, detached_buf)?; + } + + // If the next entry is detached metadata, then drop it since we wrote a new one + let next_ent = entries + .next() + .ok_or_else(|| anyhow!("Expected metadata object after commit"))??; + let next_ent_path = next_ent.path()?; + let next_ent_path: &Utf8Path = (&*next_ent_path).try_into()?; + let objtype = crate::tar::import::Importer::parse_metadata_entry(next_ent_path)?.1; + if objtype != ostree::ObjectType::CommitMeta { + crate::tar::write::copy_entry(next_ent, dest, None)?; + } + + // Finally, copy all remaining entries. + while let Some(entry) = entries.next() { + if let Some(c) = cancellable { + c.set_error_if_cancelled()?; + } + crate::tar::write::copy_entry(entry?, dest, None)?; + } + + Ok(()) +} + +/// Replace the detached metadata in an tar stream which is an export of an OSTree commit. +pub fn update_detached_metadata>( + src: impl std::io::Read, + dest: D, + detached_buf: Option<&[u8]>, + cancellable: Option<&C>, +) -> Result { + let mut src = tar::Archive::new(src); + let mut dest = tar::Builder::new(dest); + reinject_detached_metadata(&mut src, &mut dest, detached_buf, cancellable)?; + dest.into_inner().map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_path_equivalent() { + assert!(path_equivalent_for_tar("var/tmp", "./var/tmp")); + assert!(path_equivalent_for_tar("./var/tmp", "var/tmp")); + assert!(path_equivalent_for_tar("/var/tmp", "var/tmp")); + assert!(!path_equivalent_for_tar("var/tmp", "var")); + } + + #[test] + fn test_map_path() { + assert_eq!( + map_path("/".into()).as_os_str(), + Utf8Path::new("/").as_os_str() + ); + assert_eq!( + map_path("./usr/etc/blah".into()).as_os_str(), + Utf8Path::new("./etc/blah").as_os_str() + ); + for unchanged in ["boot", "usr/bin", "usr/lib/foo"].iter().map(Utf8Path::new) { + assert_eq!(unchanged.as_os_str(), map_path_v1(unchanged).as_os_str()); + } + + assert_eq!( + Utf8Path::new("etc").as_os_str(), + map_path_v1(Utf8Path::new("usr/etc")).as_os_str() + ); + assert_eq!( + Utf8Path::new("etc/foo").as_os_str(), + map_path_v1(Utf8Path::new("usr/etc/foo")).as_os_str() + ); + } + + #[test] + fn test_unmap_path() { + assert_eq!( + unmap_path("/".into()).as_os_str(), + Utf8Path::new("/").as_os_str() + ); + assert_eq!( + unmap_path("/etc".into()).as_os_str(), + Utf8Path::new("/etc").as_os_str() + ); + assert_eq!( + unmap_path("/usr/etc".into()).as_os_str(), + Utf8Path::new("/usr/etc").as_os_str() + ); + assert_eq!( + unmap_path("usr/etc".into()).as_os_str(), + Utf8Path::new("usr/etc").as_os_str() + ); + assert_eq!( + unmap_path("etc".into()).as_os_str(), + Utf8Path::new("usr/etc").as_os_str() + ); + assert_eq!( + unmap_path("etc/blah".into()).as_os_str(), + Utf8Path::new("usr/etc/blah").as_os_str() + ); + } + + #[test] + fn test_denormal_symlink() { + let normal = ["/", "/usr", "../usr/bin/blah"]; + let denormal = ["../../usr/sbin//chkconfig", "foo//bar/baz"]; + for path in normal { + assert!(!symlink_is_denormal(path)); + } + for path in denormal { + assert!(symlink_is_denormal(path)); + } + } + + #[test] + fn test_v1_xattrs_object_path() { + let checksum = "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7"; + let expected = "sysroot/ostree/repo/objects/b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs-link"; + let output = v1_xattrs_object_path(checksum); + assert_eq!(&output, expected); + } +} diff --git a/crates/ostree-ext/src/tar/import.rs b/crates/ostree-ext/src/tar/import.rs new file mode 100644 index 000000000..74fdeee64 --- /dev/null +++ b/crates/ostree-ext/src/tar/import.rs @@ -0,0 +1,940 @@ +//! APIs for extracting OSTree commits from container images + +use crate::Result; +use anyhow::{anyhow, bail, ensure, Context}; +use camino::Utf8Path; +use camino::Utf8PathBuf; +use fn_error_context::context; +use gio::glib; +use gio::prelude::*; +use glib::Variant; +use ostree::gio; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::io::prelude::*; +use tracing::{event, instrument, Level}; + +/// Arbitrary limit on xattrs to avoid RAM exhaustion attacks. The actual filesystem limits are often much smaller. +// See https://en.wikipedia.org/wiki/Extended_file_attributes +// For example, XFS limits to 614 KiB. +const MAX_XATTR_SIZE: u32 = 1024 * 1024; +/// Limit on metadata objects (dirtree/dirmeta); this is copied +/// from ostree-core.h. TODO: Bind this in introspection +const MAX_METADATA_SIZE: u32 = 10 * 1024 * 1024; + +/// Upper size limit for "small" regular files. +// https://stackoverflow.com/questions/258091/when-should-i-use-mmap-for-file-access +pub(crate) const SMALL_REGFILE_SIZE: usize = 127 * 1024; + +// The prefix for filenames that contain content we actually look at. +pub(crate) const REPO_PREFIX: &str = "sysroot/ostree/repo/"; +/// Statistics from import. +#[derive(Debug, Default)] +struct ImportStats { + dirtree: u32, + dirmeta: u32, + regfile_small: u32, + regfile_large: u32, + symlinks: u32, +} + +enum ImporterMode { + Commit(Option), + ObjectSet(BTreeSet), +} + +/// Importer machine. +pub(crate) struct Importer { + repo: ostree::Repo, + remote: Option, + verify_text: Option, + // Cache of xattrs, keyed by their content checksum. + xattrs: HashMap, + // Reusable buffer for xattrs references. It maps a file checksum (.0) + // to an xattrs checksum (.1) in the `xattrs` cache above. + next_xattrs: Option<(String, String)>, + + // Reusable buffer for reads. See also https://github.com/rust-lang/rust/issues/78485 + buf: Vec, + + stats: ImportStats, + + /// Additional state depending on whether we're importing an object set or a commit. + data: ImporterMode, +} + +/// Validate size/type of a tar header for OSTree metadata object. +fn validate_metadata_header(header: &tar::Header, desc: &str) -> Result { + if header.entry_type() != tar::EntryType::Regular { + return Err(anyhow!("Invalid non-regular metadata object {}", desc)); + } + let size = header.size()?; + let max_size = MAX_METADATA_SIZE as u64; + if size > max_size { + return Err(anyhow!( + "object of size {} exceeds {} bytes", + size, + max_size + )); + } + Ok(size as usize) +} + +fn header_attrs(header: &tar::Header) -> Result<(u32, u32, u32)> { + let uid: u32 = header.uid()?.try_into()?; + let gid: u32 = header.gid()?.try_into()?; + let mode: u32 = header.mode()?; + Ok((uid, gid, mode)) +} + +// The C function ostree_object_type_from_string aborts on +// unknown strings, so we have a safe version here. +fn objtype_from_string(t: &str) -> Option { + Some(match t { + "commit" => ostree::ObjectType::Commit, + "commitmeta" => ostree::ObjectType::CommitMeta, + "dirtree" => ostree::ObjectType::DirTree, + "dirmeta" => ostree::ObjectType::DirMeta, + "file" => ostree::ObjectType::File, + _ => return None, + }) +} + +/// Given a tar entry, read it all into a GVariant +fn entry_to_variant( + mut entry: tar::Entry, + desc: &str, +) -> Result { + let header = entry.header(); + let size = validate_metadata_header(header, desc)?; + + let mut buf: Vec = Vec::with_capacity(size); + let n = std::io::copy(&mut entry, &mut buf)?; + assert_eq!(n as usize, size); + let v = glib::Bytes::from_owned(buf); + let v = Variant::from_bytes::(&v); + Ok(v.normal_form()) +} + +/// Parse an object path into (parent, rest, objtype). +/// +/// Normal ostree object paths look like 00/1234.commit. +/// In the tar format, we may also see 00/1234.file.xattrs. +fn parse_object_entry_path(path: &Utf8Path) -> Result<(&str, &Utf8Path, &str)> { + // The "sharded" commit directory. + let parentname = path + .parent() + .and_then(|p| p.file_name()) + .ok_or_else(|| anyhow!("Invalid path (no parent) {}", path))?; + #[allow(clippy::needless_as_bytes)] + if !(parentname.is_ascii() && parentname.as_bytes().len() == 2) { + return Err(anyhow!("Invalid checksum parent {}", parentname)); + } + let name = path + .file_name() + .map(Utf8Path::new) + .ok_or_else(|| anyhow!("Invalid path (dir) {}", path))?; + let objtype = name + .extension() + .ok_or_else(|| anyhow!("Invalid objpath {}", path))?; + + Ok((parentname, name, objtype)) +} + +fn parse_checksum(parent: &str, name: &Utf8Path) -> Result { + let checksum_rest = name + .file_stem() + .ok_or_else(|| anyhow!("Invalid object path part {}", name))?; + // Also take care of the double extension on `.file.xattrs`. + let checksum_rest = checksum_rest.trim_end_matches(".file"); + + #[allow(clippy::needless_as_bytes)] + if !(checksum_rest.is_ascii() && checksum_rest.as_bytes().len() == 62) { + return Err(anyhow!("Invalid checksum part {}", checksum_rest)); + } + let reassembled = format!("{parent}{checksum_rest}"); + validate_sha256(reassembled) +} + +/// Parse a `.file-xattrs-link` link target into the corresponding checksum. +fn parse_xattrs_link_target(path: &Utf8Path) -> Result { + let (parent, rest, _objtype) = parse_object_entry_path(path)?; + parse_checksum(parent, rest) +} + +impl Importer { + /// Create an importer which will import an OSTree commit object. + pub(crate) fn new_for_commit(repo: &ostree::Repo, remote: Option) -> Self { + Self { + repo: repo.clone(), + remote, + verify_text: None, + buf: vec![0u8; 16384], + xattrs: Default::default(), + next_xattrs: None, + stats: Default::default(), + data: ImporterMode::Commit(None), + } + } + + /// Create an importer to write an "object set"; a chunk of objects which is + /// usually streamed from a separate storage system, such as an OCI container image layer. + pub(crate) fn new_for_object_set(repo: &ostree::Repo) -> Self { + Self { + repo: repo.clone(), + remote: None, + verify_text: None, + buf: vec![0u8; 16384], + xattrs: Default::default(), + next_xattrs: None, + stats: Default::default(), + data: ImporterMode::ObjectSet(Default::default()), + } + } + + // Given a tar entry, filter it out if it doesn't look like an object file in + // `/sysroot/ostree`. + // It is an error if the filename is invalid UTF-8. If it is valid UTF-8, return + // an owned copy of the path. + fn filter_entry( + e: tar::Entry, + ) -> Result, Utf8PathBuf)>> { + if e.header().entry_type() == tar::EntryType::Directory { + return Ok(None); + } + let orig_path = e.path()?; + let path = Utf8Path::from_path(&orig_path) + .ok_or_else(|| anyhow!("Invalid non-utf8 path {:?}", orig_path))?; + // Ignore the regular non-object file hardlinks we inject + if let Ok(path) = path.strip_prefix(REPO_PREFIX) { + // Filter out the repo config file and refs dir + if path.file_name() == Some("config") || path.starts_with("refs") { + return Ok(None); + } + let path = path.into(); + Ok(Some((e, path))) + } else { + Ok(None) + } + } + + pub(crate) fn parse_metadata_entry(path: &Utf8Path) -> Result<(String, ostree::ObjectType)> { + let (parentname, name, objtype) = parse_object_entry_path(path)?; + let checksum = parse_checksum(parentname, name)?; + let objtype = objtype_from_string(objtype) + .ok_or_else(|| anyhow!("Invalid object type {}", objtype))?; + Ok((checksum, objtype)) + } + + /// Import a metadata object. + #[context("Importing metadata object")] + fn import_metadata( + &mut self, + entry: tar::Entry, + checksum: &str, + objtype: ostree::ObjectType, + ) -> Result<()> { + let v = match objtype { + ostree::ObjectType::DirTree => { + self.stats.dirtree += 1; + entry_to_variant::<_, ostree::TreeVariantType>(entry, checksum)? + } + ostree::ObjectType::DirMeta => { + self.stats.dirmeta += 1; + entry_to_variant::<_, ostree::DirmetaVariantType>(entry, checksum)? + } + o => return Err(anyhow!("Invalid metadata object type; {:?}", o)), + }; + // FIXME validate here that this checksum was in the set we expected. + // https://github.com/ostreedev/ostree-rs-ext/issues/1 + let actual = + self.repo + .write_metadata(objtype, Some(checksum), &v, gio::Cancellable::NONE)?; + assert_eq!(actual.to_hex(), checksum); + Ok(()) + } + + /// Import a content object, large regular file flavour. + #[context("Importing regfile")] + fn import_large_regfile_object( + &mut self, + mut entry: tar::Entry, + size: usize, + checksum: &str, + xattrs: glib::Variant, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + let (uid, gid, mode) = header_attrs(entry.header())?; + let w = self.repo.write_regfile( + Some(checksum), + uid, + gid, + libc::S_IFREG | mode, + size as u64, + Some(&xattrs), + )?; + { + let w = w.clone().upcast::(); + loop { + let n = entry + .read(&mut self.buf[..]) + .context("Reading large regfile")?; + if n == 0 { + break; + } + w.write(&self.buf[0..n], cancellable) + .context("Writing large regfile")?; + } + } + let c = w.finish(cancellable)?; + debug_assert_eq!(c, checksum); + self.stats.regfile_large += 1; + Ok(()) + } + + /// Import a content object, small regular file flavour. + #[context("Importing regfile small")] + fn import_small_regfile_object( + &mut self, + mut entry: tar::Entry, + size: usize, + checksum: &str, + xattrs: glib::Variant, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + let (uid, gid, mode) = header_attrs(entry.header())?; + assert!(size <= SMALL_REGFILE_SIZE); + let mut buf = vec![0u8; size]; + entry.read_exact(&mut buf[..])?; + let c = self.repo.write_regfile_inline( + Some(checksum), + uid, + gid, + libc::S_IFREG | mode, + Some(&xattrs), + &buf, + cancellable, + )?; + debug_assert_eq!(c.as_str(), checksum); + self.stats.regfile_small += 1; + Ok(()) + } + + /// Import a content object, symlink flavour. + #[context("Importing symlink")] + fn import_symlink_object( + &mut self, + entry: tar::Entry, + checksum: &str, + xattrs: glib::Variant, + ) -> Result<()> { + let (uid, gid, _) = header_attrs(entry.header())?; + let target = entry + .link_name()? + .ok_or_else(|| anyhow!("Invalid symlink"))?; + let target = target + .as_os_str() + .to_str() + .ok_or_else(|| anyhow!("Non-utf8 symlink"))?; + let c = self.repo.write_symlink( + Some(checksum), + uid, + gid, + Some(&xattrs), + target, + gio::Cancellable::NONE, + )?; + debug_assert_eq!(c.as_str(), checksum); + self.stats.symlinks += 1; + Ok(()) + } + + /// Import a content object. + #[context("Processing content object {}", checksum)] + fn import_content_object( + &mut self, + entry: tar::Entry, + checksum: &str, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + let size: usize = entry.header().size()?.try_into()?; + + // Pop the queued xattrs reference. + let (file_csum, xattrs_csum) = self + .next_xattrs + .take() + .ok_or_else(|| anyhow!("Missing xattrs reference"))?; + if checksum != file_csum { + return Err(anyhow!("Object mismatch, found xattrs for {}", file_csum)); + } + + if self + .repo + .has_object(ostree::ObjectType::File, checksum, cancellable)? + { + return Ok(()); + } + + // Retrieve xattrs content from the cache. + let xattrs = self + .xattrs + .get(&xattrs_csum) + .cloned() + .ok_or_else(|| anyhow!("Failed to find xattrs content {}", xattrs_csum,))?; + + match entry.header().entry_type() { + tar::EntryType::Regular => { + if size > SMALL_REGFILE_SIZE { + self.import_large_regfile_object(entry, size, checksum, xattrs, cancellable) + } else { + self.import_small_regfile_object(entry, size, checksum, xattrs, cancellable) + } + } + tar::EntryType::Symlink => self.import_symlink_object(entry, checksum, xattrs), + o => Err(anyhow!("Invalid tar entry of type {:?}", o)), + } + } + + /// Given a tar entry that looks like an object (its path is under ostree/repo/objects/), + /// determine its type and import it. + #[context("Importing object {}", path)] + fn import_object( + &mut self, + entry: tar::Entry<'_, R>, + path: &Utf8Path, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + let (parentname, name, suffix) = parse_object_entry_path(path)?; + let checksum = parse_checksum(parentname, name)?; + + match suffix { + "commit" => Err(anyhow!("Found multiple commit objects")), + "file" => { + self.import_content_object(entry, &checksum, cancellable)?; + // Track the objects we wrote + match &mut self.data { + ImporterMode::ObjectSet(imported) => { + if let Some(p) = imported.replace(checksum) { + anyhow::bail!("Duplicate object: {}", p); + } + } + ImporterMode::Commit(_) => {} + } + Ok(()) + } + "file-xattrs" => self.process_file_xattrs(entry, checksum), + "file-xattrs-link" => self.process_file_xattrs_link(entry, checksum), + "xattrs" => self.process_xattr_ref(entry, checksum), + kind => { + let objtype = objtype_from_string(kind) + .ok_or_else(|| anyhow!("Invalid object type {}", kind))?; + match &mut self.data { + ImporterMode::ObjectSet(_) => { + anyhow::bail!( + "Found metadata object {}.{:?} in object set mode", + checksum, + objtype + ); + } + ImporterMode::Commit(_) => {} + } + self.import_metadata(entry, &checksum, objtype) + } + } + } + + /// Process a `.file-xattrs` object (v1). + #[context("Processing file xattrs")] + fn process_file_xattrs( + &mut self, + entry: tar::Entry, + checksum: String, + ) -> Result<()> { + self.cache_xattrs_content(entry, Some(checksum))?; + Ok(()) + } + + /// Process a `.file-xattrs-link` object (v1). + /// + /// This is an hardlink that contains extended attributes for a content object. + /// When the max hardlink count is reached, this object may also be encoded as + /// a regular file instead. + #[context("Processing xattrs link")] + fn process_file_xattrs_link( + &mut self, + entry: tar::Entry, + checksum: String, + ) -> Result<()> { + use tar::EntryType::{Link, Regular}; + if let Some(prev) = &self.next_xattrs { + bail!( + "Found previous dangling xattrs for file object '{}'", + prev.0 + ); + } + + // Extract the xattrs checksum from the link target or from the content (v1). + // Later, it will be used as the key for a lookup into the `self.xattrs` cache. + let xattrs_checksum = match entry.header().entry_type() { + Link => { + let link_target = entry + .link_name()? + .ok_or_else(|| anyhow!("No xattrs link content for {}", checksum))?; + let xattr_target = Utf8Path::from_path(&link_target) + .ok_or_else(|| anyhow!("Invalid non-UTF8 xattrs link {}", checksum))?; + parse_xattrs_link_target(xattr_target)? + } + Regular => self.cache_xattrs_content(entry, None)?, + x => bail!("Unexpected xattrs type '{:?}' found for {}", x, checksum), + }; + + // Now xattrs are properly cached for the next content object in the stream, + // which should match `checksum`. + self.next_xattrs = Some((checksum, xattrs_checksum)); + + Ok(()) + } + + /// Process a `.file.xattrs` entry (v0). + /// + /// This is an hardlink that contains extended attributes for a content object. + #[context("Processing xattrs reference")] + fn process_xattr_ref( + &mut self, + entry: tar::Entry, + target: String, + ) -> Result<()> { + if let Some(prev) = &self.next_xattrs { + bail!( + "Found previous dangling xattrs for file object '{}'", + prev.0 + ); + } + + // Parse the xattrs checksum from the link target (v0). + // Later, it will be used as the key for a lookup into the `self.xattrs` cache. + let header = entry.header(); + if header.entry_type() != tar::EntryType::Link { + bail!("Non-hardlink xattrs reference found for {}", target); + } + let xattr_target = entry + .link_name()? + .ok_or_else(|| anyhow!("No xattrs link content for {}", target))?; + let xattr_target = Utf8Path::from_path(&xattr_target) + .ok_or_else(|| anyhow!("Invalid non-UTF8 xattrs link {}", target))?; + let xattr_target = xattr_target + .file_name() + .ok_or_else(|| anyhow!("Invalid xattrs link {}", target))? + .to_string(); + let xattrs_checksum = validate_sha256(xattr_target)?; + + // Now xattrs are properly cached for the next content object in the stream, + // which should match `checksum`. + self.next_xattrs = Some((target, xattrs_checksum)); + + Ok(()) + } + + /// Process a special /xattrs/ entry, with checksum of xattrs content (v0). + fn process_split_xattrs_content( + &mut self, + entry: tar::Entry, + ) -> Result<()> { + let checksum = { + let path = entry.path()?; + let name = path + .file_name() + .ok_or_else(|| anyhow!("Invalid xattrs dir: {:?}", path))?; + let name = name + .to_str() + .ok_or_else(|| anyhow!("Invalid non-UTF8 xattrs name: {:?}", name))?; + validate_sha256(name.to_string())? + }; + self.cache_xattrs_content(entry, Some(checksum))?; + Ok(()) + } + + /// Read an xattrs entry and cache its content, optionally validating its checksum. + /// + /// This returns the computed checksum for the successfully cached content. + fn cache_xattrs_content( + &mut self, + mut entry: tar::Entry, + expected_checksum: Option, + ) -> Result { + let header = entry.header(); + if header.entry_type() != tar::EntryType::Regular { + return Err(anyhow!( + "Invalid xattr entry of type {:?}", + header.entry_type() + )); + } + let n = header.size()?; + if n > MAX_XATTR_SIZE as u64 { + return Err(anyhow!("Invalid xattr size {}", n)); + } + + let mut contents = vec![0u8; n as usize]; + entry.read_exact(contents.as_mut_slice())?; + let data: glib::Bytes = contents.as_slice().into(); + let xattrs_checksum = { + let digest = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), &data)?; + hex::encode(digest) + }; + if let Some(input) = expected_checksum { + ensure!( + input == xattrs_checksum, + "Checksum mismatch, expected '{}' but computed '{}'", + input, + xattrs_checksum + ); + } + + let contents = Variant::from_bytes::<&[(&[u8], &[u8])]>(&data); + self.xattrs.insert(xattrs_checksum.clone(), contents); + Ok(xattrs_checksum) + } + + fn import_objects_impl<'a>( + &mut self, + ents: impl Iterator, Utf8PathBuf)>>, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + for entry in ents { + let (entry, path) = entry?; + if let Ok(p) = path.strip_prefix("objects/") { + self.import_object(entry, p, cancellable)?; + } else if path.strip_prefix("xattrs/").is_ok() { + self.process_split_xattrs_content(entry)?; + } + } + Ok(()) + } + + #[context("Importing objects")] + pub(crate) fn import_objects( + &mut self, + archive: &mut tar::Archive, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + let ents = archive.entries()?.filter_map(|e| match e { + Ok(e) => Self::filter_entry(e).transpose(), + Err(e) => Some(Err(anyhow::Error::msg(e))), + }); + self.import_objects_impl(ents, cancellable) + } + + #[context("Importing commit")] + pub(crate) fn import_commit( + &mut self, + archive: &mut tar::Archive, + cancellable: Option<&gio::Cancellable>, + ) -> Result<()> { + // This can only be invoked once + assert!(matches!(self.data, ImporterMode::Commit(None))); + // Create an iterator that skips over directories; we just care about the file names. + let mut ents = archive.entries()?.filter_map(|e| match e { + Ok(e) => Self::filter_entry(e).transpose(), + Err(e) => Some(Err(anyhow::Error::msg(e))), + }); + // Read the commit object. + let (commit_ent, commit_path) = ents + .next() + .ok_or_else(|| anyhow!("Commit object not found"))??; + + if commit_ent.header().entry_type() != tar::EntryType::Regular { + return Err(anyhow!( + "Expected regular file for commit object, not {:?}", + commit_ent.header().entry_type() + )); + } + let (checksum, objtype) = Self::parse_metadata_entry(&commit_path)?; + if objtype != ostree::ObjectType::Commit { + return Err(anyhow!("Expected commit object, not {:?}", objtype)); + } + let commit = entry_to_variant::<_, ostree::CommitVariantType>(commit_ent, &checksum)?; + + let (next_ent, nextent_path) = ents + .next() + .ok_or_else(|| anyhow!("End of stream after commit object"))??; + let (next_checksum, next_objtype) = Self::parse_metadata_entry(&nextent_path)?; + + if let Some(remote) = self.remote.as_deref() { + if next_objtype != ostree::ObjectType::CommitMeta { + return Err(anyhow!( + "Using remote {} for verification; Expected commitmeta object, not {:?}", + remote, + next_objtype + )); + } + if next_checksum != checksum { + return Err(anyhow!( + "Expected commitmeta checksum {}, found {}", + checksum, + next_checksum + )); + } + let commitmeta = entry_to_variant::<_, std::collections::HashMap>( + next_ent, + &next_checksum, + )?; + + // Now that we have both the commit and detached metadata in memory, verify that + // the signatures in the detached metadata correctly sign the commit. + self.verify_text = Some( + self.repo + .signature_verify_commit_data( + remote, + &commit.data_as_bytes(), + &commitmeta.data_as_bytes(), + ostree::RepoVerifyFlags::empty(), + ) + .context("Verifying ostree commit in tar stream")? + .into(), + ); + + self.repo.mark_commit_partial(&checksum, true)?; + + // Write the commit object, which also verifies its checksum. + let actual_checksum = + self.repo + .write_metadata(objtype, Some(&checksum), &commit, cancellable)?; + assert_eq!(actual_checksum.to_hex(), checksum); + event!(Level::DEBUG, "Imported {}.commit", checksum); + + // Finally, write the detached metadata. + self.repo + .write_commit_detached_metadata(&checksum, Some(&commitmeta), cancellable)?; + } else { + self.repo.mark_commit_partial(&checksum, true)?; + + // We're not doing any validation of the commit, so go ahead and write it. + let actual_checksum = + self.repo + .write_metadata(objtype, Some(&checksum), &commit, cancellable)?; + assert_eq!(actual_checksum.to_hex(), checksum); + event!(Level::DEBUG, "Imported {}.commit", checksum); + + // Write the next object, whether it's commit metadata or not. + let (meta_checksum, meta_objtype) = Self::parse_metadata_entry(&nextent_path)?; + match meta_objtype { + ostree::ObjectType::CommitMeta => { + let commitmeta = entry_to_variant::< + _, + std::collections::HashMap, + >(next_ent, &meta_checksum)?; + self.repo.write_commit_detached_metadata( + &checksum, + Some(&commitmeta), + gio::Cancellable::NONE, + )?; + } + _ => { + self.import_object(next_ent, &nextent_path, cancellable)?; + } + } + } + match &mut self.data { + ImporterMode::Commit(c) => { + c.replace(checksum); + } + ImporterMode::ObjectSet(_) => unreachable!(), + } + + self.import_objects_impl(ents, cancellable)?; + + Ok(()) + } + + pub(crate) fn finish_import_commit(self) -> (String, Option) { + tracing::debug!("Import stats: {:?}", self.stats); + match self.data { + ImporterMode::Commit(c) => (c.unwrap(), self.verify_text), + ImporterMode::ObjectSet(_) => unreachable!(), + } + } + + pub(crate) fn default_dirmeta() -> glib::Variant { + let finfo = gio::FileInfo::new(); + finfo.set_attribute_uint32("unix::uid", 0); + finfo.set_attribute_uint32("unix::gid", 0); + finfo.set_attribute_uint32("unix::mode", libc::S_IFDIR | 0o755); + // SAFETY: TODO: This is not a nullable return, fix it in ostree + ostree::create_directory_metadata(&finfo, None) + } + + pub(crate) fn finish_import_object_set(self) -> Result { + let objset = match self.data { + ImporterMode::Commit(_) => unreachable!(), + ImporterMode::ObjectSet(s) => s, + }; + tracing::debug!("Imported {} content objects", objset.len()); + let mtree = ostree::MutableTree::new(); + for checksum in objset.into_iter() { + mtree.replace_file(&checksum, &checksum)?; + } + let dirmeta = self.repo.write_metadata( + ostree::ObjectType::DirMeta, + None, + &Self::default_dirmeta(), + gio::Cancellable::NONE, + )?; + mtree.set_metadata_checksum(&dirmeta.to_hex()); + let tree = self.repo.write_mtree(&mtree, gio::Cancellable::NONE)?; + let commit = self.repo.write_commit_with_time( + None, + None, + None, + None, + tree.downcast_ref().unwrap(), + 0, + gio::Cancellable::NONE, + )?; + Ok(commit.to_string()) + } +} + +fn validate_sha256(input: String) -> Result { + if input.len() != 64 { + return Err(anyhow!("Invalid sha256 checksum (len) {}", input)); + } + if !input.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err(anyhow!("Invalid sha256 checksum {}", input)); + } + Ok(input) +} + +/// Configuration for tar import. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct TarImportOptions { + /// Name of the remote to use for signature verification. + pub remote: Option, +} + +/// Read the contents of a tarball and import the ostree commit inside. +/// Returns the sha256 of the imported commit. +#[instrument(level = "debug", skip_all)] +pub async fn import_tar( + repo: &ostree::Repo, + src: impl tokio::io::AsyncRead + Send + Unpin + 'static, + options: Option, +) -> Result { + let options = options.unwrap_or_default(); + let src = tokio_util::io::SyncIoBridge::new(src); + let repo = repo.clone(); + // The tar code we use today is blocking, so we spawn a thread. + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let mut archive = tar::Archive::new(src); + let txn = repo.auto_transaction(Some(cancellable))?; + let mut importer = Importer::new_for_commit(&repo, options.remote); + importer.import_commit(&mut archive, Some(cancellable))?; + let (checksum, _) = importer.finish_import_commit(); + txn.commit(Some(cancellable))?; + repo.mark_commit_partial(&checksum, false)?; + Ok::<_, anyhow::Error>(checksum) + }) + .await +} + +/// Read the contents of a tarball and import the content objects inside. +/// Generates a synthetic commit object referencing them. +#[instrument(level = "debug", skip_all)] +pub async fn import_tar_objects( + repo: &ostree::Repo, + src: impl tokio::io::AsyncRead + Send + Unpin + 'static, +) -> Result { + let src = tokio_util::io::SyncIoBridge::new(src); + let repo = repo.clone(); + // The tar code we use today is blocking, so we spawn a thread. + crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let mut archive = tar::Archive::new(src); + let mut importer = Importer::new_for_object_set(&repo); + let txn = repo.auto_transaction(Some(cancellable))?; + importer.import_objects(&mut archive, Some(cancellable))?; + let r = importer.finish_import_object_set()?; + txn.commit(Some(cancellable))?; + Ok::<_, anyhow::Error>(r) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_metadata_entry() { + let c = "a8/6d80a3e9ff77c2e3144c787b7769b300f91ffd770221aac27bab854960b964"; + let invalid = format!("{c}.blah"); + for &k in &["", "42", c, &invalid] { + assert!(Importer::parse_metadata_entry(k.into()).is_err()) + } + let valid = format!("{c}.commit"); + let r = Importer::parse_metadata_entry(valid.as_str().into()).unwrap(); + assert_eq!(r.0, c.replace('/', "")); + assert_eq!(r.1, ostree::ObjectType::Commit); + } + + #[test] + fn test_validate_sha256() { + let err_cases = &[ + "a86d80a3e9ff77c2e3144c787b7769b300f91ffd770221aac27bab854960b9644", + "a86d80a3E9ff77c2e3144c787b7769b300f91ffd770221aac27bab854960b964", + ]; + for input in err_cases { + validate_sha256(input.to_string()).unwrap_err(); + } + + validate_sha256( + "a86d80a3e9ff77c2e3144c787b7769b300f91ffd770221aac27bab854960b964".to_string(), + ) + .unwrap(); + } + + #[test] + fn test_parse_object_entry_path() { + let path = + "sysroot/ostree/repo/objects/b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file.xattrs"; + let input = Utf8PathBuf::from(path); + let expected_parent = "b8"; + let expected_rest = + "627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file.xattrs"; + let expected_objtype = "xattrs"; + let output = parse_object_entry_path(&input).unwrap(); + assert_eq!(output.0, expected_parent); + assert_eq!(output.1, expected_rest); + assert_eq!(output.2, expected_objtype); + } + + #[test] + fn test_parse_checksum() { + let parent = "b8"; + let name = "627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file.xattrs"; + let expected = "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7"; + let output = parse_checksum(parent, &Utf8PathBuf::from(name)).unwrap(); + assert_eq!(output, expected); + } + + #[test] + fn test_parse_xattrs_link_target() { + let err_cases = &[ + "", + "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs", + "../b8/62.file-xattrs", + ]; + for input in err_cases { + parse_xattrs_link_target(Utf8Path::new(input)).unwrap_err(); + } + + let ok_cases = &[ + "../b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs", + "sysroot/ostree/repo/objects/b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs", + ]; + let expected = "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7"; + for input in ok_cases { + let output = parse_xattrs_link_target(Utf8Path::new(input)).unwrap(); + assert_eq!(output, expected); + } + } +} diff --git a/crates/ostree-ext/src/tar/mod.rs b/crates/ostree-ext/src/tar/mod.rs new file mode 100644 index 000000000..2e1bbc722 --- /dev/null +++ b/crates/ostree-ext/src/tar/mod.rs @@ -0,0 +1,51 @@ +//! # Losslessly export and import ostree commits as tar archives +//! +//! Convert an ostree commit into a tarball stream, and import it again, including +//! support for OSTree signature verification. +//! +//! In the current libostree C library, while it supports export to tar, this +//! process is lossy - commit metadata is discarded. Further, re-importing +//! requires recalculating all of the object checksums, and tying these +//! together, it does not support verifying ostree level cryptographic signatures +//! such as GPG/ed25519. +//! +//! # Tar stream layout +//! +//! In order to solve these problems, this new tar serialization format effectively +//! combines *both* a `/sysroot/ostree/repo/objects` directory and a checkout in `/usr`, +//! where the latter are hardlinks to the former. +//! +//! The exported stream will have the ostree metadata first; in particular the commit object. +//! Following the commit object is the `.commitmeta` object, which contains any cryptographic +//! signatures. +//! +//! This library then supports verifying the pair of (commit, commitmeta) using an ostree +//! remote, in the same way that `ostree pull` will do. +//! +//! The remainder of the stream is a breadth-first traversal of dirtree/dirmeta objects and the +//! content objects they reference. +//! +//! # `bare-split-xattrs` repository mode +//! +//! In format version 1, the tar stream embeds a proper ostree repository using a tailored +//! `bare-split-xattrs` mode. +//! +//! This is because extended attributes (xattrs) are a complex subject for tar, which has +//! many variants. +//! Further, when exporting bootable ostree commits to container images, it is not actually +//! desired to have the container runtime try to unpack and apply those. +//! +//! For these reasons, extended attributes (xattrs) get serialized into detached objects +//! which are associated with the relevant content objects. +//! +//! At a low level, two dedicated object types are used: +//! * `file-xattrs` as regular files storing (and de-duplicating) xattrs content. +//! * `file-xattrs-link` as hardlinks which associate a `file` object to its corresponding +//! `file-xattrs` object. + +mod import; +pub use import::*; +mod export; +pub use export::*; +mod write; +pub use write::*; diff --git a/crates/ostree-ext/src/tar/write.rs b/crates/ostree-ext/src/tar/write.rs new file mode 100644 index 000000000..a7deb31c2 --- /dev/null +++ b/crates/ostree-ext/src/tar/write.rs @@ -0,0 +1,617 @@ +//! APIs to write a tarball stream into an OSTree commit. +//! +//! This functionality already exists in libostree mostly, +//! this API adds a higher level, more ergonomic Rust frontend +//! to it. +//! +//! In the future, this may also evolve into parsing the tar +//! stream in Rust, not in C. + +use crate::generic_decompress::Decompressor; +use crate::Result; +use anyhow::{anyhow, Context}; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; + +use cap_std::io_lifetimes; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use cap_std_ext::{cap_std, cap_tempfile}; +use containers_image_proxy::oci_spec::image as oci_image; +use fn_error_context::context; +use ostree::gio; +use ostree::prelude::FileExt; +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; +use std::io::{BufWriter, Seek, Write}; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; +use tracing::instrument; + +// Exclude things in https://www.freedesktop.org/wiki/Software/systemd/APIFileSystems/ +// from being placed in the rootfs. +const EXCLUDED_TOPLEVEL_PATHS: &[&str] = &["run", "tmp", "proc", "sys", "dev"]; + +/// Copy a tar entry to a new tar archive, optionally using a different filesystem path. +#[context("Copying entry")] +pub(crate) fn copy_entry( + mut entry: tar::Entry, + dest: &mut tar::Builder, + path: Option<&Path>, +) -> Result<()> { + // Make copies of both the header and path, since that's required for the append APIs + let path = if let Some(path) = path { + path.to_owned() + } else { + (*entry.path()?).to_owned() + }; + let mut header = entry.header().clone(); + if let Some(headers) = entry.pax_extensions()? { + let extensions = headers + .map(|ext| { + let ext = ext?; + Ok((ext.key()?, ext.value_bytes())) + }) + .collect::>>()?; + dest.append_pax_extensions(extensions.as_slice().iter().copied())?; + } + + // Need to use the entry.link_name() not the header.link_name() + // api as the header api does not handle long paths: + // https://github.com/alexcrichton/tar-rs/issues/192 + match entry.header().entry_type() { + tar::EntryType::Symlink => { + let target = entry.link_name()?.ok_or_else(|| anyhow!("Invalid link"))?; + // Sanity check UTF-8 here too. + let target: &Utf8Path = (&*target).try_into()?; + dest.append_link(&mut header, path, target) + } + tar::EntryType::Link => { + let target = entry.link_name()?.ok_or_else(|| anyhow!("Invalid link"))?; + let target: &Utf8Path = (&*target).try_into()?; + // We need to also normalize the target in order to handle hardlinked files in /etc + // where we remap /etc to /usr/etc. + let target = remap_etc_path(target); + dest.append_link(&mut header, path, &*target) + } + _ => dest.append_data(&mut header, path, entry), + } + .map_err(Into::into) +} + +/// Configuration for tar layer commits. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct WriteTarOptions { + /// Base ostree commit hash + pub base: Option, + /// Enable SELinux labeling from the base commit + /// Requires the `base` option. + pub selinux: bool, + /// Allow content not in /usr; this should be paired with ostree rootfs.transient = true + pub allow_nonusr: bool, + /// If true, do not move content in /var to /usr/share/factory/var. This should be used + /// with ostree v2024.3 or newer. + pub retain_var: bool, +} + +/// The result of writing a tar stream. +/// +/// This includes some basic data on the number of files that were filtered +/// out because they were not in `/usr`. +#[derive(Debug, Default)] +pub struct WriteTarResult { + /// The resulting OSTree commit SHA-256. + pub commit: String, + /// Number of paths in a prefix (e.g. `/var` or `/boot`) which were discarded. + pub filtered: BTreeMap, +} + +// Copy of logic from https://github.com/ostreedev/ostree/pull/2447 +// to avoid waiting for backport + releases +fn sepolicy_from_base(repo: &ostree::Repo, base: &str) -> Result { + let cancellable = gio::Cancellable::NONE; + let policypath = "usr/etc/selinux"; + let tempdir = tempfile::tempdir()?; + let (root, _) = repo.read_commit(base, cancellable)?; + let policyroot = root.resolve_relative_path(policypath); + if policyroot.query_exists(cancellable) { + let policydest = tempdir.path().join(policypath); + std::fs::create_dir_all(policydest.parent().unwrap())?; + let opts = ostree::RepoCheckoutAtOptions { + mode: ostree::RepoCheckoutMode::User, + subpath: Some(Path::new(policypath).to_owned()), + ..Default::default() + }; + repo.checkout_at(Some(&opts), ostree::AT_FDCWD, policydest, base, cancellable)?; + } + Ok(tempdir) +} + +#[derive(Debug, PartialEq, Eq)] +enum NormalizedPathResult<'a> { + Filtered(&'a str), + Normal(Utf8PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct TarImportConfig { + allow_nonusr: bool, + remap_factory_var: bool, +} + +// If a path starts with /etc or ./etc or etc, remap it to be usr/etc. +fn remap_etc_path(path: &Utf8Path) -> Cow<'_, Utf8Path> { + let mut components = path.components(); + let Some(prefix) = components.next() else { + return Cow::Borrowed(path); + }; + let (prefix, first) = if matches!(prefix, Utf8Component::CurDir | Utf8Component::RootDir) { + let Some(next) = components.next() else { + return Cow::Borrowed(path); + }; + (Some(prefix), next) + } else { + (None, prefix) + }; + if first.as_str() == "etc" { + let usr = Utf8Component::Normal("usr"); + Cow::Owned( + prefix + .into_iter() + .chain([usr, first]) + .chain(components) + .collect(), + ) + } else { + Cow::Borrowed(path) + } +} + +fn normalize_validate_path<'a>( + path: &'a Utf8Path, + config: &'_ TarImportConfig, +) -> Result> { + // This converts e.g. `foo//bar/./baz` into `foo/bar/baz`. + let mut components = path + .components() + .map(|part| { + match part { + // Convert absolute paths to relative + camino::Utf8Component::RootDir => Ok(camino::Utf8Component::CurDir), + // Allow ./ and regular parts + camino::Utf8Component::Normal(_) | camino::Utf8Component::CurDir => Ok(part), + // Barf on Windows paths as well as Unix path uplinks `..` + _ => Err(anyhow!("Invalid path: {}", path)), + } + }) + .peekable(); + let mut ret = Utf8PathBuf::new(); + // Insert a leading `./` if not present + if let Some(Ok(camino::Utf8Component::Normal(_))) = components.peek() { + ret.push(camino::Utf8Component::CurDir); + } + let mut found_first = false; + let mut excluded = false; + for part in components { + let part = part?; + if excluded { + return Ok(NormalizedPathResult::Filtered(part.as_str())); + } + if !found_first { + if let Utf8Component::Normal(part) = part { + found_first = true; + match part { + // We expect all the OS content to live in usr in general + "usr" => ret.push(part), + // ostree has special support for /etc + "etc" => { + ret.push("usr/etc"); + } + "var" => { + // Content in /var will get copied by a systemd tmpfiles.d unit + if config.remap_factory_var { + ret.push("usr/share/factory/var"); + } else { + ret.push(part) + } + } + o if EXCLUDED_TOPLEVEL_PATHS.contains(&o) => { + // We don't want to actually drop the toplevel, but mark + // *children* of it as excluded. + excluded = true; + ret.push(part) + } + _ if config.allow_nonusr => ret.push(part), + _ => { + return Ok(NormalizedPathResult::Filtered(part)); + } + } + } else { + ret.push(part); + } + } else { + ret.push(part); + } + } + + Ok(NormalizedPathResult::Normal(ret)) +} + +/// Perform various filtering on imported tar archives. +/// - Move /etc to /usr/etc +/// - Entirely drop files not in /usr +/// +/// This also acts as a Rust "pre-parser" of the tar archive, hopefully +/// catching anything corrupt that might be exploitable from the C libarchive side. +/// Remember that we're parsing this while we're downloading it, and in order +/// to verify integrity we rely on the total sha256 of the blob, so all content +/// written before then must be considered untrusted. +pub(crate) fn filter_tar( + src: impl std::io::Read, + dest: impl std::io::Write, + config: &TarImportConfig, + tmpdir: &Dir, +) -> Result> { + let src = std::io::BufReader::new(src); + let mut src = tar::Archive::new(src); + let dest = BufWriter::new(dest); + let mut dest = tar::Builder::new(dest); + let mut filtered = BTreeMap::new(); + + let ents = src.entries()?; + + tracing::debug!("Filtering tar; config={config:?}"); + + // Lookaside data for dealing with hardlinked files into /sysroot; see below. + let mut changed_sysroot_objects = HashMap::new(); + let mut new_sysroot_link_targets = HashMap::::new(); + + for entry in ents { + let mut entry = entry?; + let header = entry.header(); + let path = entry.path()?; + let path: &Utf8Path = (&*path).try_into()?; + // Force all paths to relative + let path = path.strip_prefix("/").unwrap_or(path); + + let is_modified = header.mtime().unwrap_or_default() > 0; + let is_regular = header.entry_type() == tar::EntryType::Regular; + if path.strip_prefix(crate::tar::REPO_PREFIX).is_ok() { + // If it's a modified file in /sysroot, it may be a target for future hardlinks. + // In that case, we copy the data off to a temporary file. Then the first hardlink + // to it becomes instead the real file, and any *further* hardlinks refer to that + // file instead. + if is_modified && is_regular { + tracing::debug!("Processing modified sysroot file {path}"); + // Create an O_TMPFILE (anonymous file) to use as a temporary store for the file data + let mut tmpf = cap_tempfile::TempFile::new_anonymous(tmpdir) + .map(BufWriter::new) + .context("Creating tmpfile")?; + let path = path.to_owned(); + let header = header.clone(); + std::io::copy(&mut entry, &mut tmpf) + .map_err(anyhow::Error::msg) + .context("Copying")?; + let mut tmpf = tmpf.into_inner()?; + tmpf.seek(std::io::SeekFrom::Start(0))?; + // Cache this data, indexed by the file path + changed_sysroot_objects.insert(path, (header, tmpf)); + continue; + } + } else if header.entry_type() == tar::EntryType::Link && is_modified { + let target = header + .link_name()? + .ok_or_else(|| anyhow!("Invalid empty hardlink"))?; + let target: &Utf8Path = (&*target).try_into()?; + // Canonicalize to a relative path + let target = path.strip_prefix("/").unwrap_or(target); + // If this is a hardlink into /sysroot... + if target.strip_prefix(crate::tar::REPO_PREFIX).is_ok() { + // And we found a previously processed modified file there + if let Some((mut header, data)) = changed_sysroot_objects.remove(target) { + tracing::debug!("Making {path} canonical for sysroot link {target}"); + // Make *this* entry the canonical one, consuming the temporary file data + dest.append_data(&mut header, path, data)?; + // And cache this file path as the new link target + new_sysroot_link_targets.insert(target.to_owned(), path.to_owned()); + } else if let Some(real_target) = new_sysroot_link_targets.get(target) { + tracing::debug!("Relinking {path} to {real_target}"); + // We found a 2nd (or 3rd, etc.) link into /sysroot; rewrite the link + // target to be the first file outside of /sysroot we found. + let mut header = header.clone(); + dest.append_link(&mut header, path, real_target)?; + } else { + tracing::debug!("Found unhandled modified link from {path} to {target}"); + } + continue; + } + } + + let normalized = match normalize_validate_path(path, config)? { + NormalizedPathResult::Filtered(path) => { + tracing::trace!("Filtered: {path}"); + if let Some(v) = filtered.get_mut(path) { + *v += 1; + } else { + filtered.insert(path.to_string(), 1); + } + continue; + } + NormalizedPathResult::Normal(path) => path, + }; + + copy_entry(entry, &mut dest, Some(normalized.as_std_path()))?; + } + dest.into_inner()?.flush()?; + Ok(filtered) +} + +/// Asynchronous wrapper for filter_tar() +#[context("Filtering tar stream")] +async fn filter_tar_async( + src: impl AsyncRead + Send + 'static, + media_type: oci_image::MediaType, + mut dest: impl AsyncWrite + Send + Unpin, + config: &TarImportConfig, + repo_tmpdir: Dir, +) -> Result> { + let (tx_buf, mut rx_buf) = tokio::io::duplex(8192); + // The source must be moved to the heap so we know it is stable for passing to the worker thread + let src = Box::pin(src); + let config = config.clone(); + let tar_transformer = crate::tokio_util::spawn_blocking_flatten(move || { + let src = tokio_util::io::SyncIoBridge::new(src); + let mut src = Decompressor::new(&media_type, src)?; + let dest = tokio_util::io::SyncIoBridge::new(tx_buf); + + let r = filter_tar(&mut src, dest, &config, &repo_tmpdir); + + src.finish()?; + + Ok(r) + }); + let copier = tokio::io::copy(&mut rx_buf, &mut dest); + let (r, v) = tokio::join!(tar_transformer, copier); + let _v: u64 = v?; + r? +} + +/// Write the contents of a tarball as an ostree commit. +#[allow(unsafe_code)] // For raw fd bits +#[instrument(level = "debug", skip_all)] +pub async fn write_tar( + repo: &ostree::Repo, + src: impl tokio::io::AsyncRead + Send + Unpin + 'static, + media_type: oci_image::MediaType, + refname: &str, + options: Option, +) -> Result { + let repo = repo.clone(); + let options = options.unwrap_or_default(); + let sepolicy = if options.selinux { + if let Some(base) = options.base { + Some(sepolicy_from_base(&repo, &base).context("tar: Preparing sepolicy")?) + } else { + None + } + } else { + None + }; + let mut c = std::process::Command::new("ostree"); + let repofd = repo.dfd_as_file()?; + let repofd: Arc = Arc::new(repofd.into()); + { + let c = c + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(["commit"]); + c.take_fd_n(repofd.clone(), 3); + c.arg("--repo=/proc/self/fd/3"); + if let Some(sepolicy) = sepolicy.as_ref() { + c.arg("--selinux-policy"); + c.arg(sepolicy.path()); + } + c.arg(format!( + "--add-metadata-string=ostree.importer.version={}", + env!("CARGO_PKG_VERSION") + )); + c.args([ + "--no-bindings", + "--tar-autocreate-parents", + "--tree=tar=/proc/self/fd/0", + "--branch", + refname, + ]); + } + let mut c = tokio::process::Command::from(c); + c.kill_on_drop(true); + let mut r = c.spawn()?; + tracing::trace!("Spawned ostree child process"); + // Safety: We passed piped() for all of these + let child_stdin = r.stdin.take().unwrap(); + let mut child_stdout = r.stdout.take().unwrap(); + let mut child_stderr = r.stderr.take().unwrap(); + // Copy the filtered tar stream to child stdin + let import_config = TarImportConfig { + allow_nonusr: options.allow_nonusr, + remap_factory_var: !options.retain_var, + }; + let repo_tmpdir = Dir::reopen_dir(&repo.dfd_borrow())? + .open_dir("tmp") + .context("Getting repo tmpdir")?; + let filtered_result = + filter_tar_async(src, media_type, child_stdin, &import_config, repo_tmpdir); + let output_copier = async move { + // Gather stdout/stderr to buffers + let mut child_stdout_buf = String::new(); + let mut child_stderr_buf = String::new(); + let (_a, _b) = tokio::try_join!( + child_stdout.read_to_string(&mut child_stdout_buf), + child_stderr.read_to_string(&mut child_stderr_buf) + )?; + Ok::<_, anyhow::Error>((child_stdout_buf, child_stderr_buf)) + }; + + // We must convert the child exit status here to an error to + // ensure we break out of the try_join! below. + let status = async move { + let status = r.wait().await?; + if !status.success() { + return Err(anyhow!("Failed to commit tar: {:?}", status)); + } + anyhow::Ok(()) + }; + tracing::debug!("Waiting on child process"); + let (filtered_result, child_stdout) = + match tokio::try_join!(status, filtered_result).context("Processing tar") { + Ok(((), filtered_result)) => { + let (child_stdout, _) = output_copier.await.context("Copying child output")?; + (filtered_result, child_stdout) + } + Err(e) => { + if let Ok((_, child_stderr)) = output_copier.await { + // Avoid trailing newline + let child_stderr = child_stderr.trim(); + Err(e.context(child_stderr.to_string()))? + } else { + Err(e)? + } + } + }; + drop(sepolicy); + + tracing::trace!("tar written successfully"); + // TODO: trim string in place + let s = child_stdout.trim(); + Ok(WriteTarResult { + commit: s.to_string(), + filtered: filtered_result, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_remap_etc() { + // These shouldn't change. Test etcc to verify we're not doing string matching. + let unchanged = ["", "foo", "/etcc/foo", "../etc/baz"]; + for x in unchanged { + similar_asserts::assert_eq!(x, remap_etc_path(x.into()).as_str()); + } + // Verify all 3 forms of "./etc", "/etc" and "etc", and also test usage of + // ".."" (should be unchanged) and "//" (will be normalized). + for (p, expected) in [ + ("/etc/foo/../bar/baz", "/usr/etc/foo/../bar/baz"), + ("etc/foo//bar", "usr/etc/foo/bar"), + ("./etc/foo", "./usr/etc/foo"), + ("etc", "usr/etc"), + ] { + similar_asserts::assert_eq!(remap_etc_path(p.into()).as_str(), expected); + } + } + + #[test] + fn test_normalize_path() { + let imp_default = &TarImportConfig { + allow_nonusr: false, + remap_factory_var: true, + }; + let allow_nonusr = &TarImportConfig { + allow_nonusr: true, + remap_factory_var: true, + }; + let composefs_and_new_ostree = &TarImportConfig { + allow_nonusr: true, + remap_factory_var: false, + }; + let valid_all = &[ + ("/usr/bin/blah", "./usr/bin/blah"), + ("usr/bin/blah", "./usr/bin/blah"), + ("usr///share/.//blah", "./usr/share/blah"), + ("var/lib/blah", "./usr/share/factory/var/lib/blah"), + ("./var/lib/blah", "./usr/share/factory/var/lib/blah"), + ("dev", "./dev"), + ("/proc", "./proc"), + ("./", "."), + ]; + let valid_nonusr = &[("boot", "./boot"), ("opt/puppet/blah", "./opt/puppet/blah")]; + for &(k, v) in valid_all { + let r = normalize_validate_path(k.into(), imp_default).unwrap(); + let r2 = normalize_validate_path(k.into(), allow_nonusr).unwrap(); + assert_eq!(r, r2); + match r { + NormalizedPathResult::Normal(r) => assert_eq!(r, v), + NormalizedPathResult::Filtered(o) => panic!("Should not have filtered {o}"), + } + } + for &(k, v) in valid_nonusr { + let strict = normalize_validate_path(k.into(), imp_default).unwrap(); + assert!( + matches!(strict, NormalizedPathResult::Filtered(_)), + "Incorrect filter for {k}" + ); + let nonusr = normalize_validate_path(k.into(), allow_nonusr).unwrap(); + match nonusr { + NormalizedPathResult::Normal(r) => assert_eq!(r, v), + NormalizedPathResult::Filtered(o) => panic!("Should not have filtered {o}"), + } + } + let filtered = &["/run/blah", "/sys/foo", "/dev/somedev"]; + for &k in filtered { + match normalize_validate_path(k.into(), imp_default).unwrap() { + NormalizedPathResult::Filtered(_) => {} + NormalizedPathResult::Normal(_) => { + panic!("{k} should be filtered") + } + } + } + let errs = &["usr/foo/../../bar"]; + for &k in errs { + assert!(normalize_validate_path(k.into(), allow_nonusr).is_err()); + assert!(normalize_validate_path(k.into(), imp_default).is_err()); + } + assert!(matches!( + normalize_validate_path("var/lib/foo".into(), composefs_and_new_ostree).unwrap(), + NormalizedPathResult::Normal(_) + )); + } + + #[tokio::test] + async fn tar_filter() -> Result<()> { + let tempd = tempfile::tempdir()?; + let rootfs = &tempd.path().join("rootfs"); + + std::fs::create_dir_all(rootfs.join("etc/systemd/system"))?; + std::fs::write(rootfs.join("etc/systemd/system/foo.service"), "fooservice")?; + std::fs::write(rootfs.join("blah"), "blah")?; + let rootfs_tar_path = &tempd.path().join("rootfs.tar"); + let rootfs_tar = std::fs::File::create(rootfs_tar_path)?; + let mut rootfs_tar = tar::Builder::new(rootfs_tar); + rootfs_tar.append_dir_all(".", rootfs)?; + let _ = rootfs_tar.into_inner()?; + let mut dest = Vec::new(); + let src = tokio::io::BufReader::new(tokio::fs::File::open(rootfs_tar_path).await?); + let cap_tmpdir = Dir::open_ambient_dir(&tempd, cap_std::ambient_authority())?; + filter_tar_async( + src, + oci_image::MediaType::ImageLayer, + &mut dest, + &Default::default(), + cap_tmpdir, + ) + .await?; + let dest = dest.as_slice(); + let mut final_tar = tar::Archive::new(Cursor::new(dest)); + let destdir = &tempd.path().join("destdir"); + final_tar.unpack(destdir)?; + assert!(destdir.join("usr/etc/systemd/system/foo.service").exists()); + assert!(!destdir.join("blah").exists()); + Ok(()) + } +} diff --git a/crates/ostree-ext/src/tokio_util.rs b/crates/ostree-ext/src/tokio_util.rs new file mode 100644 index 000000000..e21b142c2 --- /dev/null +++ b/crates/ostree-ext/src/tokio_util.rs @@ -0,0 +1,106 @@ +//! Helpers for bridging GLib async/mainloop with Tokio. + +use anyhow::Result; +use core::fmt::{Debug, Display}; +use futures_util::{Future, FutureExt}; +use ostree::gio; +use ostree::prelude::{CancellableExt, CancellableExtManual}; + +/// Call a faillible future, while monitoring `cancellable` and return an error if cancelled. +pub async fn run_with_cancellable(f: F, cancellable: &gio::Cancellable) -> Result +where + F: Future>, +{ + // Bridge GCancellable to a tokio notification + let notify = std::sync::Arc::new(tokio::sync::Notify::new()); + let notify2 = notify.clone(); + cancellable.connect_cancelled(move |_| notify2.notify_one()); + cancellable.set_error_if_cancelled()?; + // See https://blog.yoshuawuyts.com/futures-concurrency-3/ on why + // `select!` is a trap in general, but I believe this case is safe. + tokio::select! { + r = f => r, + _ = notify.notified() => { + Err(anyhow::anyhow!("Operation was cancelled")) + } + } +} + +struct CancelOnDrop(gio::Cancellable); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +/// Wrapper for [`tokio::task::spawn_blocking`] which provides a [`gio::Cancellable`] that will be triggered on drop. +/// +/// This function should be used in a Rust/tokio native `async fn`, but that want to invoke +/// GLib style blocking APIs that use `GCancellable`. The cancellable will be triggered when this +/// future is dropped, which helps bound thread usage. +/// +/// This is in a sense the inverse of [`run_with_cancellable`]. +pub fn spawn_blocking_cancellable(f: F) -> tokio::task::JoinHandle +where + F: FnOnce(&gio::Cancellable) -> R + Send + 'static, + R: Send + 'static, +{ + tokio::task::spawn_blocking(move || { + let dropper = CancelOnDrop(gio::Cancellable::new()); + f(&dropper.0) + }) +} + +/// Flatten a nested Result>, defaulting to converting the error type to an `anyhow::Error`. +/// See https://doc.rust-lang.org/std/result/enum.Result.html#method.flatten +pub(crate) fn flatten_anyhow(r: std::result::Result, E>) -> Result +where + E: Display + Debug + Send + Sync + 'static, +{ + match r { + Ok(x) => x, + Err(e) => Err(anyhow::anyhow!(e)), + } +} + +/// A wrapper around [`spawn_blocking_cancellable`] that flattens nested results. +pub fn spawn_blocking_cancellable_flatten(f: F) -> impl Future> +where + F: FnOnce(&gio::Cancellable) -> Result + Send + 'static, + T: Send + 'static, +{ + spawn_blocking_cancellable(f).map(flatten_anyhow) +} + +/// A wrapper around [`tokio::task::spawn_blocking`] that flattens nested results. +pub fn spawn_blocking_flatten(f: F) -> impl Future> +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f).map(flatten_anyhow) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cancellable() { + let cancellable = ostree::gio::Cancellable::new(); + + let cancellable_copy = cancellable.clone(); + let s = async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + cancellable_copy.cancel(); + }; + let r = async move { + tokio::time::sleep(std::time::Duration::from_secs(200)).await; + Ok(()) + }; + let r = run_with_cancellable(r, &cancellable); + let (_, r) = tokio::join!(s, r); + assert!(r.is_err()); + } +} diff --git a/crates/ostree-ext/src/utils.rs b/crates/ostree-ext/src/utils.rs new file mode 100644 index 000000000..a70cac053 --- /dev/null +++ b/crates/ostree-ext/src/utils.rs @@ -0,0 +1,29 @@ +use std::{future::Future, time::Duration}; + +/// Call an async task function, and write a message to stdout +/// with an automatic spinner to show that we're not blocked. +/// Note that generally the called function should not output +/// anything to stdout as this will interfere with the spinner. +pub(crate) async fn async_task_with_spinner(msg: &str, f: F) -> T +where + F: Future, +{ + let pb = indicatif::ProgressBar::new_spinner(); + let style = indicatif::ProgressStyle::default_bar(); + pb.set_style(style.template("{spinner} {msg}").unwrap()); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(150)); + let r = f.await; + pb.finish_and_clear(); + r +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_spinner() { + async_task_with_spinner("Testing...", tokio::time::sleep(Duration::from_secs(5))).await + } +} diff --git a/crates/ostree-ext/tests/it/fixtures/hlinks.tar.gz b/crates/ostree-ext/tests/it/fixtures/hlinks.tar.gz new file mode 100644 index 000000000..0bbc06d49 Binary files /dev/null and b/crates/ostree-ext/tests/it/fixtures/hlinks.tar.gz differ diff --git a/crates/ostree-ext/tests/it/fixtures/manifest1.json b/crates/ostree-ext/tests/it/fixtures/manifest1.json new file mode 100644 index 000000000..52f09f286 --- /dev/null +++ b/crates/ostree-ext/tests/it/fixtures/manifest1.json @@ -0,0 +1 @@ +{"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:f3b50d0849a19894aa27ca2346a78efdacf2c56bdc2a3493672d2a819990fedf","size":9301},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:75f4abe8518ec55cb8bf0d358a737084f38e2c030a28651d698c0b7569d680a6","size":1387849},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:777cb841d2803f775a36fba62bcbfe84b2a1e0abc27cf995961b63c3d218a410","size":48676116},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:1179dc1e2994ec0466787ec43967db9016b4b93c602bb9675d7fe4c0993366ba","size":124705297},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:74555b3730c4c0f77529ead433db58e038070666b93a5cc0da262d7b8debff0e","size":38743650},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:0ff8b1fdd38e5cfb6390024de23ba4b947cd872055f62e70f2c21dad5c928925","size":77161948},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:76b83eea62b7b93200a056b5e0201ef486c67f1eeebcf2c7678ced4d614cece2","size":21970157},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:d85c742f69904cb8dbf98abca4724d364d91792fcf8b5f5634ab36dda162bfc4","size":59797135},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:167e5df36d0fcbed876ca90c1ed1e6c79b5e2bdaba5eae74ab86444654b19eff","size":49410348},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:b34384ba76fa1e335cc8d75522508d977854f2b423f8aceb50ca6dfc2f609a99","size":21714783},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:7bf2d65ebf222ee10115284abf6909b1a3da0f3bd6d8d849e30723636b7145cb","size":15264848},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:a75bbf55d8de4dbd54e429e16fbd46688717faf4ea823c94676529cc2525fd5f","size":14373701},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:cf728677fa8c84bfcfd71e17953062421538d492d7fbfdd0dbce8eb1e5f6eec3","size":8400473},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:caff60c1ef085fb500c94230ccab9338e531578635070230b1413b439fd53f8f","size":6914489},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:65ca8f9bddaa720b74c5a7401bf273e93eba6b3b855a62422a8258373e0b1ae0","size":8294965},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:387bab4fcb713e9691617a645b6af2b7ad29fe5e009b0b0d3215645ef315481c","size":6600369},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:f63dcde5a664dad3eb3321bbcf2913d9644d16561a67c86ab61d814c1462583d","size":16869027},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bcd90242651342fbd2ed5ca3e60d03de90fdd28c3a9f634329f6e1c21c79718","size":5735283},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:cb65c21a0659b5b826881280556995a7ca4818c2b9b7a89e31d816a996fa8640","size":4528663},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5187f51b62f4a2e82198a75afcc623a0323d4804fa7848e2e0acb30d77b8d9ab","size":5266030},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bfef79d6d35378fba9093083ff6bd7b5ed9f443f87517785e6ff134dc8d08c6a","size":4316135},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:1cf332fd50b382af7941d6416994f270c894e9d60fb5c6cecf25de887673bbcb","size":3914655},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:e0d80be6e71bfae398f06f7a7e3b224290f3dde7544c8413f922934abeb1f599","size":2441858},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:48ff87e7a7af41d7139c5230e2e939aa97cafb1f62a114825bda5f5904e04a0e","size":3818782},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bcc652ccaa27638bd5bd2d7188053f1736586afbae87b3952e9211c773e3563","size":3885971},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:d83d9388b8c8c1e7c97b6b18f5107b74354700ebce9da161ccb73156a2c54a2e","size":3442642},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:efc465ae44a18ee395e542eb97c8d1fc21bf9d5fb49244ba4738e9bf48bfd3dc","size":3066348},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:c5c471cce08aa9cc7d96884a9e1981b7bb67ee43524af47533f50a8ddde7a83d","size":909923},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8956cd951abc481ba364cf8ef5deca7cc9185b59ed95ae40b52e42afdc271d8e","size":3553645},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5b0963a6c89d595b5c4786e2f3ce0bc168a262efab74dfce3d7c8d1063482c60","size":1495301},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bf2df295da2716291f9dd4707158bca218b4a7920965955a4808b824c1bee2b6","size":3063142},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:19b2ea8d63794b8249960d581216ae1ccb80f8cfe518ff8dd1f12d65d19527a5","size":8109718},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:420636df561ccc835ef9665f41d4bc91c5f00614a61dca266af2bcd7bee2cc25","size":3003935},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5ae67caf0978d82848d47ff932eee83a1e5d2581382c9c47335f69c9d7acc180","size":2468557},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:4f4b8bb8463dc74bb7f32eee78d02b71f61a322967b6d6cbb29829d262376f74","size":2427605},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:69373f86b83e6e5a962de07f40ff780a031b42d2568ffbb8b3c36de42cc90dec","size":2991782},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:2d05c2f993f9761946701da37f45fc573a2db8467f92b3f0d356f5f7adaf229e","size":3085765},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:41925843e5c965165bedc9c8124b96038f08a89c95ba94603a5f782dc813f0a8","size":2724309},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:a8c39f2998073e0e8b55fb88ccd68d2621a0fb6e31a528fd4790a1c90f8508a9","size":2512079},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:b905f801d092faba0c155597dd1303fa8c0540116af59c111ed7744e486ed63b","size":2341122},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:4f46b58b37828fa71fa5d7417a8ca7a62761cc6a72eb1592943572fc2446b054","size":2759344},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:3fbae92ecc64cf253b643a0e75b56514dc694451f163b47fb4e15af373238e10","size":2539288},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:744dd4a3ec521668942661cf1f184eb8f07f44025ce1aa35d5072ad9d72946fe","size":2415870},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:6c74c0a05a36bddabef1fdfae365ff87a9c5dd1ec7345d9e20f7f8ab04b39fc6","size":2145078},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:910ff6f93303ebedde3459f599b06d7b70d8f0674e3fe1d6623e3af809245cc4","size":5098511},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:2752e2f62f38fea3a390f111d673d2529dbf929f6c67ec7ef4359731d1a7edd8","size":1051999},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5065c3aac5fcc3c1bde50a19d776974353301f269a936dd2933a67711af3b703","size":2713694},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bf6993eea50bbd8b448e6fd719f83c82d1d40b623f2c415f7727e766587ea83","size":1686714},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:630221744f0f9632f4f34f74241e65f79e78f938100266a119113af1ce10a1c5","size":2061581},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:e7e2eae322bca0ffa01bb2cae72288507bef1a11ad51f99d0a4faba1b1e000b9","size":2079706},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bb6374635385b0c2539c284b137d831bd45fbe64b5e49aee8ad92d14c156a41b","size":3142398},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:40493ecd0f9ab499a2bec715415c3a98774ea6d1c9c01eb30a6b56793204a02d","size":69953187}]} \ No newline at end of file diff --git a/crates/ostree-ext/tests/it/fixtures/manifest2.json b/crates/ostree-ext/tests/it/fixtures/manifest2.json new file mode 100644 index 000000000..102c40170 --- /dev/null +++ b/crates/ostree-ext/tests/it/fixtures/manifest2.json @@ -0,0 +1 @@ +{"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:ca0f7e342503b45a1110aba49177e386242e9192ab1742a95998b6b99c2a0150","size":9301},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bca674ffe2ebe92b9e952bc807b9f1cd0d559c057e95ac81f3bae12a9b96b53e","size":1387854},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:777cb841d2803f775a36fba62bcbfe84b2a1e0abc27cf995961b63c3d218a410","size":48676116},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:1179dc1e2994ec0466787ec43967db9016b4b93c602bb9675d7fe4c0993366ba","size":124705297},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:74555b3730c4c0f77529ead433db58e038070666b93a5cc0da262d7b8debff0e","size":38743650},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:0b5d930ffc92d444b0a7b39beed322945a3038603fbe2a56415a6d02d598df1f","size":77162517},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8d12d20c2d1c8f05c533a2a1b27a457f25add8ad38382523660c4093f180887b","size":21970100},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:d85c742f69904cb8dbf98abca4724d364d91792fcf8b5f5634ab36dda162bfc4","size":59797135},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:167e5df36d0fcbed876ca90c1ed1e6c79b5e2bdaba5eae74ab86444654b19eff","size":49410348},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:b34384ba76fa1e335cc8d75522508d977854f2b423f8aceb50ca6dfc2f609a99","size":21714783},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:7bf2d65ebf222ee10115284abf6909b1a3da0f3bd6d8d849e30723636b7145cb","size":15264848},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:a75bbf55d8de4dbd54e429e16fbd46688717faf4ea823c94676529cc2525fd5f","size":14373701},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:cf728677fa8c84bfcfd71e17953062421538d492d7fbfdd0dbce8eb1e5f6eec3","size":8400473},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:caff60c1ef085fb500c94230ccab9338e531578635070230b1413b439fd53f8f","size":6914489},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:65ca8f9bddaa720b74c5a7401bf273e93eba6b3b855a62422a8258373e0b1ae0","size":8294965},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:387bab4fcb713e9691617a645b6af2b7ad29fe5e009b0b0d3215645ef315481c","size":6600369},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:f63dcde5a664dad3eb3321bbcf2913d9644d16561a67c86ab61d814c1462583d","size":16869027},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bcd90242651342fbd2ed5ca3e60d03de90fdd28c3a9f634329f6e1c21c79718","size":5735283},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:cb65c21a0659b5b826881280556995a7ca4818c2b9b7a89e31d816a996fa8640","size":4528663},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5187f51b62f4a2e82198a75afcc623a0323d4804fa7848e2e0acb30d77b8d9ab","size":5266030},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bfef79d6d35378fba9093083ff6bd7b5ed9f443f87517785e6ff134dc8d08c6a","size":4316135},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:1cf332fd50b382af7941d6416994f270c894e9d60fb5c6cecf25de887673bbcb","size":3914655},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:e0d80be6e71bfae398f06f7a7e3b224290f3dde7544c8413f922934abeb1f599","size":2441858},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:48ff87e7a7af41d7139c5230e2e939aa97cafb1f62a114825bda5f5904e04a0e","size":3818782},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bcc652ccaa27638bd5bd2d7188053f1736586afbae87b3952e9211c773e3563","size":3885971},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:d83d9388b8c8c1e7c97b6b18f5107b74354700ebce9da161ccb73156a2c54a2e","size":3442642},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:efc465ae44a18ee395e542eb97c8d1fc21bf9d5fb49244ba4738e9bf48bfd3dc","size":3066348},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:c5c471cce08aa9cc7d96884a9e1981b7bb67ee43524af47533f50a8ddde7a83d","size":909923},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8956cd951abc481ba364cf8ef5deca7cc9185b59ed95ae40b52e42afdc271d8e","size":3553645},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5b0963a6c89d595b5c4786e2f3ce0bc168a262efab74dfce3d7c8d1063482c60","size":1495301},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bf2df295da2716291f9dd4707158bca218b4a7920965955a4808b824c1bee2b6","size":3063142},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:19b2ea8d63794b8249960d581216ae1ccb80f8cfe518ff8dd1f12d65d19527a5","size":8109718},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:420636df561ccc835ef9665f41d4bc91c5f00614a61dca266af2bcd7bee2cc25","size":3003935},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5ae67caf0978d82848d47ff932eee83a1e5d2581382c9c47335f69c9d7acc180","size":2468557},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:4f4b8bb8463dc74bb7f32eee78d02b71f61a322967b6d6cbb29829d262376f74","size":2427605},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:69373f86b83e6e5a962de07f40ff780a031b42d2568ffbb8b3c36de42cc90dec","size":2991782},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:2d05c2f993f9761946701da37f45fc573a2db8467f92b3f0d356f5f7adaf229e","size":3085765},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:41925843e5c965165bedc9c8124b96038f08a89c95ba94603a5f782dc813f0a8","size":2724309},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:a8c39f2998073e0e8b55fb88ccd68d2621a0fb6e31a528fd4790a1c90f8508a9","size":2512079},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:b905f801d092faba0c155597dd1303fa8c0540116af59c111ed7744e486ed63b","size":2341122},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:4f46b58b37828fa71fa5d7417a8ca7a62761cc6a72eb1592943572fc2446b054","size":2759344},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:3fbae92ecc64cf253b643a0e75b56514dc694451f163b47fb4e15af373238e10","size":2539288},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:744dd4a3ec521668942661cf1f184eb8f07f44025ce1aa35d5072ad9d72946fe","size":2415870},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:6c74c0a05a36bddabef1fdfae365ff87a9c5dd1ec7345d9e20f7f8ab04b39fc6","size":2145078},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:910ff6f93303ebedde3459f599b06d7b70d8f0674e3fe1d6623e3af809245cc4","size":5098511},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:2752e2f62f38fea3a390f111d673d2529dbf929f6c67ec7ef4359731d1a7edd8","size":1051999},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:5065c3aac5fcc3c1bde50a19d776974353301f269a936dd2933a67711af3b703","size":2713694},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:8bf6993eea50bbd8b448e6fd719f83c82d1d40b623f2c415f7727e766587ea83","size":1686714},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:630221744f0f9632f4f34f74241e65f79e78f938100266a119113af1ce10a1c5","size":2061581},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:e7e2eae322bca0ffa01bb2cae72288507bef1a11ad51f99d0a4faba1b1e000b9","size":2079706},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:bb6374635385b0c2539c284b137d831bd45fbe64b5e49aee8ad92d14c156a41b","size":3142398},{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:cb9b8a4ac4a8df62df79e6f0348a14b3ec239816d42985631c88e76d4e3ff815","size":69952385}]} \ No newline at end of file diff --git a/crates/ostree-ext/tests/it/main.rs b/crates/ostree-ext/tests/it/main.rs new file mode 100644 index 000000000..2dfb77970 --- /dev/null +++ b/crates/ostree-ext/tests/it/main.rs @@ -0,0 +1,2107 @@ +//! Main integration tests that use the public APIs. + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std::fs::{Dir, DirBuilder, DirBuilderExt}; +use cap_std_ext::cap_std; +use containers_image_proxy::oci_spec; +use gvariant::aligned_bytes::TryAsAligned; +use gvariant::{Marker, Structure}; +use oci_image::ImageManifest; +use oci_spec::image as oci_image; +use ocidir::oci_spec::distribution::Reference; +use ocidir::oci_spec::image::{Arch, DigestAlgorithm}; +use ostree_ext::chunking::ObjectMetaSized; +use ostree_ext::container::{store, ManifestDiff, OSTREE_COMMIT_LABEL}; +use ostree_ext::container::{ + Config, ExportOpts, ImageReference, OstreeImageReference, SignatureSource, Transport, +}; +use ostree_ext::prelude::{Cast, FileExt}; +use ostree_ext::tar::TarImportOptions; +use ostree_ext::{fixture, ostree_manual}; +use ostree_ext::{gio, glib}; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::io::{BufReader, BufWriter}; +use std::process::{Command, Stdio}; +use std::sync::{LazyLock, OnceLock}; +use std::time::SystemTime; +use xshell::cmd; + +use ostree_ext::fixture::{ + FileDef, Fixture, NonOstreeFixture, CONTENTS_CHECKSUM_V0, LAYERS_V0_LEN, PKGS_V0_LEN, +}; + +const EXAMPLE_TAR_LAYER: &[u8] = include_bytes!("fixtures/hlinks.tar.gz"); +const TEST_REGISTRY_DEFAULT: &str = "localhost:5000"; + +/// Check if we have skopeo +fn check_skopeo() -> bool { + static HAVE_SKOPEO: OnceLock = OnceLock::new(); + *HAVE_SKOPEO.get_or_init(|| { + Command::new("skopeo") + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() + }) +} + +#[track_caller] +fn assert_err_contains(r: Result, s: impl AsRef) { + let s = s.as_ref(); + let msg = format!("{:#}", r.err().expect("Expecting an error")); + if !msg.contains(s) { + panic!(r#"Error message "{msg}" did not contain "{s}""#); + } +} + +static TEST_REGISTRY: LazyLock = + LazyLock::new(|| match std::env::var_os("TEST_REGISTRY") { + Some(t) => t.to_str().unwrap().to_owned(), + None => TEST_REGISTRY_DEFAULT.to_string(), + }); + +// This is mostly just sanity checking these functions are publicly accessible +#[test] +fn test_cli_fns() -> Result<()> { + let fixture = Fixture::new_v1()?; + let srcpath = fixture.path.join("src/repo"); + let srcrepo_parsed = ostree_ext::cli::parse_repo(&srcpath).unwrap(); + assert_eq!(srcrepo_parsed.mode(), fixture.srcrepo().mode()); + + let ir = + ostree_ext::cli::parse_imgref("ostree-unverified-registry:quay.io/examplens/exampleos") + .unwrap(); + assert_eq!(ir.imgref.transport, Transport::Registry); + + let ir = ostree_ext::cli::parse_base_imgref("docker://quay.io/examplens/exampleos").unwrap(); + assert_eq!(ir.transport, Transport::Registry); + Ok(()) +} + +#[tokio::test] +async fn test_tar_import_empty() -> Result<()> { + let fixture = Fixture::new_v1()?; + let r = ostree_ext::tar::import_tar(fixture.destrepo(), tokio::io::empty(), None).await; + assert_err_contains(r, "Commit object not found"); + Ok(()) +} + +#[tokio::test] +async fn test_tar_export_reproducible() -> Result<()> { + let fixture = Fixture::new_v1()?; + let (_, rev) = fixture + .srcrepo() + .read_commit(fixture.testref(), gio::Cancellable::NONE)?; + let export1 = { + let mut h = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + ostree_ext::tar::export_commit(fixture.srcrepo(), rev.as_str(), &mut h, None)?; + h.finish()? + }; + // Artificial delay to flush out mtimes (one second granularity baseline, plus another 100ms for good measure). + std::thread::sleep(std::time::Duration::from_millis(1100)); + let export2 = { + let mut h = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + ostree_ext::tar::export_commit(fixture.srcrepo(), rev.as_str(), &mut h, None)?; + h.finish()? + }; + assert_eq!(*export1, *export2); + Ok(()) +} + +#[tokio::test] +async fn test_tar_import_signed() -> Result<()> { + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let test_tar = fixture.export_tar()?; + + let rev = fixture.srcrepo().require_rev(fixture.testref())?; + let (commitv, _) = fixture.srcrepo().load_commit(rev.as_str())?; + assert_eq!( + ostree::commit_get_content_checksum(&commitv) + .unwrap() + .as_str(), + CONTENTS_CHECKSUM_V0 + ); + + // Verify we fail with an unknown remote. + let src_tar = tokio::fs::File::from_std(fixture.dir.open(test_tar)?.into_std()); + let mut taropts = TarImportOptions::default(); + taropts.remote = Some("nosuchremote".to_string()); + let r = ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, Some(taropts)).await; + assert_err_contains(r, r#"Remote "nosuchremote" not found"#); + + // Test a remote, but without a key + let opts = glib::VariantDict::new(None); + opts.insert("gpg-verify", &true); + opts.insert("custom-backend", &"ostree-rs-ext"); + fixture + .destrepo() + .remote_add("myremote", None, Some(&opts.end()), gio::Cancellable::NONE)?; + let src_tar = tokio::fs::File::from_std(fixture.dir.open(test_tar)?.into_std()); + let mut taropts = TarImportOptions::default(); + taropts.remote = Some("myremote".to_string()); + let r = ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, Some(taropts)).await; + assert_err_contains(r, r#"Can't check signature: public key not found"#); + + // And signed correctly + cmd!( + sh, + "ostree --repo=dest/repo remote gpg-import --stdin myremote" + ) + .stdin(sh.read_file("src/gpghome/key1.asc")?) + .ignore_stdout() + .run()?; + let src_tar = tokio::fs::File::from_std(fixture.dir.open(test_tar)?.into_std()); + let mut taropts = TarImportOptions::default(); + taropts.remote = Some("myremote".to_string()); + let imported = ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, Some(taropts)).await?; + let (commitdata, state) = fixture.destrepo().load_commit(&imported)?; + assert_eq!( + CONTENTS_CHECKSUM_V0, + ostree::commit_get_content_checksum(&commitdata) + .unwrap() + .as_str() + ); + assert_eq!(state, ostree::RepoCommitState::NORMAL); + + // Drop the commit metadata, and verify that import fails + fixture.clear_destrepo()?; + let nometa = "test-no-commitmeta.tar"; + let srcf = fixture.dir.open(test_tar)?; + let destf = fixture.dir.create(nometa)?; + tokio::task::spawn_blocking(move || -> Result<_> { + let src = BufReader::new(srcf); + let f = BufWriter::new(destf); + ostree_ext::tar::update_detached_metadata(src, f, None, gio::Cancellable::NONE).unwrap(); + Ok(()) + }) + .await??; + let src_tar = tokio::fs::File::from_std(fixture.dir.open(nometa)?.into_std()); + let mut taropts = TarImportOptions::default(); + taropts.remote = Some("myremote".to_string()); + let r = ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, Some(taropts)).await; + assert_err_contains(r, "Expected commitmeta object"); + + // Now inject garbage into the commitmeta by flipping some bits in the signature + let rev = fixture.srcrepo().require_rev(fixture.testref())?; + let commitmeta = fixture + .srcrepo() + .read_commit_detached_metadata(&rev, gio::Cancellable::NONE)? + .unwrap(); + let mut commitmeta = Vec::from(&*commitmeta.data_as_bytes()); + let len = commitmeta.len() / 2; + let last = commitmeta.get_mut(len).unwrap(); + (*last) = last.wrapping_add(1); + + let srcf = fixture.dir.open(test_tar)?; + let destf = fixture.dir.create(nometa)?; + tokio::task::spawn_blocking(move || -> Result<_> { + let src = BufReader::new(srcf); + let f = BufWriter::new(destf); + ostree_ext::tar::update_detached_metadata( + src, + f, + Some(&commitmeta), + gio::Cancellable::NONE, + ) + .unwrap(); + Ok(()) + }) + .await??; + let src_tar = tokio::fs::File::from_std(fixture.dir.open(nometa)?.into_std()); + let mut taropts = TarImportOptions::default(); + taropts.remote = Some("myremote".to_string()); + let r = ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, Some(taropts)).await; + assert_err_contains(r, "BAD signature"); + + Ok(()) +} + +#[derive(Debug)] +struct TarExpected { + path: &'static str, + etype: tar::EntryType, + mode: u32, + should_have_security_capability: bool, +} + +#[allow(clippy::from_over_into)] +impl Into for (&'static str, tar::EntryType, u32) { + fn into(self) -> TarExpected { + TarExpected { + path: self.0, + etype: self.1, + mode: self.2, + should_have_security_capability: false, + } + } +} + +#[allow(clippy::from_over_into)] +impl Into for (&'static str, tar::EntryType, u32, bool) { + fn into(self) -> TarExpected { + TarExpected { + path: self.0, + etype: self.1, + mode: self.2, + should_have_security_capability: self.3, + } + } +} + +fn validate_tar_expected( + t: &mut tar::Entries, + expected: impl IntoIterator, +) -> Result<()> { + let mut expected: HashMap<&'static str, TarExpected> = + expected.into_iter().map(|exp| (exp.path, exp)).collect(); + let entries = t.map(|e| e.unwrap()); + let mut seen_paths = HashSet::new(); + // Verify we're injecting directories, fixes the absence of `/tmp` in our + // images for example. + for mut entry in entries { + if expected.is_empty() { + return Ok(()); + } + let header = entry.header(); + let entry_path = entry.path().unwrap().to_string_lossy().into_owned(); + if seen_paths.contains(&entry_path) { + anyhow::bail!("Duplicate path: {}", entry_path); + } + seen_paths.insert(entry_path.clone()); + if let Some(exp) = expected.remove(entry_path.as_str()) { + assert_eq!(header.entry_type(), exp.etype, "{entry_path}"); + let expected_mode = exp.mode; + let header_mode = header.mode().unwrap(); + assert_eq!( + header_mode, + expected_mode, + "h={header_mode:o} e={expected_mode:o} type: {:?} path: {}", + header.entry_type(), + entry_path + ); + if exp.should_have_security_capability { + let pax = entry + .pax_extensions()? + .ok_or_else(|| anyhow::anyhow!("Missing pax extensions for {entry_path}"))?; + let mut found = false; + for ent in pax { + let ent = ent?; + if ent.key_bytes() != b"SCHILY.xattr.security.capability" { + continue; + } + found = true; + break; + } + assert!(found, "Expected security.capability in {entry_path}"); + } + } + } + + assert!(expected.is_empty(), "Expected but not found:\n{expected:?}"); + Ok(()) +} + +fn common_tar_structure() -> impl Iterator { + use tar::EntryType::Directory; + [ + ("sysroot/ostree/repo/objects/00", Directory, 0o755), + ("sysroot/ostree/repo/objects/23", Directory, 0o755), + ("sysroot/ostree/repo/objects/77", Directory, 0o755), + ("sysroot/ostree/repo/objects/bc", Directory, 0o755), + ("sysroot/ostree/repo/objects/ff", Directory, 0o755), + ("sysroot/ostree/repo/refs", Directory, 0o755), + ("sysroot/ostree/repo/refs", Directory, 0o755), + ("sysroot/ostree/repo/refs/heads", Directory, 0o755), + ("sysroot/ostree/repo/refs/mirrors", Directory, 0o755), + ("sysroot/ostree/repo/refs/remotes", Directory, 0o755), + ("sysroot/ostree/repo/state", Directory, 0o755), + ("sysroot/ostree/repo/tmp", Directory, 0o755), + ("sysroot/ostree/repo/tmp/cache", Directory, 0o755), + ] + .into_iter() + .map(Into::into) +} + +// Find various expected files +fn common_tar_contents_all() -> impl Iterator { + use tar::EntryType::{Directory, Link, Regular}; + [ + ("boot", Directory, 0o755), + ("usr", Directory, 0o755), + ("usr/lib/emptyfile", Regular, 0o644), + ("usr/lib64/emptyfile2", Regular, 0o644), + ("usr/bin/bash", Link, 0o755), + ("usr/bin/hardlink-a", Link, 0o644), + ("usr/bin/hardlink-b", Link, 0o644), + ("var/tmp", Directory, 0o1777), + ] + .into_iter() + .map(Into::into) + .chain(std::iter::once( + ("sysroot/ostree/repo/objects/b0/48a4f451e9fdfaaec911c1fe07d5d1d39be02f932b827c25458d3b15ae589e.file", Regular, 0o755, true).into(), + )) +} + +/// Validate metadata (prelude) in a v1 tar. +fn validate_tar_v1_metadata(src: &mut tar::Entries) -> Result<()> { + use tar::EntryType::{Directory, Regular}; + let prelude = [ + ("sysroot/ostree/repo", Directory, 0o755), + ("sysroot/ostree/repo/config", Regular, 0o644), + ] + .into_iter() + .map(Into::into); + + validate_tar_expected(src, common_tar_structure().chain(prelude))?; + + Ok(()) +} + +/// Validate basic structure of the tar export. +#[test] +fn test_tar_export_structure() -> Result<()> { + let fixture = Fixture::new_v1()?; + + let src_tar = fixture.export_tar()?; + std::fs::copy(fixture.path.join(src_tar), "/tmp/test.tar").unwrap(); + let mut src_tar = fixture + .dir + .open(src_tar) + .map(BufReader::new) + .map(tar::Archive::new)?; + let mut src_tar = src_tar.entries()?; + validate_tar_v1_metadata(&mut src_tar).unwrap(); + validate_tar_expected(&mut src_tar, common_tar_contents_all())?; + + Ok(()) +} + +#[tokio::test] +async fn test_tar_import_export() -> Result<()> { + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let p = fixture.export_tar()?; + let src_tar = tokio::fs::File::from_std(fixture.dir.open(p)?.into_std()); + + let imported_commit: String = + ostree_ext::tar::import_tar(fixture.destrepo(), src_tar, None).await?; + let (commitdata, _) = fixture.destrepo().load_commit(&imported_commit)?; + assert_eq!( + CONTENTS_CHECKSUM_V0, + ostree::commit_get_content_checksum(&commitdata) + .unwrap() + .as_str() + ); + cmd!(sh, "ostree --repo=dest/repo ls -R {imported_commit}") + .ignore_stdout() + .run()?; + let val = cmd!(sh, "ostree --repo=dest/repo show --print-detached-metadata-key=my-detached-key {imported_commit}").read()?; + assert_eq!(val.as_str(), "'my-detached-value'"); + + let (root, _) = fixture + .destrepo() + .read_commit(&imported_commit, gio::Cancellable::NONE)?; + let kdir = ostree_ext::bootabletree::find_kernel_dir(&root, gio::Cancellable::NONE)?; + let kdir = kdir.unwrap(); + assert_eq!( + kdir.basename().unwrap().to_str().unwrap(), + "5.10.18-200.x86_64" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_tar_write() -> Result<()> { + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + // Test translating /etc to /usr/etc + fixture.dir.create_dir_all("tmproot/etc")?; + let tmproot = &fixture.dir.open_dir("tmproot")?; + let tmpetc = tmproot.open_dir("etc")?; + tmpetc.write("someconfig.conf", b"some config")?; + tmproot.create_dir_all("var/log")?; + let tmpvarlog = tmproot.open_dir("var/log")?; + tmpvarlog.write("foo.log", "foolog")?; + tmpvarlog.write("bar.log", "barlog")?; + tmproot.create_dir("run")?; + tmproot.write("run/somefile", "somestate")?; + let tmptar = "testlayer.tar"; + cmd!(sh, "tar cf {tmptar} -C tmproot .").run()?; + let src = fixture.dir.open(tmptar)?; + fixture.dir.remove_file(tmptar)?; + let src = tokio::fs::File::from_std(src.into_std()); + let r = ostree_ext::tar::write_tar( + fixture.destrepo(), + src, + oci_image::MediaType::ImageLayer, + "layer", + None, + ) + .await?; + let layer_commit = r.commit.as_str(); + cmd!( + sh, + "ostree --repo=dest/repo ls {layer_commit} /usr/etc/someconfig.conf" + ) + .ignore_stdout() + .run()?; + assert_eq!(r.filtered.len(), 1); + assert!(r.filtered.get("var").is_none()); + // TODO: change filter_tar to properly make this run/somefile, but eh...we're + // just going to accept this stuff in the future but ignore it anyways. + assert_eq!(*r.filtered.get("somefile").unwrap(), 1); + + Ok(()) +} + +#[tokio::test] +async fn test_tar_write_tar_layer() -> Result<()> { + let fixture = Fixture::new_v1()?; + let mut v = Vec::new(); + let mut dec = flate2::bufread::GzDecoder::new(std::io::Cursor::new(EXAMPLE_TAR_LAYER)); + let _n = std::io::copy(&mut dec, &mut v)?; + let r = tokio::io::BufReader::new(std::io::Cursor::new(v)); + ostree_ext::tar::write_tar( + fixture.destrepo(), + r, + oci_image::MediaType::ImageLayer, + "test", + None, + ) + .await?; + Ok(()) +} + +fn skopeo_inspect(imgref: &str) -> Result { + let out = Command::new("skopeo") + .args(["inspect", imgref]) + .stdout(std::process::Stdio::piped()) + .output()?; + Ok(String::from_utf8(out.stdout)?) +} + +fn skopeo_inspect_config(imgref: &str) -> Result { + let out = Command::new("skopeo") + .args(["inspect", "--config", imgref]) + .stdout(std::process::Stdio::piped()) + .output()?; + Ok(serde_json::from_slice(&out.stdout)?) +} + +async fn impl_test_container_import_export(chunked: bool) -> Result<()> { + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let testrev = fixture + .srcrepo() + .require_rev(fixture.testref()) + .context("Failed to resolve ref")?; + + let srcoci_path = &fixture.path.join("oci"); + let srcoci_imgref = ImageReference { + transport: Transport::OciDir, + name: srcoci_path.as_str().to_string(), + }; + let config = Config { + labels: Some( + [("foo", "bar"), ("test", "value")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ), + ..Default::default() + }; + // If chunking is requested, compute object ownership and size mappings + let contentmeta = chunked + .then(|| { + let meta = fixture.get_object_meta().context("Computing object meta")?; + ObjectMetaSized::compute_sizes(fixture.srcrepo(), meta).context("Computing sizes") + }) + .transpose()?; + let mut opts = ExportOpts::default(); + let container_config = oci_spec::image::ConfigBuilder::default() + .stop_signal("SIGRTMIN+3") + .build() + .unwrap(); + opts.copy_meta_keys = vec!["buildsys.checksum".to_string()]; + opts.copy_meta_opt_keys = vec!["nosuchvalue".to_string()]; + opts.max_layers = std::num::NonZeroU32::new(PKGS_V0_LEN as u32); + opts.package_contentmeta = contentmeta.as_ref(); + opts.container_config = Some(container_config); + let digest = ostree_ext::container::encapsulate( + fixture.srcrepo(), + fixture.testref(), + &config, + Some(opts), + &srcoci_imgref, + ) + .await + .context("exporting")?; + assert!(srcoci_path.exists()); + + let inspect = skopeo_inspect(&srcoci_imgref.to_string())?; + // Legacy path includes this + assert!(!inspect.contains(r#""version": "42.0""#)); + // Also include the new standard version + assert!(inspect.contains(r#""org.opencontainers.image.version": "42.0""#)); + assert!(inspect.contains(r#""foo": "bar""#)); + assert!(inspect.contains(r#""test": "value""#)); + assert!(inspect.contains( + r#""buildsys.checksum": "41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3""# + )); + let cfg = skopeo_inspect_config(&srcoci_imgref.to_string())?; + let creation_time = + chrono::NaiveDateTime::parse_from_str(cfg.created().as_deref().unwrap(), "%+").unwrap(); + assert_eq!(creation_time.and_utc().timestamp(), 872879442); + let found_cfg = cfg.config().as_ref().unwrap(); + // unwrap. Unwrap. UnWrap. UNWRAP!!!!!!! + assert_eq!( + found_cfg + .cmd() + .as_ref() + .unwrap() + .first() + .as_ref() + .unwrap() + .as_str(), + "/usr/bin/bash" + ); + assert_eq!(found_cfg.stop_signal().as_deref().unwrap(), "SIGRTMIN+3"); + + let n_chunks = if chunked { LAYERS_V0_LEN } else { 1 }; + assert_eq!(cfg.rootfs().diff_ids().len(), n_chunks); + assert_eq!(cfg.history().as_ref().unwrap().len(), n_chunks); + + // Verify exporting to ociarchive + { + let archivepath = &fixture.path.join("export.ociarchive"); + let ociarchive_dest = ImageReference { + transport: Transport::OciArchive, + name: archivepath.as_str().to_string(), + }; + let _: oci_image::Digest = ostree_ext::container::encapsulate( + fixture.srcrepo(), + fixture.testref(), + &config, + None, + &ociarchive_dest, + ) + .await + .context("exporting to ociarchive") + .unwrap(); + assert!(archivepath.is_file()); + } + + let srcoci_unverified = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: srcoci_imgref.clone(), + }; + + let (_, pushed_digest) = ostree_ext::container::fetch_manifest(&srcoci_unverified).await?; + assert_eq!(pushed_digest, digest); + + let (_, pushed_digest, _config) = + ostree_ext::container::fetch_manifest_and_config(&srcoci_unverified).await?; + assert_eq!(pushed_digest, digest); + + // No remote matching + let srcoci_unknownremote = OstreeImageReference { + sigverify: SignatureSource::OstreeRemote("unknownremote".to_string()), + imgref: srcoci_imgref.clone(), + }; + let r = ostree_ext::container::unencapsulate(fixture.destrepo(), &srcoci_unknownremote) + .await + .context("importing"); + assert_err_contains(r, r#"Remote "unknownremote" not found"#); + + // Test with a signature + let opts = glib::VariantDict::new(None); + opts.insert("gpg-verify", &true); + opts.insert("custom-backend", &"ostree-rs-ext"); + fixture + .destrepo() + .remote_add("myremote", None, Some(&opts.end()), gio::Cancellable::NONE)?; + cmd!( + sh, + "ostree --repo=dest/repo remote gpg-import --stdin myremote" + ) + .stdin(sh.read_file("src/gpghome/key1.asc")?) + .run()?; + let srcoci_verified = OstreeImageReference { + sigverify: SignatureSource::OstreeRemote("myremote".to_string()), + imgref: srcoci_imgref.clone(), + }; + let import = ostree_ext::container::unencapsulate(fixture.destrepo(), &srcoci_verified) + .await + .context("importing")?; + assert_eq!(import.ostree_commit, testrev.as_str()); + + let temp_unsigned = ImageReference { + transport: Transport::OciDir, + name: fixture.path.join("unsigned.ocidir").to_string(), + }; + let _ = ostree_ext::container::update_detached_metadata(&srcoci_imgref, &temp_unsigned, None) + .await + .unwrap(); + let temp_unsigned = OstreeImageReference { + sigverify: SignatureSource::OstreeRemote("myremote".to_string()), + imgref: temp_unsigned, + }; + fixture.clear_destrepo()?; + let r = ostree_ext::container::unencapsulate(fixture.destrepo(), &temp_unsigned).await; + assert_err_contains(r, "Expected commitmeta object"); + + // Test without signature verification + // Create a new repo + { + let fixture = Fixture::new_v1()?; + let import = ostree_ext::container::unencapsulate(fixture.destrepo(), &srcoci_unverified) + .await + .context("importing")?; + assert_eq!(import.ostree_commit, testrev.as_str()); + } + + Ok(()) +} + +#[tokio::test] +async fn test_export_as_container_nonderived() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + // Export into an OCI directory + let src_imgref = fixture.export_container().await.unwrap().0; + + let initimport = fixture.must_import(&src_imgref).await?; + let initimport_ls = fixture::ostree_ls(fixture.destrepo(), &initimport.merge_commit).unwrap(); + + let exported_ocidir_name = "exported.ocidir"; + let dest = ImageReference { + transport: Transport::OciDir, + name: format!("{}:exported-test", fixture.path.join(exported_ocidir_name)), + }; + fixture.dir.create_dir(exported_ocidir_name)?; + let ocidir = ocidir::OciDir::ensure(fixture.dir.open_dir(exported_ocidir_name)?)?; + let exported = store::export(fixture.destrepo(), &src_imgref, &dest, None) + .await + .unwrap(); + + let idx = ocidir.read_index()?; + let desc = idx.manifests().first().unwrap(); + let new_manifest: oci_image::ImageManifest = ocidir.read_json_blob(desc).unwrap(); + + assert_eq!(desc.digest().to_string(), exported.to_string()); + assert_eq!(new_manifest.layers().len(), fixture::LAYERS_V0_LEN); + + // Reset the destrepo + fixture.clear_destrepo()?; + // Clear out the original source + std::fs::remove_dir_all(src_imgref.name.as_str())?; + + let reimported = fixture.must_import(&dest).await?; + let reimport_ls = fixture::ostree_ls(fixture.destrepo(), &reimported.merge_commit).unwrap(); + similar_asserts::assert_eq!(initimport_ls, reimport_ls); + Ok(()) +} + +/// Verify that fetches of a digested pull spec don't do networking +#[tokio::test] +async fn test_no_fetch_digested() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let (src_imgref_oci, expected_digest) = fixture.export_container().await.unwrap(); + let mut imp = store::ImageImporter::new( + fixture.destrepo(), + &OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: src_imgref_oci.clone(), + }, + Default::default(), + ) + .await + .unwrap(); + // Because oci: transport doesn't allow digested pull specs, we pull from OCI, but set the target + // to a registry. + let target_imgref_name = Reference::with_digest( + "quay.io/exampleos".into(), + "example".into(), + expected_digest.to_string(), + ); + let target_imgref = ImageReference { + transport: Transport::Registry, + name: target_imgref_name.to_string(), + }; + let target_imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: target_imgref, + }; + imp.set_target(&target_imgref); + let prep = match imp.prepare().await? { + store::PrepareResult::AlreadyPresent(_) => unreachable!(), + store::PrepareResult::Ready(prep) => prep, + }; + let r = imp.import(prep).await.unwrap(); + assert_eq!(r.manifest_digest, expected_digest); + let mut imp = store::ImageImporter::new(fixture.destrepo(), &target_imgref, Default::default()) + .await + .unwrap(); + // And the key test, we shouldn't reach out to the registry here + imp.set_offline(); + match imp.prepare().await.context("Init prep derived").unwrap() { + store::PrepareResult::AlreadyPresent(_) => {} + store::PrepareResult::Ready(_) => panic!("Should have image already"), + }; + + Ok(()) +} + +#[tokio::test] +async fn test_export_as_container_derived() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + // Export into an OCI directory + let src_imgref = fixture.export_container().await.unwrap().0; + // Add a derived layer + let derived_tag = "derived"; + // Build a derived image + let srcpath = src_imgref.name.as_str(); + fixture.generate_test_derived_oci(srcpath, Some(&derived_tag))?; + let derived_imgref = ImageReference { + transport: src_imgref.transport, + name: format!("{}:{derived_tag}", src_imgref.name.as_str()), + }; + + // The first import into destrepo of the derived OCI + let initimport = fixture.must_import(&derived_imgref).await?; + let initimport_ls = fixture::ostree_ls(fixture.destrepo(), &initimport.merge_commit).unwrap(); + // Export it + let exported_ocidir_name = "exported.ocidir"; + let dest = ImageReference { + transport: Transport::OciDir, + name: format!("{}:exported-test", fixture.path.join(exported_ocidir_name)), + }; + fixture.dir.create_dir(exported_ocidir_name)?; + let ocidir = ocidir::OciDir::ensure(fixture.dir.open_dir(exported_ocidir_name)?)?; + let exported = store::export(fixture.destrepo(), &derived_imgref, &dest, None) + .await + .unwrap(); + + let idx = ocidir.read_index()?; + let desc = idx.manifests().first().unwrap(); + let new_manifest: oci_image::ImageManifest = ocidir.read_json_blob(desc).unwrap(); + + assert_eq!(desc.digest().digest(), exported.digest()); + assert_eq!(new_manifest.layers().len(), fixture::LAYERS_V0_LEN + 1); + + // Reset the destrepo + fixture.clear_destrepo()?; + // Clear out the original source + std::fs::remove_dir_all(srcpath)?; + + let reimported = fixture.must_import(&dest).await?; + let reimport_ls = fixture::ostree_ls(fixture.destrepo(), &reimported.merge_commit).unwrap(); + similar_asserts::assert_eq!(initimport_ls, reimport_ls); + + Ok(()) +} + +#[tokio::test] +async fn test_unencapsulate_unbootable() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = { + let mut fixture = Fixture::new_base()?; + fixture.bootable = false; + fixture.commit_filedefs(FileDef::iter_from(ostree_ext::fixture::CONTENTS_V0))?; + fixture + }; + let testrev = fixture + .srcrepo() + .require_rev(fixture.testref()) + .context("Failed to resolve ref")?; + let srcoci_path = &fixture.path.join("oci"); + let srcoci_imgref = ImageReference { + transport: Transport::OciDir, + name: srcoci_path.as_str().to_string(), + }; + let srcoci_unverified = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: srcoci_imgref.clone(), + }; + + let config = Config::default(); + let _digest = ostree_ext::container::encapsulate( + fixture.srcrepo(), + fixture.testref(), + &config, + None, + &srcoci_imgref, + ) + .await + .context("exporting")?; + assert!(srcoci_path.exists()); + + assert!(fixture + .destrepo() + .resolve_rev(fixture.testref(), true) + .unwrap() + .is_none()); + + let target = ostree_ext::container::unencapsulate(fixture.destrepo(), &srcoci_unverified) + .await + .unwrap(); + + assert_eq!(target.ostree_commit.as_str(), testrev.as_str()); + + Ok(()) +} + +/// Parse a chunked container image and validate its structure; particularly +fn validate_chunked_structure(oci_path: &Utf8Path) -> Result<()> { + use tar::EntryType::Link; + + let d = Dir::open_ambient_dir(oci_path, cap_std::ambient_authority())?; + let d = ocidir::OciDir::open(d)?; + let idx = d.read_index()?; + let desc = idx.manifests().first().unwrap(); + let manifest: oci_image::ImageManifest = d.read_json_blob(desc).unwrap(); + + assert_eq!(manifest.layers().len(), LAYERS_V0_LEN); + let ostree_layer = manifest.layers().first().unwrap(); + let mut ostree_layer_blob = d + .read_blob(ostree_layer) + .map(BufReader::new) + .map(flate2::read::GzDecoder::new) + .map(tar::Archive::new)?; + let mut ostree_layer_blob = ostree_layer_blob.entries()?; + validate_tar_v1_metadata(&mut ostree_layer_blob)?; + + // This layer happens to be first + let pkgdb_layer_offset = 1; + let pkgdb_layer = &manifest.layers()[pkgdb_layer_offset]; + let mut pkgdb_blob = d + .read_blob(pkgdb_layer) + .map(BufReader::new) + .map(flate2::read::GzDecoder::new) + .map(tar::Archive::new)?; + + let pkgdb = [ + ("usr/lib/pkgdb/pkgdb", Link, 0o644), + ("usr/lib/sysimage/pkgdb", Link, 0o644), + ] + .into_iter() + .map(Into::into); + + validate_tar_expected(&mut pkgdb_blob.entries()?, pkgdb)?; + + Ok(()) +} + +#[tokio::test] +async fn test_container_arch_mismatch() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + + let imgref = fixture.export_container().await.unwrap().0; + + // Build a derived image + let derived_path = &fixture.path.join("derived.oci"); + let srcpath = imgref.name.as_str(); + oci_clone(srcpath, derived_path).await.unwrap(); + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut layer_tar = tar::Builder::new(w); + let mut h = tar::Header::new_gnu(); + h.set_uid(0); + h.set_gid(0); + h.set_size(0); + h.set_mode(0o755); + h.set_entry_type(tar::EntryType::Directory); + layer_tar.append_data( + &mut h.clone(), + "etc/mips-operating-system", + &mut std::io::empty(), + )?; + layer_tar.into_inner()?; + Ok(()) + }, + None, + Some(Arch::Mips64le), + )?; + + let derived_imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + imp.require_bootable(); + imp.set_ostree_version(2023, 11); + let r = imp.prepare().await; + assert_err_contains(r, "Image has architecture mips64le"); + + Ok(()) +} + +#[tokio::test] +async fn test_container_chunked() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let nlayers = LAYERS_V0_LEN - 1; + let mut fixture = Fixture::new_v1()?; + + let (imgref, expected_digest) = fixture.export_container().await.unwrap(); + let exported_commit = fixture.srcrepo().require_rev(fixture.testref())?; + let imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref, + }; + // Validate the structure of the image + match &imgref.imgref { + ImageReference { + transport: Transport::OciDir, + name, + } => validate_chunked_structure(Utf8Path::new(name)).unwrap(), + _ => unreachable!(), + }; + + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &imgref, Default::default()).await?; + assert!(store::query_image(fixture.destrepo(), &imgref.imgref) + .unwrap() + .is_none()); + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let labels = prep.config.labels_of_config().unwrap(); + assert!(prep.deprecated_warning().is_none()); + assert_eq!( + labels.get(OSTREE_COMMIT_LABEL).unwrap(), + exported_commit.as_str() + ); + assert_eq!(prep.version(), Some("42.0")); + let digest = prep.manifest_digest.clone(); + assert!(prep.ostree_commit_layer.as_ref().unwrap().commit.is_none()); + assert_eq!(prep.ostree_layers.len(), nlayers); + assert_eq!(prep.layers.len(), 0); + for layer in prep.layers.iter() { + assert!(layer.commit.is_none()); + } + assert_eq!(digest, expected_digest); + { + let mut layer_history = prep.layers_with_history(); + assert!(layer_history + .next() + .unwrap()? + .1 + .created_by() + .as_ref() + .unwrap() + .starts_with("ostree export")); + assert_eq!( + layer_history + .next() + .unwrap()? + .1 + .created_by() + .as_ref() + .unwrap(), + "9 components" + ); + } + let import = imp.import(prep).await.context("Init pull derived").unwrap(); + assert_eq!(import.manifest_digest, digest); + // For now we never expect that these are the same + assert_ne!(import.get_commit(), exported_commit.as_str()); + // But the parent should match + let commit_obj = fixture.destrepo().load_commit(import.get_commit())?.0; + assert_eq!( + ostree::commit_get_parent(&commit_obj).unwrap(), + exported_commit.as_str() + ); + + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 1); + + assert!( + store::image_filtered_content_warning(&import.filtered_files) + .unwrap() + .is_none() + ); + // Verify there are no updates. + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &imgref, Default::default()).await?; + let state = match imp.prepare().await? { + store::PrepareResult::AlreadyPresent(i) => i, + store::PrepareResult::Ready(_) => panic!("should be already imported"), + }; + assert!(state.cached_update.is_none()); + + const ADDITIONS: &str = indoc::indoc! { " +r usr/bin/bash bash-v0 +"}; + fixture + .update(FileDef::iter_from(ADDITIONS), std::iter::empty()) + .context("Failed to update")?; + + let expected_digest = fixture.export_container().await.unwrap().1; + assert_ne!(digest, expected_digest); + + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &imgref, Default::default()).await?; + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + // Verify we also serialized the cached update + { + let cached = store::query_image(fixture.destrepo(), &imgref.imgref) + .unwrap() + .unwrap(); + assert_eq!(cached.version(), Some("42.0")); + + let cached_update = cached.cached_update.unwrap(); + assert_eq!(cached_update.manifest_digest, prep.manifest_digest); + assert_eq!(cached_update.version(), Some("42.0")); + } + let to_fetch = prep.layers_to_fetch().collect::>>()?; + assert_eq!(to_fetch.len(), 2); + assert_eq!(expected_digest, prep.manifest_digest); + assert!(prep.ostree_commit_layer.as_ref().unwrap().commit.is_none()); + assert_eq!(prep.ostree_layers.len(), nlayers); + let (first, second) = (to_fetch[0], to_fetch[1]); + assert!(first.0.commit.is_none()); + assert!(second.0.commit.is_none()); + assert_eq!( + first.1, + "ostree export of commit 6e6afc49d902daa2456c858818e0ad8bf9afe79cdcca738c5676c0e175c1def1" + ); + assert_eq!(second.1, "9 components"); + + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 1); + let n = store::count_layer_references(fixture.destrepo())? as i64; + let _import = imp.import(prep).await.unwrap(); + + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 1); + + let n2 = store::count_layer_references(fixture.destrepo())? as i64; + assert_eq!(n, n2); + fixture + .destrepo() + .prune(ostree::RepoPruneFlags::REFS_ONLY, 0, gio::Cancellable::NONE)?; + + // Build a derived image + let srcpath = imgref.imgref.name.as_str(); + let derived_tag = "derived"; + fixture.generate_test_derived_oci(srcpath, Some(&derived_tag))?; + + let derived_imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: format!("{srcpath}:{derived_tag}"), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + let prep = match imp.prepare().await.unwrap() { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let to_fetch = prep.layers_to_fetch().collect::>>()?; + assert_eq!(to_fetch.len(), 1); + assert!(prep.ostree_commit_layer.as_ref().unwrap().commit.is_some()); + assert_eq!(prep.ostree_layers.len(), nlayers); + + // We want to test explicit layer pruning + imp.disable_gc(); + let import = imp.import(prep).await.unwrap(); + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 2); + + assert_eq!( + store::image_filtered_content_warning(&import.filtered_files) + .unwrap() + .unwrap(), + "Image contains non-ostree compatible file paths: filtered: 1" + ); + + // redo it but with the layers already imported and sanity-check that the + // merge commit is the same + let merge_commit = import.merge_commit; + store::remove_image(fixture.destrepo(), &derived_imgref.imgref).unwrap(); + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + let prep = match imp.prepare().await.unwrap() { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.context("reimport").unwrap(); + assert_eq!(import.merge_commit, merge_commit); + + // but this time we don't get the warning because we didn't reimport the derived layer + assert!( + store::image_filtered_content_warning(&import.filtered_files) + .unwrap() + .is_none() + ); + + // Should only be new layers + let n_removed = store::gc_image_layers(fixture.destrepo())?; + assert_eq!(n_removed, 0); + // Also test idempotence + store::remove_image(fixture.destrepo(), &imgref.imgref).unwrap(); + store::remove_image(fixture.destrepo(), &imgref.imgref).unwrap(); + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 1); + // Still no removed layers after removing the base image + let n_removed = store::gc_image_layers(fixture.destrepo())?; + assert_eq!(n_removed, 0); + store::remove_images(fixture.destrepo(), [&derived_imgref.imgref]).unwrap(); + assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 0); + let n_removed = store::gc_image_layers(fixture.destrepo())?; + assert_eq!(n_removed, (LAYERS_V0_LEN + 1) as u32); + + // Repo should be clean now + assert_eq!(store::count_layer_references(fixture.destrepo())?, 0); + assert_eq!( + fixture + .destrepo() + .list_refs(None, gio::Cancellable::NONE) + .unwrap() + .len(), + 0 + ); + + Ok(()) +} + +#[tokio::test] +async fn test_container_var_content() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + + let imgref = fixture.export_container().await.unwrap().0; + let imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref, + }; + + // Build a derived image + let derived_path = &fixture.path.join("derived.oci"); + let srcpath = imgref.imgref.name.as_str(); + oci_clone(srcpath, derived_path).await.unwrap(); + let temproot = &fixture.path.join("temproot"); + let junk_var_data = "junk var data"; + || -> Result<_> { + std::fs::create_dir(temproot)?; + let temprootd = Dir::open_ambient_dir(temproot, cap_std::ambient_authority())?; + let mut db = DirBuilder::new(); + db.mode(0o755); + db.recursive(true); + temprootd.create_dir_with("var/lib", &db)?; + temprootd.write("var/lib/foo", junk_var_data)?; + Ok(()) + }() + .context("generating temp content")?; + ostree_ext::integrationtest::generate_derived_oci(derived_path, temproot, None)?; + + let derived_imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + imp.set_ostree_version(2023, 11); + let prep = match imp.prepare().await.unwrap() { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.unwrap(); + + let ostree_root = fixture + .destrepo() + .read_commit(&import.merge_commit, gio::Cancellable::NONE)? + .0; + let varfile = ostree_root + .child("usr/share/factory/var/lib/foo") + .downcast::() + .unwrap(); + assert_eq!( + ostree_manual::repo_file_read_to_string(&varfile)?, + junk_var_data + ); + assert!(!ostree_root + .child("var/lib/foo") + .query_exists(gio::Cancellable::NONE)); + + assert!( + store::image_filtered_content_warning(&import.filtered_files) + .unwrap() + .is_none() + ); + + // Reset things + fixture.clear_destrepo()?; + + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + imp.set_ostree_version(2024, 3); + let prep = match imp.prepare().await.unwrap() { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.unwrap(); + let ostree_root = fixture + .destrepo() + .read_commit(&import.merge_commit, gio::Cancellable::NONE)? + .0; + let varfile = ostree_root + .child("usr/share/factory/var/lib/foo") + .downcast::() + .unwrap(); + assert!(!varfile.query_exists(gio::Cancellable::NONE)); + assert!(ostree_root + .child("var/lib/foo") + .query_exists(gio::Cancellable::NONE)); + Ok(()) +} + +#[tokio::test] +async fn test_container_etc_hardlinked_absolute() -> Result<()> { + test_container_etc_hardlinked(true).await +} + +#[tokio::test] +async fn test_container_etc_hardlinked_relative() -> Result<()> { + test_container_etc_hardlinked(false).await +} + +async fn test_container_etc_hardlinked(absolute: bool) -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + + let imgref = fixture.export_container().await.unwrap().0; + let imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref, + }; + + // Build a derived image + let derived_path = &fixture.path.join("derived.oci"); + let srcpath = imgref.imgref.name.as_str(); + oci_clone(srcpath, derived_path).await.unwrap(); + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut layer_tar = tar::Builder::new(w); + // Create a simple hardlinked file /etc/foo and /etc/bar in the tar stream, which + // needs usr/etc processing. + let mut h = tar::Header::new_gnu(); + h.set_uid(0); + h.set_gid(0); + h.set_size(0); + h.set_mode(0o755); + h.set_entry_type(tar::EntryType::Directory); + layer_tar.append_data(&mut h.clone(), "etc", &mut std::io::empty())?; + let testdata = "hardlinked test data"; + h.set_mode(0o644); + h.set_size(testdata.len().try_into().unwrap()); + h.set_entry_type(tar::EntryType::Regular); + layer_tar.append_data( + &mut h.clone(), + "etc/foo", + std::io::Cursor::new(testdata.as_bytes()), + )?; + h.set_entry_type(tar::EntryType::Link); + h.set_size(0); + layer_tar.append_link(&mut h.clone(), "etc/bar", "etc/foo")?; + + // Another case where we have /etc/dnf.conf and a hardlinked /ostree/repo/objects + // link into it - in this case we should ignore the hardlinked one. + let testdata = "hardlinked into object store"; + let mut h = tar::Header::new_ustar(); + h.set_mode(0o644); + h.set_mtime(42); + h.set_size(testdata.len().try_into().unwrap()); + h.set_entry_type(tar::EntryType::Regular); + layer_tar.append_data( + &mut h.clone(), + "etc/dnf.conf", + std::io::Cursor::new(testdata.as_bytes()), + )?; + h.set_entry_type(tar::EntryType::Link); + h.set_mtime(42); + h.set_size(0); + let path = "sysroot/ostree/repo/objects/45/7279b28b541ca20358bec8487c81baac6a3d5ed3cea019aee675137fab53cb.file"; + let target = "etc/dnf.conf"; + if absolute { + let ustarname = &mut h.as_ustar_mut().unwrap().name; + // The tar crate doesn't let us set absolute paths in tar archives, so we bypass + // it and just write to the path buffer directly. + assert!(path.len() < ustarname.len()); + ustarname[0..path.len()].copy_from_slice(path.as_bytes()); + h.set_link_name(target)?; + h.set_cksum(); + layer_tar.append(&mut h.clone(), std::io::empty())?; + } else { + layer_tar.append_link(&mut h.clone(), path, target)?; + } + layer_tar.finish()?; + Ok(()) + }, + None, + None, + )?; + + let derived_imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_imgref, Default::default()).await?; + imp.set_ostree_version(2023, 11); + let prep = match imp.prepare().await.unwrap() { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.unwrap(); + let r = fixture + .destrepo() + .read_commit(import.get_commit(), gio::Cancellable::NONE)? + .0; + let foo = r.resolve_relative_path("usr/etc/foo"); + let foo = foo.downcast_ref::().unwrap(); + foo.ensure_resolved()?; + let bar = r.resolve_relative_path("usr/etc/bar"); + let bar = bar.downcast_ref::().unwrap(); + bar.ensure_resolved()?; + assert_eq!(foo.checksum(), bar.checksum()); + + let dnfconf = r.resolve_relative_path("usr/etc/dnf.conf"); + let dnfconf: &ostree::RepoFile = dnfconf.downcast_ref::().unwrap(); + dnfconf.ensure_resolved()?; + + Ok(()) +} + +#[tokio::test] +async fn test_non_ostree() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = NonOstreeFixture::new_base()?; + let (imgref, src_digest) = fixture.export_container().await.unwrap(); + let imp = fixture.must_import(&imgref).await?; + if imp.manifest_digest != src_digest { + let src_manifest: oci_image::ImageManifest = { + let idx = fixture.src_oci.read_index()?; + let manifest = idx + .manifests() + .iter() + .find(|m| m.digest() == &src_digest) + .unwrap(); + fixture.src_oci.read_json_blob(manifest)? + }; + let src_manifest = serde_json::to_string_pretty(&src_manifest).unwrap(); + let dest_manifest = serde_json::to_string_pretty(&imp.manifest).unwrap(); + similar_asserts::assert_eq!(&src_manifest, &dest_manifest); + } + assert_eq!(imp.manifest_digest, src_digest); + Ok(()) +} + +/// Copy an OCI directory. +async fn oci_clone(src: impl AsRef, dest: impl AsRef) -> Result<()> { + let src = src.as_ref(); + let dest = dest.as_ref(); + // For now we just fork off `cp` and rely on reflinks, but we could and should + // explicitly hardlink blobs/sha256 e.g. + let cmd = tokio::process::Command::new("cp") + .args(["-a", "--reflink=auto"]) + .args([src, dest]) + .status() + .await?; + if !cmd.success() { + anyhow::bail!("cp failed"); + } + Ok(()) +} + +#[tokio::test] +async fn test_container_import_export_v1() { + if !check_skopeo() { + return; + } + impl_test_container_import_export(false).await.unwrap(); + impl_test_container_import_export(true).await.unwrap(); +} + +/// But layers work via the container::write module. +#[tokio::test] +async fn test_container_write_derive() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let cancellable = gio::Cancellable::NONE; + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let base_oci_path = &fixture.path.join("exampleos.oci"); + let _digest = ostree_ext::container::encapsulate( + fixture.srcrepo(), + fixture.testref(), + &Config { + cmd: Some(vec!["/bin/bash".to_string()]), + ..Default::default() + }, + None, + &ImageReference { + transport: Transport::OciDir, + name: base_oci_path.to_string(), + }, + ) + .await + .context("exporting")?; + assert!(base_oci_path.exists()); + + // Build the derived images + let derived_path = &fixture.path.join("derived.oci"); + oci_clone(base_oci_path, derived_path).await?; + let temproot = &fixture.path.join("temproot"); + std::fs::create_dir_all(temproot.join("usr/bin"))?; + let newderivedfile_contents = "newderivedfile v0"; + std::fs::write( + temproot.join("usr/bin/newderivedfile"), + newderivedfile_contents, + )?; + std::fs::write( + temproot.join("usr/bin/newderivedfile3"), + "newderivedfile3 v0", + )?; + // Remove the kernel directory and make a new one + let moddir = temproot.join("usr/lib/modules"); + let oldkernel = "5.10.18-200.x86_64"; + std::fs::create_dir_all(&moddir)?; + let oldkernel_wh = &format!(".wh.{oldkernel}"); + std::fs::write(moddir.join(oldkernel_wh), "")?; + let newkdir = moddir.join("5.12.7-42.x86_64"); + std::fs::create_dir_all(&newkdir)?; + std::fs::write(newkdir.join("vmlinuz"), "a new kernel")?; + ostree_ext::integrationtest::generate_derived_oci(derived_path, temproot, None)?; + // And v2 + let derived2_path = &fixture.path.join("derived2.oci"); + oci_clone(base_oci_path, derived2_path).await?; + std::fs::remove_dir_all(temproot)?; + std::fs::create_dir_all(temproot.join("usr/bin"))?; + std::fs::write(temproot.join("usr/bin/newderivedfile"), "newderivedfile v1")?; + std::fs::write( + temproot.join("usr/bin/newderivedfile2"), + "newderivedfile2 v0", + )?; + ostree_ext::integrationtest::generate_derived_oci(derived2_path, temproot, None)?; + + let derived_ref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + // There shouldn't be any container images stored yet. + let images = store::list_images(fixture.destrepo())?; + assert!(images.is_empty()); + + // Verify importing a derived image fails + let r = ostree_ext::container::unencapsulate(fixture.destrepo(), &derived_ref).await; + assert_err_contains(r, "Image has 1 non-ostree layers"); + + // Pull a derived image - two layers, new base plus one layer. + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_ref, Default::default()).await?; + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let expected_digest = prep.manifest_digest.clone(); + assert!(prep.ostree_commit_layer.as_ref().unwrap().commit.is_none()); + assert_eq!(prep.layers.len(), 1); + for layer in prep.layers.iter() { + assert!(layer.commit.is_none()); + } + let import = imp.import(prep).await.context("Init pull derived")?; + // We should have exactly one image stored. + let images = store::list_images(fixture.destrepo())?; + assert_eq!(images.len(), 1); + assert_eq!(images[0], derived_ref.imgref.to_string()); + + let imported_commit = &fixture + .destrepo() + .load_commit(import.merge_commit.as_str())? + .0; + let digest = store::manifest_digest_from_commit(imported_commit)?; + assert_eq!(digest.algorithm(), &DigestAlgorithm::Sha256); + assert_eq!(digest, expected_digest); + + let commit_meta = &imported_commit.child_value(0); + let commit_meta = glib::VariantDict::new(Some(commit_meta)); + let config = commit_meta + .lookup::("ostree.container.image-config")? + .unwrap(); + let config: oci_spec::image::ImageConfiguration = serde_json::from_str(&config)?; + assert_eq!(config.os(), &oci_spec::image::Os::Linux); + + // Parse the commit and verify we pulled the derived content. + let root = fixture + .destrepo() + .read_commit(&import.merge_commit, cancellable)? + .0; + let root = root.downcast_ref::().unwrap(); + { + let derived = root.resolve_relative_path("usr/bin/newderivedfile"); + let derived = derived.downcast_ref::().unwrap(); + let found_newderived_contents = + ostree_ext::ostree_manual::repo_file_read_to_string(derived)?; + assert_eq!(found_newderived_contents, newderivedfile_contents); + + let kver = ostree_ext::bootabletree::find_kernel_dir(root.upcast_ref(), cancellable) + .unwrap() + .unwrap() + .basename() + .unwrap(); + let kver = Utf8Path::from_path(&kver).unwrap(); + assert_eq!(kver, newkdir.file_name().unwrap()); + + let old_kernel_dir = root.resolve_relative_path(format!("usr/lib/modules/{oldkernel}")); + assert!(!old_kernel_dir.query_exists(cancellable)); + } + + // Import again, but there should be no changes. + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_ref, Default::default()).await?; + let already_present = match imp.prepare().await? { + store::PrepareResult::AlreadyPresent(c) => c, + store::PrepareResult::Ready(_) => { + panic!("Should have already imported {}", &derived_ref) + } + }; + assert_eq!(import.merge_commit, already_present.merge_commit); + + // Test upgrades; replace the oci-archive with new content. + std::fs::remove_dir_all(derived_path)?; + std::fs::rename(derived2_path, derived_path)?; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_ref, Default::default()).await?; + let prep = match imp.prepare().await? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + // We *should* already have the base layer. + assert!(prep.ostree_commit_layer.as_ref().unwrap().commit.is_some()); + // One new layer + assert_eq!(prep.layers.len(), 1); + for layer in prep.layers.iter() { + assert!(layer.commit.is_none()); + } + let import = imp.import(prep).await?; + // New commit. + assert_ne!(import.merge_commit, already_present.merge_commit); + // We should still have exactly one image stored. + let images = store::list_images(fixture.destrepo())?; + assert_eq!(images[0], derived_ref.imgref.to_string()); + assert_eq!(images.len(), 1); + + // Verify we have the new file and *not* the old one + let merge_commit = import.merge_commit.as_str(); + cmd!( + sh, + "ostree --repo=dest/repo ls {merge_commit} /usr/bin/newderivedfile2" + ) + .ignore_stdout() + .run()?; + let c = cmd!( + sh, + "ostree --repo=dest/repo cat {merge_commit} /usr/bin/newderivedfile" + ) + .read()?; + assert_eq!(c.as_str(), "newderivedfile v1"); + assert!(cmd!( + sh, + "ostree --repo=dest/repo ls {merge_commit} /usr/bin/newderivedfile3" + ) + .ignore_stderr() + .run() + .is_err()); + + // And there should be no changes on upgrade again. + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &derived_ref, Default::default()).await?; + let already_present = match imp.prepare().await? { + store::PrepareResult::AlreadyPresent(c) => c, + store::PrepareResult::Ready(_) => { + panic!("Should have already imported {}", &derived_ref) + } + }; + assert_eq!(import.merge_commit, already_present.merge_commit); + + // Create a new repo, and copy to it + let destrepo2 = ostree::Repo::create_at( + ostree::AT_FDCWD, + fixture.path.join("destrepo2").as_str(), + ostree::RepoMode::Archive, + None, + gio::Cancellable::NONE, + )?; + #[allow(deprecated)] + store::copy( + fixture.destrepo(), + &derived_ref.imgref, + &destrepo2, + &derived_ref.imgref, + ) + .await + .context("Copying")?; + + let images = store::list_images(&destrepo2)?; + assert_eq!(images.len(), 1); + assert_eq!(images[0], derived_ref.imgref.to_string()); + + // And test copy_as + let target_name = "quay.io/exampleos/centos:stream9"; + let registry_ref = ImageReference { + transport: Transport::Registry, + name: target_name.to_string(), + }; + store::copy( + fixture.destrepo(), + &derived_ref.imgref, + &destrepo2, + ®istry_ref, + ) + .await + .context("Copying")?; + + let mut images = store::list_images(&destrepo2)?; + images.sort_unstable(); + assert_eq!(images[0], registry_ref.to_string()); + assert_eq!(images[1], derived_ref.imgref.to_string()); + + Ok(()) +} + +/// Implementation of a test case for non-gzip (i.e. zstd or zstd:chunked) compression +async fn test_non_gzip(format: &str) -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let baseimg = &fixture.export_container().await?.0; + let basepath = &match baseimg.transport { + Transport::OciDir => fixture.path.join(baseimg.name.as_str()), + _ => unreachable!(), + }; + let baseimg_ref = format!("oci:{basepath}"); + let zstd_image_path = &fixture.path.join("zstd.oci"); + let st = tokio::process::Command::new("skopeo") + .args([ + "copy", + &format!("--dest-compress-format={format}"), + baseimg_ref.as_str(), + &format!("oci:{zstd_image_path}"), + ]) + .status() + .await?; + assert!(st.success()); + + let zstdref = &OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: zstd_image_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), zstdref, Default::default()).await?; + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let _ = imp.import(prep).await.unwrap(); + + Ok(()) +} + +/// Test for zstd +#[tokio::test] +async fn test_container_zstd() -> Result<()> { + test_non_gzip("zstd").await +} + +/// Test for zstd:chunked +#[tokio::test] +async fn test_container_zstd_chunked() -> Result<()> { + test_non_gzip("zstd:chunked").await +} + +/// Test for https://github.com/ostreedev/ostree-rs-ext/issues/405 +/// We need to handle the case of modified hardlinks into /sysroot +#[tokio::test] +async fn test_container_write_derive_sysroot_hardlink() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let baseimg = &fixture.export_container().await?.0; + let basepath = &match baseimg.transport { + Transport::OciDir => fixture.path.join(baseimg.name.as_str()), + _ => unreachable!(), + }; + + // Build a derived image + let derived_path = &fixture.path.join("derived.oci"); + oci_clone(basepath, derived_path).await?; + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut tar = tar::Builder::new(w); + let objpath = Utf8Path::new("sysroot/ostree/repo/objects/60/feb13e826d2f9b62490ab24cea0f4a2d09615fb57027e55f713c18c59f4796.file"); + let d = objpath.parent().unwrap(); + fn mkparents( + t: &mut tar::Builder, + path: &Utf8Path, + ) -> std::io::Result<()> { + if let Some(parent) = path.parent().filter(|p| !p.as_str().is_empty()) { + mkparents(t, parent)?; + } + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Directory); + h.set_uid(0); + h.set_gid(0); + h.set_mode(0o755); + h.set_size(0); + t.append_data(&mut h, path, std::io::empty()) + } + mkparents(&mut tar, d).context("Appending parent")?; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_uid(0); + h.set_gid(0); + h.set_mode(0o644); + h.set_mtime(now); + let data = b"hello"; + h.set_size(data.len() as u64); + tar.append_data(&mut h, objpath, std::io::Cursor::new(data)) + .context("appending object")?; + for path in ["usr/bin/bash", "usr/bin/bash-hardlinked"] { + let targetpath = Utf8Path::new(path); + h.set_size(0); + h.set_mtime(now); + h.set_entry_type(tar::EntryType::Link); + tar.append_link(&mut h, targetpath, objpath) + .context("appending target")?; + } + Ok::<_, anyhow::Error>(()) + }, + None, + None, + )?; + let derived_ref = &OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), derived_ref, Default::default()).await?; + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.unwrap(); + + // Verify we have the new file + let merge_commit = import.merge_commit.as_str(); + cmd!( + sh, + "ostree --repo=dest/repo ls {merge_commit} /usr/bin/bash" + ) + .ignore_stdout() + .run()?; + for path in ["/usr/bin/bash", "/usr/bin/bash-hardlinked"] { + let r = cmd!(sh, "ostree --repo=dest/repo cat {merge_commit} {path}").read()?; + assert_eq!(r.as_str(), "hello"); + } + + Ok(()) +} + +#[tokio::test] +// Today rpm-ostree vendors a stable ostree-rs-ext; this test +// verifies that the old ostree-rs-ext code can parse the containers +// generated by the new ostree code. +async fn test_old_code_parses_new_export() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let rpmostree = Utf8Path::new("/usr/bin/rpm-ostree"); + if !rpmostree.exists() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let imgref = fixture.export_container().await?.0; + let imgref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref, + }; + fixture.clear_destrepo()?; + let destrepo_path = fixture.path.join("dest/repo"); + let s = Command::new("ostree") + .args([ + "container", + "unencapsulate", + "--repo", + destrepo_path.as_str(), + imgref.to_string().as_str(), + ]) + .output()?; + if !s.status.success() { + anyhow::bail!( + "Failed to run ostree: {:?}: {}", + s, + String::from_utf8_lossy(&s.stderr) + ); + } + Ok(()) +} + +/// Test for https://github.com/ostreedev/ostree-rs-ext/issues/655 +#[tokio::test] +async fn test_container_xattr() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let sh = fixture.new_shell()?; + let baseimg = &fixture.export_container().await?.0; + let basepath = &match baseimg.transport { + Transport::OciDir => fixture.path.join(baseimg.name.as_str()), + _ => unreachable!(), + }; + + // Verify security.capability is in the ostree commit + let arping = "/usr/bin/arping"; + { + let ostree_root = fixture + .srcrepo() + .read_commit(fixture.testref(), gio::Cancellable::NONE)? + .0; + let arping_ostree = ostree_root.resolve_relative_path(arping); + assert_eq!( + arping_ostree.query_file_type( + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + gio::Cancellable::NONE + ), + gio::FileType::Regular + ); + let arping_ostree = arping_ostree.downcast_ref::().unwrap(); + let arping_ostree_xattrs = arping_ostree.xattrs(gio::Cancellable::NONE)?; + let v = arping_ostree_xattrs.data_as_bytes(); + let v = v.try_as_aligned().unwrap(); + let v = gvariant::gv!("a(ayay)").cast(v); + assert!(v + .iter() + .find(|entry| { + let k = entry.to_tuple().0; + let k = std::ffi::CStr::from_bytes_with_nul(k).unwrap(); + k.to_str().ok() == Some("security.capability") + }) + .is_some()); + } + + // Build a derived image + let derived_path = &fixture.path.join("derived.oci"); + oci_clone(basepath, derived_path).await?; + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut tar = tar::Builder::new(w); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_uid(0); + h.set_gid(0); + h.set_mode(0o644); + h.set_mtime(0); + let data = b"hello"; + h.set_size(data.len() as u64); + tar.append_pax_extensions([("SCHILY.xattr.user.foo", b"bar".as_slice())]) + .unwrap(); + tar.append_data(&mut h, "usr/bin/testxattr", std::io::Cursor::new(data)) + .unwrap(); + Ok::<_, anyhow::Error>(()) + }, + None, + None, + )?; + let derived_ref = &OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }, + }; + let mut imp = + store::ImageImporter::new(fixture.destrepo(), derived_ref, Default::default()).await?; + let prep = match imp.prepare().await.context("Init prep derived")? { + store::PrepareResult::AlreadyPresent(_) => panic!("should not be already imported"), + store::PrepareResult::Ready(r) => r, + }; + let import = imp.import(prep).await.unwrap(); + let merge_commit = import.merge_commit; + + // Yeah we just scrape the output of ostree because it's easy + let out = cmd!( + sh, + "ostree --repo=dest/repo ls -X {merge_commit} /usr/bin/testxattr" + ) + .read()?; + assert!(out.contains("'user.foo', [byte 0x62, 0x61, 0x72]")); + let out = cmd!(sh, "ostree --repo=dest/repo ls -X {merge_commit} {arping}").read()?; + assert!(out.contains("'security.capability'")); + + Ok(()) +} + +#[ignore] +#[tokio::test] +// Verify that we can push and pull to a registry, not just oci-archive:. +// This requires a registry set up externally right now. One can run a HTTP registry via e.g. +// `podman run --rm -ti -p 5000:5000 --name registry docker.io/library/registry:2` +// but that doesn't speak HTTPS and adding that is complex. +// A simple option is setting up e.g. quay.io/$myuser/exampleos and then do: +// Then you can run this test via `env TEST_REGISTRY=quay.io/$myuser cargo test -- --ignored`. +async fn test_container_import_export_registry() -> Result<()> { + let tr = &*TEST_REGISTRY; + let fixture = Fixture::new_v1()?; + let testref = fixture.testref(); + let testrev = fixture + .srcrepo() + .require_rev(testref) + .context("Failed to resolve ref")?; + let src_imgref = ImageReference { + transport: Transport::Registry, + name: format!("{tr}/exampleos"), + }; + let config = Config { + cmd: Some(vec!["/bin/bash".to_string()]), + ..Default::default() + }; + let digest = + ostree_ext::container::encapsulate(fixture.srcrepo(), testref, &config, None, &src_imgref) + .await + .context("exporting to registry")?; + let mut digested_imgref = src_imgref.clone(); + digested_imgref.name = format!("{}@{}", src_imgref.name, digest); + + let import_ref = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: digested_imgref, + }; + let import = ostree_ext::container::unencapsulate(fixture.destrepo(), &import_ref) + .await + .context("importing")?; + assert_eq!(import.ostree_commit, testrev.as_str()); + Ok(()) +} + +#[test] +fn test_diff() -> Result<()> { + let mut fixture = Fixture::new_v1()?; + const ADDITIONS: &str = indoc::indoc! { " +r /usr/bin/newbin some-new-binary +d /usr/share +"}; + fixture + .update( + FileDef::iter_from(ADDITIONS), + [Cow::Borrowed("/usr/bin/bash".into())].into_iter(), + ) + .context("Failed to update")?; + let from = &format!("{}^", fixture.testref()); + let repo = fixture.srcrepo(); + let subdir: Option<&str> = None; + let diff = ostree_ext::diff::diff(repo, from, fixture.testref(), subdir)?; + assert!(diff.subdir.is_none()); + assert_eq!(diff.added_dirs.len(), 1); + assert_eq!(diff.added_dirs.iter().next().unwrap(), "/usr/share"); + assert_eq!(diff.added_files.len(), 1); + assert_eq!(diff.added_files.iter().next().unwrap(), "/usr/bin/newbin"); + assert_eq!(diff.removed_files.len(), 1); + assert_eq!(diff.removed_files.iter().next().unwrap(), "/usr/bin/bash"); + let diff = ostree_ext::diff::diff(repo, from, fixture.testref(), Some("/usr"))?; + assert_eq!(diff.subdir.as_ref().unwrap(), "/usr"); + assert_eq!(diff.added_dirs.len(), 1); + assert_eq!(diff.added_dirs.iter().next().unwrap(), "/share"); + assert_eq!(diff.added_files.len(), 1); + assert_eq!(diff.added_files.iter().next().unwrap(), "/bin/newbin"); + assert_eq!(diff.removed_files.len(), 1); + assert_eq!(diff.removed_files.iter().next().unwrap(), "/bin/bash"); + Ok(()) +} + +#[test] +fn test_manifest_diff() { + let a: ImageManifest = serde_json::from_str(include_str!("fixtures/manifest1.json")).unwrap(); + let b: ImageManifest = serde_json::from_str(include_str!("fixtures/manifest2.json")).unwrap(); + + let d = ManifestDiff::new(&a, &b); + assert_eq!(d.from, &a); + assert_eq!(d.to, &b); + assert_eq!(d.added.len(), 4); + assert_eq!( + d.added[0].digest().to_string(), + "sha256:0b5d930ffc92d444b0a7b39beed322945a3038603fbe2a56415a6d02d598df1f" + ); + assert_eq!( + d.added[3].digest().to_string(), + "sha256:cb9b8a4ac4a8df62df79e6f0348a14b3ec239816d42985631c88e76d4e3ff815" + ); + assert_eq!(d.removed.len(), 4); + assert_eq!( + d.removed[0].digest().to_string(), + "sha256:0ff8b1fdd38e5cfb6390024de23ba4b947cd872055f62e70f2c21dad5c928925" + ); + assert_eq!( + d.removed[3].digest().to_string(), + "sha256:76b83eea62b7b93200a056b5e0201ef486c67f1eeebcf2c7678ced4d614cece2" + ); +} diff --git a/crates/system-reinstall-bootc/Cargo.toml b/crates/system-reinstall-bootc/Cargo.toml new file mode 100644 index 000000000..d25b24f45 --- /dev/null +++ b/crates/system-reinstall-bootc/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "system-reinstall-bootc" +version = "0.1.9" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/bootc-dev/bootc" +publish = false +# For now don't bump this above what is currently shipped in RHEL9. +rust-version = "1.75.0" + +# See https://github.com/coreos/cargo-vendor-filterer +[package.metadata.vendor-filter] +# For now we only care about tier 1+2 Linux. (In practice, it's unlikely there is a tier3-only Linux dependency) +platforms = ["*-unknown-linux-gnu"] + +[dependencies] +# Internal crates +bootc-mount = { path = "../mount" } +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anstream = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +fn-error-context = { workspace = true } +indoc = { workspace = true } +log = { workspace = true } +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +tracing = { workspace = true } +uzers = { workspace = true } + +# Crate-specific dependencies +crossterm = "0.29.0" +dialoguer = "0.12.0" +openssh-keys = "0.6.4" +serde_yaml = "0.9.22" +which = "8.0.0" + +[lints] +workspace = true diff --git a/crates/system-reinstall-bootc/sample_config.yaml b/crates/system-reinstall-bootc/sample_config.yaml new file mode 100644 index 000000000..1d40e88ff --- /dev/null +++ b/crates/system-reinstall-bootc/sample_config.yaml @@ -0,0 +1,2 @@ +# The bootc container image to install +bootc_image: quay.io/fedora/fedora-bootc:41 diff --git a/crates/system-reinstall-bootc/src/btrfs.rs b/crates/system-reinstall-bootc/src/btrfs.rs new file mode 100644 index 000000000..cfaa6eccb --- /dev/null +++ b/crates/system-reinstall-bootc/src/btrfs.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use bootc_mount::Filesystem; +use fn_error_context::context; + +#[context("check_root_siblings")] +pub(crate) fn check_root_siblings() -> Result> { + let mounts = bootc_mount::run_findmnt(&[], None, None)?; + let problem_filesystems: Vec = mounts + .filesystems + .iter() + .filter(|fs| fs.target == "/") + .flat_map(|root| { + let children: Vec<&Filesystem> = root + .children + .iter() + .flatten() + .filter(|child| child.source == root.source) + .collect(); + children + }) + .map(|zs| { + format!( + "Type: {}, Mount Point: {}, Source: {}", + zs.fstype, zs.target, zs.source + ) + }) + .collect(); + Ok(problem_filesystems) +} diff --git a/crates/system-reinstall-bootc/src/config/cli.rs b/crates/system-reinstall-bootc/src/config/cli.rs new file mode 100644 index 000000000..7d7524881 --- /dev/null +++ b/crates/system-reinstall-bootc/src/config/cli.rs @@ -0,0 +1,7 @@ +use clap::Parser; + +#[derive(Parser)] +pub(crate) struct Cli { + /// The bootc container image to install, e.g. quay.io/fedora/fedora-bootc:41 + pub(crate) bootc_image: String, +} diff --git a/crates/system-reinstall-bootc/src/config/mod.rs b/crates/system-reinstall-bootc/src/config/mod.rs new file mode 100644 index 000000000..4a1b4ecf8 --- /dev/null +++ b/crates/system-reinstall-bootc/src/config/mod.rs @@ -0,0 +1,34 @@ +use std::{fs::File, io::BufReader}; + +use anyhow::{Context, Result}; +use bootc_utils::PathQuotedDisplay; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; + +mod cli; + +/// The environment variable that can be used to specify an image. +const CONFIG_VAR: &str = "BOOTC_REINSTALL_CONFIG"; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReinstallConfig { + /// The bootc image to install on the system. + pub(crate) bootc_image: String, + pub(crate) composefs_backend: bool, +} + +impl ReinstallConfig { + #[context("load")] + pub fn load() -> Result> { + let Some(config) = std::env::var_os(CONFIG_VAR) else { + return Ok(None); + }; + let f = File::open(&config) + .with_context(|| format!("Opening {}", PathQuotedDisplay::new(&config))) + .map(BufReader::new)?; + let r = serde_yaml::from_reader(f) + .with_context(|| format!("Parsing config from {}", PathQuotedDisplay::new(&config)))?; + Ok(Some(r)) + } +} diff --git a/crates/system-reinstall-bootc/src/lvm.rs b/crates/system-reinstall-bootc/src/lvm.rs new file mode 100644 index 000000000..3022ed6f7 --- /dev/null +++ b/crates/system-reinstall-bootc/src/lvm.rs @@ -0,0 +1,87 @@ +use std::process::Command; + +use anyhow::Result; +use bootc_mount::run_findmnt; +use bootc_utils::{CommandRunExt, ResultExt}; +use fn_error_context::context; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct Lvs { + report: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LvsReport { + lv: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub(crate) struct LogicalVolume { + lv_name: String, + lv_size: String, + lv_path: String, + vg_name: String, +} + +#[context("parse_volumes")] +pub(crate) fn parse_volumes(group: Option<&str>) -> Result> { + if which::which("lvs").is_err() { + tracing::debug!("lvs binary not found. Skipping logical volume check."); + return Ok(Vec::::new()); + } + + let mut cmd = Command::new("lvs"); + cmd.args([ + "--reportformat=json", + "-o", + "lv_name,lv_size,lv_path,vg_name", + ]) + .args(group); + + let output: Lvs = cmd.run_and_parse_json()?; + + Ok(output + .report + .iter() + .flat_map(|r| r.lv.iter().cloned()) + .collect()) +} + +#[context("check_root_siblings")] +pub(crate) fn check_root_siblings() -> Result> { + let all_volumes = parse_volumes(None)?; + + // first look for a lv mounted to '/' + // then gather all the sibling lvs in the vg along with their mount points + let siblings: Vec = all_volumes + .iter() + .filter(|lv| { + let mount = run_findmnt(&["-S", &lv.lv_path], None, None).log_err_default(); + if let Some(fs) = mount.filesystems.first() { + &fs.target == "/" + } else { + false + } + }) + .flat_map(|root_lv| parse_volumes(Some(root_lv.vg_name.as_str())).unwrap_or_default()) + .try_fold(Vec::new(), |mut acc, r| -> anyhow::Result<_> { + let mount = run_findmnt(&["-S", &r.lv_path], None, None).log_err_default(); + let mount_path = if let Some(fs) = mount.filesystems.first() { + &fs.target + } else { + "" + }; + + if mount_path != "/" && !mount_path.is_empty() { + acc.push(format!( + "Type: LVM, Mount Point: {}, LV: {}, VG: {}, Size: {}", + mount_path, r.lv_name, r.vg_name, r.lv_size + )) + }; + + Ok(acc) + })?; + + Ok(siblings) +} diff --git a/crates/system-reinstall-bootc/src/main.rs b/crates/system-reinstall-bootc/src/main.rs new file mode 100644 index 000000000..61fde0b89 --- /dev/null +++ b/crates/system-reinstall-bootc/src/main.rs @@ -0,0 +1,99 @@ +//! The main entrypoint for the bootc system reinstallation CLI + +use anyhow::{ensure, Context, Result}; +use bootc_utils::CommandRunExt; +use clap::Parser; +use fn_error_context::context; +use rustix::process::getuid; + +mod btrfs; +mod config; +mod lvm; +mod podman; +mod prompt; +pub(crate) mod users; + +const ROOT_KEY_MOUNT_POINT: &str = "/bootc_authorized_ssh_keys/root"; + +/// Reinstall the system using the provided bootc container. +/// +/// This will interactively replace the system with the content of the targeted +/// container image. +/// +/// If the environment variable BOOTC_REINSTALL_CONFIG is set, it must be a YAML +/// file with a single member `bootc_image` that specifies the image to install. +/// This will take precedence over the CLI. +#[derive(clap::Parser)] +pub(crate) struct ReinstallOpts { + /// The bootc image to install + pub(crate) image: String, + // Note if we ever add any other options here, + #[arg(long)] + pub(crate) composefs_backend: bool, +} + +#[context("run")] +fn run() -> Result<()> { + // We historically supported an environment variable providing a config to override the image, so + // keep supporting that. I'm considering deprecating that though. + let opts = if let Some(config) = config::ReinstallConfig::load().context("loading config")? { + ReinstallOpts { + image: config.bootc_image, + composefs_backend: config.composefs_backend, + } + } else { + // Otherwise an image is required. + ReinstallOpts::parse() + }; + + bootc_utils::initialize_tracing(); + tracing::trace!("starting {}", env!("CARGO_PKG_NAME")); + + // Rootless podman is not supported by bootc + ensure!(getuid().is_root(), "Must run as the root user"); + + podman::ensure_podman_installed()?; + + //pull image early so it can be inspected, e.g. to check for cloud-init + podman::pull_if_not_present(&opts.image)?; + + println!(); + + let ssh_key_file = tempfile::NamedTempFile::new()?; + let ssh_key_file_path = ssh_key_file + .path() + .to_str() + .ok_or_else(|| anyhow::anyhow!("unable to create authorized_key temp file"))?; + + tracing::trace!("ssh_key_file_path: {}", ssh_key_file_path); + + prompt::get_ssh_keys(ssh_key_file_path)?; + + prompt::mount_warning()?; + + let mut reinstall_podman_command = podman::reinstall_command(&opts, ssh_key_file_path)?; + + println!(); + println!("Going to run command:"); + println!(); + println!("{}", reinstall_podman_command.to_string_pretty()); + + println!(); + println!("After reboot, the current root will be available in the /sysroot directory. Existing mounts will not be automatically mounted by the bootc system unless they are defined in the bootc image. Some automatic cleanup of the previous root will be performed."); + + prompt::temporary_developer_protection_prompt()?; + + reinstall_podman_command + .run_inherited_with_cmd_context() + .context("running reinstall command")?; + + prompt::reboot()?; + + std::process::Command::new("reboot").run_capture_stderr()?; + + Ok(()) +} + +fn main() { + bootc_utils::run_main(run) +} diff --git a/crates/system-reinstall-bootc/src/podman.rs b/crates/system-reinstall-bootc/src/podman.rs new file mode 100644 index 000000000..545b3137a --- /dev/null +++ b/crates/system-reinstall-bootc/src/podman.rs @@ -0,0 +1,172 @@ +use crate::{prompt, ReinstallOpts}; + +use super::ROOT_KEY_MOUNT_POINT; +use anyhow::{ensure, Context, Result}; +use bootc_utils::CommandRunExt; +use fn_error_context::context; +use std::process::Command; +use which::which; + +#[context("bootc_has_clean")] +fn bootc_has_clean(image: &str) -> Result { + let output = Command::new("podman") + .args([ + "run", + "--rm", + image, + "bootc", + "install", + "to-existing-root", + "--help", + ]) + .output()?; + let stdout_str = String::from_utf8_lossy(&output.stdout); + Ok(stdout_str.contains("--cleanup")) +} + +#[context("reinstall_command")] +pub(crate) fn reinstall_command(opts: &ReinstallOpts, ssh_key_file: &str) -> Result { + let mut podman_command_and_args = [ + // We use podman to run the bootc container. This might change in the future to remove the + // podman dependency. + "podman", + "run", + // The container needs to be privileged, as it heavily modifies the host + "--privileged", + // The container needs to access the host's PID namespace to mount host directories + "--pid=host", + // Set the UID/GID to root overwriting any possible USER directive in the Containerfile + "--user=root:root", + // Keep these here to support images with bootc versions prior to 1.1.5 + // when these parameters were obsoleted + "-v", + "/var/lib/containers:/var/lib/containers", + "-v", + "/dev:/dev", + "--security-opt", + "label=type:unconfined_t", + "-v", + "/:/target", + ] + .map(String::from) + .to_vec(); + + // Pass along RUST_LOG from the host to enable detailed output from the bootc command + if let Ok(rust_log) = std::env::var("RUST_LOG") { + podman_command_and_args.push(format!("--env=RUST_LOG={rust_log}")); + } + + let mut bootc_command_and_args = [ + "bootc", + "install", + // We're replacing the current root + "to-existing-root", + // The user already knows they're reinstalling their machine, that's the entire purpose of + // this binary. Since this is no longer an "arcane" bootc command, we can safely avoid this + // timed warning prompt. TODO: Discuss in https://github.com/bootc-dev/bootc/discussions/1060 + "--acknowledge-destructive", + // The image is always pulled first, so let's avoid requiring the credentials to be baked + // in the image for this check. + "--skip-fetch-check", + ] + .map(String::from) + .to_vec(); + + if opts.composefs_backend { + bootc_command_and_args.push("--composefs-backend".into()); + } + + // Enable the systemd service to cleanup the previous install after booting into the + // bootc system for the first time. + // This only happens if the bootc version in the image >= 1.1.8 (this is when the cleanup + // feature was introduced) + if bootc_has_clean(&opts.image)? { + bootc_command_and_args.push("--cleanup".to_string()); + } + + podman_command_and_args.push("-v".to_string()); + podman_command_and_args.push(format!("{ssh_key_file}:{ROOT_KEY_MOUNT_POINT}")); + + bootc_command_and_args.push("--root-ssh-authorized-keys".to_string()); + bootc_command_and_args.push(ROOT_KEY_MOUNT_POINT.to_string()); + + let all_args = [ + podman_command_and_args, + vec![opts.image.to_string()], + bootc_command_and_args, + ] + .concat(); + + let mut command = Command::new(&all_args[0]); + command.args(&all_args[1..]); + + Ok(command) +} + +fn pull_image_command(image: &str) -> Command { + let mut command = Command::new("podman"); + command.args(["pull", image]); + command +} + +fn image_exists_command(image: &str) -> Command { + let mut command = Command::new("podman"); + command.args(["image", "exists", image]); + command +} + +#[context("pull_if_not_present")] +pub(crate) fn pull_if_not_present(image: &str) -> Result<()> { + let result = image_exists_command(image).status()?; + + if result.success() { + println!("Image {image} is already present locally, skipping pull."); + return Ok(()); + } else { + println!("Image {image} is not present locally, pulling it now."); + println!(); + pull_image_command(image) + .run_inherited_with_cmd_context() + .context(format!("pulling image {image}"))?; + } + + Ok(()) +} + +/// Path to the podman installation script. Can be influenced by the build +/// SYSTEM_REINSTALL_BOOTC_INSTALL_PODMAN_PATH parameter to override. Defaults +/// to /usr/lib/system-reinstall-bootc/install-podman +const fn podman_install_script_path() -> &'static str { + if let Some(path) = option_env!("SYSTEM_REINSTALL_BOOTC_INSTALL_PODMAN_PATH") { + path + } else { + "/usr/lib/system-reinstall-bootc/install-podman" + } +} + +#[context("ensure_podman_installed")] +pub(crate) fn ensure_podman_installed() -> Result<()> { + if which("podman").is_ok() { + return Ok(()); + } + + prompt::ask_yes_no("Podman was not found on this system. It's required in order to install a bootc image. Do you want to install it now?", true)?; + + ensure!( + which(podman_install_script_path()).is_ok(), + "Podman installation script {} not found, cannot automatically install podman. Please install it manually and try again.", + podman_install_script_path() + ); + + Command::new(podman_install_script_path()) + .run_inherited_with_cmd_context() + .context("installing podman")?; + + // Make sure the installation was actually successful + ensure!( + which("podman").is_ok(), + "podman still doesn't seem to be available, despite the installation. Please install it manually and try again." + ); + + Ok(()) +} diff --git a/crates/system-reinstall-bootc/src/prompt.rs b/crates/system-reinstall-bootc/src/prompt.rs new file mode 100644 index 000000000..98f649e36 --- /dev/null +++ b/crates/system-reinstall-bootc/src/prompt.rs @@ -0,0 +1,184 @@ +/// A variant of `println!` that flushes stdout. +macro_rules! println_flush { + ($($arg:tt)*) => { + { + use std::io::Write; + println!($($arg)*); + std::io::stdout().flush().unwrap(); + } + }; +} + +use crate::{btrfs, lvm, prompt, users::get_all_users_keys}; +use anyhow::{ensure, Context, Result}; +use fn_error_context::context; + +use crossterm::event::{self, Event}; +use std::time::Duration; + +const NO_SSH_PROMPT: &str = "None of the users on this system found have authorized SSH keys, \ + if your image doesn't use cloud-init or other means to set up users, \ + you may not be able to log in after reinstalling. Do you want to continue?"; + +#[context("prompt_single_user")] +fn prompt_single_user(user: &crate::users::UserKeys) -> Result> { + let prompt = indoc::formatdoc! { + "Found only one user ({user}) with {num_keys} SSH authorized keys. + Would you like to import its SSH authorized keys + into the root user on the new bootc system? + Then you can login as root@ using those keys.", + user = user.user, + num_keys = user.num_keys(), + }; + let answer = ask_yes_no(&prompt, true)?; + Ok(if answer { vec![&user] } else { vec![] }) +} + +#[context("prompt_user_selection")] +fn prompt_user_selection( + all_users: &[crate::users::UserKeys], +) -> Result> { + let keys: Vec = all_users.iter().map(|x| x.user.clone()).collect(); + + // TODO: Handle https://github.com/console-rs/dialoguer/issues/77 + let selected_user_indices: Vec = dialoguer::MultiSelect::new() + .with_prompt(indoc::indoc! { + "Select which user's SSH authorized keys you want to import into + the root user of the new bootc system. + Then you can login as root@ using those keys. + (arrow keys to move, space to select)", + }) + .items(&keys) + .interact()?; + + Ok(selected_user_indices + .iter() + // Safe unwrap because we know the index is valid + .map(|x| all_users.get(*x).unwrap()) + .collect()) +} + +#[context("reboot")] +pub(crate) fn reboot() -> Result<()> { + let delay_seconds = 10; + println_flush!( + "Operation complete, rebooting in {delay_seconds} seconds. Press Ctrl-C to cancel reboot, or press enter to continue immediately.", + ); + + let mut elapsed_ms = 0; + let interval = 100; + + while elapsed_ms < delay_seconds * 1000 { + if event::poll(Duration::from_millis(0))? { + if let Event::Key(_) = event::read().unwrap() { + break; + } + } + std::thread::sleep(Duration::from_millis(interval)); + elapsed_ms += interval; + } + + Ok(()) +} + +/// Temporary safety mechanism to stop devs from running it on their dev machine. TODO: Discuss +/// final prompting UX in https://github.com/bootc-dev/bootc/discussions/1060 +#[context("temporary_developer_protection_prompt")] +pub(crate) fn temporary_developer_protection_prompt() -> Result<()> { + // Print an empty line so that the warning stands out from the rest of the output + println_flush!(); + + let prompt = "NOTICE: This will replace the installed operating system and reboot. Are you sure you want to continue?"; + let answer = ask_yes_no(prompt, false)?; + + if !answer { + println_flush!("Exiting without reinstalling the system."); + std::process::exit(0); + } + + Ok(()) +} + +#[context("ask_yes_no")] +pub(crate) fn ask_yes_no(prompt: &str, default: bool) -> Result { + dialoguer::Confirm::new() + .with_prompt(prompt) + .default(default) + .wait_for_newline(true) + .interact() + .context("prompting") +} + +pub(crate) fn press_enter() { + println!(); + println!("Press to continue."); + + loop { + if let Event::Key(_) = event::read().unwrap() { + break; + } + } +} + +#[context("mount_warning")] +pub(crate) fn mount_warning() -> Result<()> { + let mut mounts = btrfs::check_root_siblings()?; + mounts.extend(lvm::check_root_siblings()?); + + if !mounts.is_empty() { + println!(); + println!("NOTICE: the following mounts are left unchanged by this tool and will not be automatically mounted unless specified in the bootc image. Consult the bootc documentation to determine the appropriate action for your system."); + println!(); + for m in mounts { + println!("{m}"); + } + press_enter(); + } + + Ok(()) +} + +/// Gather authorized keys for all user's of the host system +/// prompt the user to select which users's keys will be imported +/// into the target system's root user's authorized_keys file +/// +/// The keys are stored in a temporary file which is passed to +/// the podman run invocation to be used by +/// `bootc install to-existing-root --root-ssh-authorized-keys` +#[context("get_ssh_keys")] +pub(crate) fn get_ssh_keys(temp_key_file_path: &str) -> Result<()> { + let users = get_all_users_keys()?; + if users.is_empty() { + ensure!( + prompt::ask_yes_no(NO_SSH_PROMPT, false)?, + "cancelled by user" + ); + + return Ok(()); + } + + let selected_users = if users.len() == 1 { + prompt_single_user(&users[0])? + } else { + prompt_user_selection(&users)? + }; + + let keys = selected_users + .into_iter() + .flat_map(|user| &user.authorized_keys) + .map(|key| { + let mut key_copy = key.clone(); + + // These options could contain a command which will + // cause the new bootc system to be inaccessible. + key_copy.options = None; + key_copy.to_key_format() + "\n" + }) + .collect::(); + + tracing::trace!("keys: {:?}", keys); + + std::fs::write(temp_key_file_path, keys.as_bytes())?; + + Ok(()) +} diff --git a/crates/system-reinstall-bootc/src/users.rs b/crates/system-reinstall-bootc/src/users.rs new file mode 100644 index 000000000..de08d3b1b --- /dev/null +++ b/crates/system-reinstall-bootc/src/users.rs @@ -0,0 +1,272 @@ +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use bootc_utils::PathQuotedDisplay; +use fn_error_context::context; +use openssh_keys::PublicKey; +use rustix::fs::Uid; +use rustix::process::geteuid; +use rustix::process::getuid; +use rustix::thread::set_thread_res_uid; +use serde_json::Value; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt::Display; +use std::fmt::Formatter; +use std::fs::File; +use std::io::BufReader; +use std::os::unix::process::CommandExt; +use std::process::Command; +use uzers::os::unix::UserExt; + +#[context("loginctl_users")] +fn loginctl_users() -> Result> { + let loginctl_raw_output = loginctl_run_compat()?; + + loginctl_parse(loginctl_raw_output) +} + +/// See [`test::test_parse_lsblk`] for example loginctl output +#[context("loginctl_parse")] +fn loginctl_parse(users: Value) -> Result> { + users + .as_array() + .context("loginctl output is not an array")? + .iter() + .map(|user_value| { + user_value + .as_object() + .context("user entry is not an object")? + .get("user") + .context("user object doesn't have a user field")? + .as_str() + .context("user name field is not a string") + .map(String::from) + }) + // Artificially add the root user to the list of users as it doesn't always appear in + // `loginctl list-sessions` + .chain(std::iter::once(Ok("root".to_string()))) + .collect::>() + .context("error parsing users") +} + +/// Run `loginctl` with some compatibility maneuvers to get JSON output +#[context("loginctl_run_compat")] +fn loginctl_run_compat() -> Result { + let mut command = Command::new("loginctl"); + command.arg("list-sessions").arg("--output").arg("json"); + let output = command.run_get_output().context("running loginctl")?; + let users: Value = match serde_json::from_reader(output) { + Ok(users) => users, + // Failing to parse means loginctl is not outputting JSON despite `--output` + // (https://github.com/systemd/systemd/issues/15275), we need to use the `--json` flag + Err(_err) => Command::new("loginctl") + .arg("list-sessions") + .arg("--json") + .arg("short") + .run_and_parse_json() + .context("running loginctl")?, + }; + Ok(users) +} + +struct UidChange { + uid: Uid, + euid: Uid, +} + +impl UidChange { + #[context("new")] + fn new(change_to_uid: Uid) -> Result { + let (uid, euid) = (getuid(), geteuid()); + set_thread_res_uid(uid, change_to_uid, euid).context("setting effective uid failed")?; + Ok(Self { uid, euid }) + } +} + +impl Drop for UidChange { + fn drop(&mut self) { + set_thread_res_uid(self.uid, self.euid, self.euid).expect("setting effective uid failed"); + } +} + +#[derive(Clone, Debug)] +pub(crate) struct UserKeys { + pub(crate) user: String, + pub(crate) authorized_keys: Vec, +} + +impl UserKeys { + pub(crate) fn num_keys(&self) -> usize { + self.authorized_keys.len() + } +} + +impl Display for UserKeys { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "User {} ({} authorized keys)", + self.user, + self.num_keys() + ) + } +} + +#[derive(Debug)] +struct SshdConfig<'a> { + authorized_keys_files: Vec<&'a str>, + authorized_keys_command: &'a str, + authorized_keys_command_user: &'a str, +} + +impl<'a> SshdConfig<'a> { + #[context("parse")] + pub fn parse(sshd_output: &'a str) -> Result> { + let config = sshd_output + .lines() + .filter_map(|line| line.split_once(' ')) + .collect::>(); + + let authorized_keys_files: Vec<&str> = config + .get("authorizedkeysfile") + .unwrap_or(&"none") + .split_whitespace() + .collect(); + let authorized_keys_command = config.get("authorizedkeyscommand").unwrap_or(&"none"); + let authorized_keys_command_user = + config.get("authorizedkeyscommanduser").unwrap_or(&"none"); + + Ok(Self { + authorized_keys_files, + authorized_keys_command, + authorized_keys_command_user, + }) + } +} + +#[context("get_keys_from_files")] +fn get_keys_from_files(user: &uzers::User, keyfiles: &Vec<&str>) -> Result> { + let home_dir = user.home_dir(); + let mut user_authorized_keys: Vec = Vec::new(); + + for keyfile in keyfiles { + let user_authorized_keys_path = home_dir.join(keyfile); + + if !user_authorized_keys_path.exists() { + tracing::debug!( + "Skipping authorized key file {} for user {} because it doesn't exist", + PathQuotedDisplay::new(&user_authorized_keys_path), + user.name().to_string_lossy() + ); + continue; + } + + // Safety: The UID should be valid because we got it from uzers + let user_uid = Uid::from_raw(user.uid()); + + // Change the effective uid for this scope, to avoid accidentally reading files we + // shouldn't through symlinks + let _uid_change = UidChange::new(user_uid)?; + + let file = File::open(user_authorized_keys_path) + .context("Failed to read user's authorized keys")?; + let mut keys = PublicKey::read_keys(BufReader::new(file))?; + user_authorized_keys.append(&mut keys); + } + + Ok(user_authorized_keys) +} + +#[context("get_keys_from_command")] +fn get_keys_from_command(command: &str, command_user: &str) -> Result> { + let user_config = uzers::get_user_by_name(command_user).context(format!( + "authorized_keys_command_user {command_user} not found" + ))?; + + let mut cmd = Command::new(command); + cmd.uid(user_config.uid()); + let output = cmd + .run_get_output() + .context(format!("running authorized_keys_command {command}"))?; + let keys = PublicKey::read_keys(output)?; + Ok(keys) +} + +#[context("get_all_users_keys")] +pub(crate) fn get_all_users_keys() -> Result> { + let loginctl_user_names = loginctl_users().context("enumerate users")?; + + let mut all_users_authorized_keys = Vec::new(); + + let sshd_output = Command::new("sshd") + .arg("-T") + .run_get_string() + .context("running sshd -T")?; + tracing::trace!("sshd output:\n {}", sshd_output); + + let sshd_config = SshdConfig::parse(sshd_output.as_str())?; + tracing::debug!("parsed sshd config: {:?}", sshd_config); + + for user_name in loginctl_user_names { + let user_info = uzers::get_user_by_name(user_name.as_str()) + .context(format!("user {user_name} not found"))?; + + let mut user_authorized_keys: Vec = Vec::new(); + if !sshd_config.authorized_keys_files.is_empty() { + let mut keys = get_keys_from_files(&user_info, &sshd_config.authorized_keys_files)?; + user_authorized_keys.append(&mut keys); + } + + if sshd_config.authorized_keys_command != "none" { + let mut keys = get_keys_from_command( + &sshd_config.authorized_keys_command, + &sshd_config.authorized_keys_command_user, + )?; + user_authorized_keys.append(&mut keys); + }; + + let user_name = user_info + .name() + .to_str() + .context("user name is not valid utf-8")?; + + if user_authorized_keys.is_empty() { + tracing::debug!( + "Skipping user {} because it has no SSH authorized_keys", + user_name + ); + continue; + } + + let user_keys = UserKeys { + user: user_name.to_string(), + authorized_keys: user_authorized_keys, + }; + + tracing::debug!( + "Found user {} with {} SSH authorized_keys", + user_keys.user, + user_keys.num_keys() + ); + + all_users_authorized_keys.push(user_keys); + } + + Ok(all_users_authorized_keys) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + pub(crate) fn test_parse_lsblk() { + let fixture = include_str!("../tests/fixtures/loginctl.json"); + + let result = loginctl_parse(serde_json::from_str(fixture).unwrap()).unwrap(); + + assert_eq!(result.len(), 2); + assert!(result.contains("root")); + assert!(result.contains("foo-doe")); + } +} diff --git a/crates/system-reinstall-bootc/tests/fixtures/loginctl.json b/crates/system-reinstall-bootc/tests/fixtures/loginctl.json new file mode 100644 index 000000000..75fe80b15 --- /dev/null +++ b/crates/system-reinstall-bootc/tests/fixtures/loginctl.json @@ -0,0 +1 @@ +[{"session":"2","uid":1000,"user":"foo-doe","seat":"seat0","leader":3045,"class":"user","tty":"tty1","idle":false,"since":null},{"session":"3","uid":1000,"user":"foo-doe","seat":null,"leader":3148,"class":"manager","tty":null,"idle":false,"since":null}] diff --git a/crates/sysusers/Cargo.toml b/crates/sysusers/Cargo.toml new file mode 100644 index 000000000..d6b59c213 --- /dev/null +++ b/crates/sysusers/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bootc-sysusers" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[dependencies] +# Internal crates +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anyhow = { workspace = true } +camino = { workspace = true } +cap-std-ext = { workspace = true, features = ["fs_utf8"] } +fn-error-context = { workspace = true } +hex = { workspace = true } +rustix = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +uzers = { workspace = true } + +[dev-dependencies] +indoc = { workspace = true } +similar-asserts = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sysusers/src/lib.rs b/crates/sysusers/src/lib.rs new file mode 100644 index 000000000..9b8233332 --- /dev/null +++ b/crates/sysusers/src/lib.rs @@ -0,0 +1,681 @@ +//! Parse and generate systemd sysusers.d entries. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#[allow(dead_code)] +mod nameservice; + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufRead, BufReader}; +use std::num::ParseIntError; +use std::path::PathBuf; +use std::str::FromStr; + +use camino::Utf8Path; +use cap_std_ext::dirext::{CapStdExtDirExt, CapStdExtDirExtUtf8}; +use cap_std_ext::{cap_std::fs::Dir, cap_std::fs_utf8::Dir as DirUtf8}; +use thiserror::Error; + +const SYSUSERSD: &str = "usr/lib/sysusers.d"; + +/// An error when processing sysusers +#[derive(Debug, Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("I/O error on {path}: {err}")] + PathIo { path: PathBuf, err: std::io::Error }, + #[error("Failed to parse sysusers entry: {0}")] + ParseFailure(String), + #[error("Failed to parse sysusers entry from {path}: {err}")] + ParseFailureInFile { path: PathBuf, err: String }, + #[error("Failed to load etc/passwd: {0}")] + PasswdLoadFailure(String), + #[error("Failed to load etc/group: {0}")] + GroupLoadFailure(String), +} + +/// The type of Result. +pub type Result = std::result::Result; + +/// In sysusers, a user can refer to a group via name or number +#[derive(Debug, PartialEq, Eq)] +pub enum GroupReference { + /// A numeric reference + Numeric(u32), + /// A named reference + Name(String), + /// A file path + Path(String), +} + +impl From for GroupReference { + fn from(value: u32) -> Self { + Self::Numeric(value) + } +} + +impl FromStr for GroupReference { + type Err = ParseIntError; + + fn from_str(s: &str) -> std::result::Result { + let r = if s.starts_with('/') { + Self::Path(s.to_owned()) + } else if s.chars().all(|c| c.is_ascii_digit()) { + Self::Numeric(u32::from_str(s)?) + } else { + Self::Name(s.to_owned()) + }; + Ok(r) + } +} + +/// In sysusers a uid can be defined statically or via a file path +#[derive(Debug, PartialEq, Eq)] +pub enum IdSource { + /// A numeric uid + Numeric(u32), + /// The uid is defined by the owner of this path + Path(String), +} + +impl FromStr for IdSource { + type Err = ParseIntError; + + fn from_str(s: &str) -> std::result::Result { + let r = if s.starts_with('/') { + Self::Path(s.to_owned()) + } else { + Self::Numeric(u32::from_str(s)?) + }; + Ok(r) + } +} + +impl From for IdSource { + fn from(value: u32) -> Self { + Self::Numeric(value) + } +} + +/// A parsed sysusers.d entry +#[derive(Debug, PartialEq, Eq)] +#[allow(missing_docs)] +pub enum SysusersEntry { + /// Defines a user + User { + name: String, + uid: Option, + pgid: Option, + gecos: String, + home: Option, + shell: Option, + }, + /// Defines a group + Group { name: String, id: Option }, + /// Defines a range of uids + Range { start: u32, end: u32 }, +} + +impl SysusersEntry { + /// Given an input string, finds the next "token" which is normally delimited by + /// whitespace, but "quoted strings" are also supported. Returns that token + /// and the remainder. If there are no more tokens, this returns None. + /// + /// Yes this is a lot of manual parsing and there's a ton of crates we could use, + /// like winnow, but this problem domain is *just* simple enough that I decided + /// not to learn that yet. + fn next_token(s: &str) -> Option<(&str, &str)> { + let s = s.trim_start(); + let (first, rest) = match s.strip_prefix('"') { + None => match s.find(|c: char| c.is_whitespace()) { + Some(idx) => s.split_at(idx), + None => (s, ""), + }, + Some(rest) => { + let end = rest.find('"')?; + (&rest[..end], &rest[end + 1..]) + } + }; + if first.is_empty() { + None + } else { + Some((first, rest)) + } + } + + fn next_token_owned(s: &str) -> Option<(String, &str)> { + Self::next_token(s).map(|(a, b)| (a.to_owned(), b)) + } + + fn next_optional_token(s: &str) -> Option<(Option<&str>, &str)> { + let (token, s) = Self::next_token(s)?; + let token = Some(token).filter(|t| *t != "-"); + Some((token, s)) + } + + fn next_optional_token_owned(s: &str) -> Option<(Option, &str)> { + Self::next_optional_token(s).map(|(a, b)| (a.map(|v| v.to_owned()), b)) + } + + pub(crate) fn parse(s: &str) -> Result> { + let err = || Error::ParseFailure(s.to_owned()); + let (ftype, s) = Self::next_token(s).ok_or_else(err)?; + let r = match ftype { + "u" | "u!" => { + let (name, s) = Self::next_token_owned(s).ok_or_else(err)?; + let (id, s) = Self::next_optional_token(s).unwrap_or_default(); + let (uid, pgid) = id + .and_then(|v| v.split_once(':')) + .or_else(|| id.map(|id| (id, id))) + .map(|(uid, gid)| (Some(uid), Some(gid))) + .unwrap_or((None, None)); + let uid = uid + .filter(|&v| v != "-") + .map(|id| id.parse()) + .transpose() + .map_err(|_| err())?; + let pgid = pgid.map(|id| id.parse()).transpose().map_err(|_| err())?; + let (gecos, s) = Self::next_token(s).unwrap_or_default(); + let gecos = gecos.to_owned(); + let (home, s) = Self::next_optional_token_owned(s).unwrap_or_default(); + let (shell, _) = Self::next_optional_token_owned(s).unwrap_or_default(); + SysusersEntry::User { + name, + uid, + pgid, + gecos, + home, + shell, + } + } + "g" => { + let (name, s) = Self::next_token_owned(s).ok_or_else(err)?; + let (id, _) = Self::next_optional_token(s).unwrap_or_default(); + let id = id.map(|id| id.parse()).transpose().map_err(|_| err())?; + SysusersEntry::Group { name, id } + } + "r" => { + let (_, s) = Self::next_optional_token(s).ok_or_else(err)?; + let (range, _) = Self::next_token(s).ok_or_else(err)?; + let (start, end) = range.split_once('-').ok_or_else(err)?; + let start: u32 = start.parse().map_err(|_| err())?; + let end: u32 = end.parse().map_err(|_| err())?; + SysusersEntry::Range { start, end } + } + // In the case of a sysusers entry that is of unknown type, we skip it out of conservatism + _ => return Ok(None), + }; + Ok(Some(r)) + } +} + +/// Read all tmpfiles.d entries in the target directory, and return a mapping +/// from (file path) => (single tmpfiles.d entry line) +pub fn read_sysusers(rootfs: &Dir) -> Result> { + let Some(d) = rootfs.open_dir_optional(SYSUSERSD)? else { + return Ok(Default::default()); + }; + let d = DirUtf8::from_cap_std(d); + let mut result = Vec::new(); + let mut found_users = BTreeSet::new(); + let mut found_groups = BTreeSet::new(); + for name in d.filenames_sorted()? { + let Some("conf") = Utf8Path::new(&name).extension() else { + continue; + }; + let r = d.open(&name).map(BufReader::new)?; + for line in r.lines() { + let line = line?; + if line.is_empty() || line.starts_with("#") { + continue; + } + let Some(e) = SysusersEntry::parse(&line).map_err(|e| Error::ParseFailureInFile { + path: name.clone().into(), + err: e.to_string(), + })? + else { + continue; + }; + match e { + SysusersEntry::User { + ref name, ref pgid, .. + } if !found_users.contains(name.as_str()) => { + found_users.insert(name.clone()); + found_groups.insert(name.clone()); + // Users implicitly create a group with the same name + let pgid = pgid.as_ref().and_then(|g| match g { + GroupReference::Numeric(n) => Some(IdSource::Numeric(*n)), + GroupReference::Path(p) => Some(IdSource::Path(p.clone())), + GroupReference::Name(_) => None, + }); + result.push(SysusersEntry::Group { + name: name.clone(), + id: pgid, + }); + result.push(e); + } + SysusersEntry::Group { ref name, .. } if !found_groups.contains(name.as_str()) => { + found_groups.insert(name.clone()); + result.push(e); + } + _ => { + // Ignore others. + } + } + } + } + Ok(result) +} + +/// The result of analyzing /etc/{passwd,group} in a root vs systemd-sysusers. +#[derive(Debug, Default)] +pub struct SysusersAnalysis { + /// Entries which are found in /etc/passwd but not present in systemd-sysusers. + pub missing_users: BTreeSet, + /// Entries which are found in /etc/group but not present in systemd-sysusers. + pub missing_groups: BTreeSet, +} + +impl SysusersAnalysis { + /// Returns true if this analysis finds no missing entries. + pub fn is_empty(&self) -> bool { + self.missing_users.is_empty() && self.missing_groups.is_empty() + } +} + +/// Analyze the state of /etc/passwd vs systemd-sysusers. +pub fn analyze(rootfs: &Dir) -> Result { + struct SysuserData { + #[allow(dead_code)] + uid: Option, + #[allow(dead_code)] + pgid: Option, + } + + struct SysgroupData { + #[allow(dead_code)] + id: Option, + } + + let Some(passwd) = nameservice::passwd::load_etc_passwd(rootfs) + .map_err(|e| Error::PasswdLoadFailure(e.to_string()))? + else { + // If there's no /etc/passwd then we're done + return Ok(SysusersAnalysis::default()); + }; + + let mut passwd = passwd + .into_iter() + .map(|mut e| { + // Make the name be the map key, leaving the old value a stub + let mut name = String::new(); + std::mem::swap(&mut e.name, &mut name); + (name, e) + }) + .collect::>(); + let mut group = nameservice::group::load_etc_group(rootfs) + .map_err(|e| Error::GroupLoadFailure(e.to_string()))? + .into_iter() + .map(|mut e| { + // Make the name be the map key, leaving the old value a stub + let mut name = String::new(); + std::mem::swap(&mut e.name, &mut name); + (name, e) + }) + .collect::>(); + + let (sysusers_users, sysusers_groups) = { + let mut users = BTreeMap::new(); + let mut groups = BTreeMap::new(); + for ent in read_sysusers(rootfs)? { + match ent { + SysusersEntry::User { + name, uid, pgid, .. + } => { + users.insert(name, SysuserData { uid, pgid }); + } + SysusersEntry::Group { name, id } => { + groups.insert(name, SysgroupData { id }); + } + SysusersEntry::Range { .. } => { + // Nothing to do here + } + } + } + (users, groups) + }; + + passwd.retain(|k, _| !sysusers_users.contains_key(k.as_str())); + group.retain(|k, _| !sysusers_groups.contains_key(k.as_str())); + + Ok(SysusersAnalysis { + missing_users: passwd.into_keys().collect(), + missing_groups: group.into_keys().collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::Write; + + use anyhow::Result; + use cap_std_ext::cap_std; + use indoc::indoc; + + const SYSUSERS_REF: &str = indoc::indoc! { r##" + # Comment here + u root 0 "Super User" /root /bin/bash + # This one omits the shell + u root 0 "Super User" /root + u bin 1:1 "bin" /bin - + # Another comment + u daemon 2:2 "daemon" /sbin - + u adm 3:4 "adm" /var/adm - + u lp 4:7 "lp" /var/spool/lpd - + u sync 5:0 "sync" /sbin /bin/sync + u shutdown 6:0 "shutdown" /sbin /sbin/shutdown + u halt 7:0 "halt" /sbin /sbin/halt + u mail 8:12 "mail" /var/spool/mail - + u operator 11:0 "operator" /root - + u games 12:100 "games" /usr/games - + u ftp 14:50 "FTP User" /var/ftp - + u nobody 65534:65534 "Kernel Overflow User" - - + # Newer systemd uses locked references + u! systemd-coredump - "systemd Core Dumper" + "##}; + + const SYSGROUPS_REF: &str = indoc::indoc! { r##" + # A comment here + g root 0 + g bin 1 + g daemon 2 + g sys 3 + g adm 4 + g tty 5 + g disk 6 + g lp 7 + g mem 8 + g kmem 9 + g wheel 10 + g cdrom 11 + g mail 12 + g man 15 + g dialout 18 + g floppy 19 + g games 20 + g utmp 22 + g tape 33 + g kvm 36 + g video 39 + g ftp 50 + g lock 54 + g audio 63 + g users 100 + g clock 103 + g input 104 + g render 105 + g sgx 106 + g nobody 65534 + "##}; + + /// Non-default sysusers found in the wild + const OTHER_SYSUSERS_REF: &str = indoc! { r#" + u qemu 107:qemu "qemu user" - - + u vboxadd -:1 - /var/run/vboxadd - + "#}; + + /// Taken from man sysusers.d + const OTHER_SYSUSERS_EXAMPLES: &str = indoc! { r#" + u user_name /file/owned/by/user "User Description" /home/dir /path/to/shell + g group_name /file/owned/by/group + # Note no GECOS field + u otheruser - + # And finally, no numeric specification at all + u justusername + g justgroupname + "#}; + + const OTHER_SYSUSERS_UNHANDLED: &str = indoc! { r#" + m user_name group_name + r - 42-43 + "#}; + + fn parse_all(s: &str) -> impl Iterator + use<'_> { + s.lines() + .filter(|line| !(line.is_empty() || line.starts_with('#'))) + .map(|line| SysusersEntry::parse(line).unwrap().unwrap()) + } + + #[test] + fn test_sysusers_parse() -> Result<()> { + let mut entries = parse_all(SYSUSERS_REF); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "root".into(), + uid: Some(0.into()), + pgid: Some(0.into()), + gecos: "Super User".into(), + home: Some("/root".into()), + shell: Some("/bin/bash".into()) + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "root".into(), + uid: Some(0.into()), + pgid: Some(0.into()), + gecos: "Super User".into(), + home: Some("/root".into()), + shell: None + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "bin".into(), + uid: Some(1.into()), + pgid: Some(1.into()), + gecos: "bin".into(), + home: Some("/bin".into()), + shell: None + } + ); + let _ = entries.next().unwrap(); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "adm".into(), + uid: Some(3.into()), + pgid: Some(4.into()), + gecos: "adm".into(), + home: Some("/var/adm".into()), + shell: None + } + ); + assert_eq!(entries.count(), 10); + + let mut entries = parse_all(OTHER_SYSUSERS_REF); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "qemu".into(), + uid: Some(107.into()), + pgid: Some(GroupReference::Name("qemu".into())), + gecos: "qemu user".into(), + home: None, + shell: None + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "vboxadd".into(), + uid: None, + pgid: Some(1.into()), + gecos: "-".into(), + home: Some("/var/run/vboxadd".into()), + shell: None + } + ); + assert_eq!(entries.count(), 0); + + let mut entries = parse_all(OTHER_SYSUSERS_EXAMPLES); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "user_name".into(), + uid: Some(IdSource::Path("/file/owned/by/user".into())), + pgid: Some(GroupReference::Path("/file/owned/by/user".into())), + gecos: "User Description".into(), + home: Some("/home/dir".into()), + shell: Some("/path/to/shell".into()) + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::Group { + name: "group_name".into(), + id: Some(IdSource::Path("/file/owned/by/group".into())) + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "otheruser".into(), + uid: None, + pgid: None, + gecos: "".into(), + home: None, + shell: None + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::User { + name: "justusername".into(), + uid: None, + pgid: None, + gecos: "".into(), + home: None, + shell: None + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::Group { + name: "justgroupname".into(), + id: None + } + ); + assert_eq!(entries.count(), 0); + + let n = OTHER_SYSUSERS_UNHANDLED + .lines() + .filter(|line| !(line.is_empty() || line.starts_with('#'))) + .try_fold(Vec::new(), |mut acc, line| { + if let Some(v) = SysusersEntry::parse(line)? { + acc.push(v); + } + anyhow::Ok(acc) + })?; + assert_eq!(n.len(), 1); + assert_eq!(n[0], SysusersEntry::Range { start: 42, end: 43 }); + + Ok(()) + } + + #[test] + fn test_sysgroups_parse() -> Result<()> { + let mut entries = SYSGROUPS_REF + .lines() + .filter(|line| !(line.is_empty() || line.starts_with('#'))) + .map(|line| SysusersEntry::parse(line).unwrap().unwrap()); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::Group { + name: "root".into(), + id: Some(0.into()), + } + ); + assert_eq!( + entries.next().unwrap(), + SysusersEntry::Group { + name: "bin".into(), + id: Some(1.into()), + } + ); + assert_eq!(entries.count(), 28); + Ok(()) + } + + fn newroot() -> Result { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir("etc")?; + root.write("etc/passwd", b"")?; + root.write("etc/group", b"")?; + root.create_dir_all(SYSUSERSD)?; + root.atomic_replace_with( + Utf8Path::new(SYSUSERSD).join("setup.conf"), + |w| -> std::io::Result<()> { + w.write_all(SYSUSERS_REF.as_bytes())?; + w.write_all(SYSGROUPS_REF.as_bytes())?; + Ok(()) + }, + )?; + Ok(root) + } + + #[test] + fn test_missing() -> Result<()> { + let root = &newroot()?; + + let a = analyze(&root).unwrap(); + assert!(a.is_empty()); + + root.write( + "etc/passwd", + indoc! { r#" + root:x:0:0:Super User:/root:/bin/bash + passim:x:982:982:Local Caching Server:/usr/share/empty:/usr/bin/nologin + avahi:x:70:70:Avahi mDNS/DNS-SD Stack:/var/run/avahi-daemon:/sbin/nologin + "#}, + )?; + root.write( + "etc/group", + indoc! { r#" + root:x:0: + adm:x:4: + wheel:x:10: + sudo:x:16: + systemd-journal:x:190: + printadmin:x:983: + rpc:x:32: + passim:x:982: + avahi:x:70: + sshd:x:981: + "#}, + )?; + + let a = analyze(&root).unwrap(); + assert!(!a.is_empty()); + let missing = a.missing_users.iter().map(|s| s.as_str()); + assert!(missing.eq(["avahi", "passim"])); + let missing = a.missing_groups.iter().map(|s| s.as_str()); + assert!(missing.eq([ + "avahi", + "passim", + "printadmin", + "rpc", + "sshd", + "sudo", + "systemd-journal" + ])); + + Ok(()) + } +} diff --git a/crates/sysusers/src/nameservice/group.rs b/crates/sysusers/src/nameservice/group.rs new file mode 100644 index 000000000..3ea1f2622 --- /dev/null +++ b/crates/sysusers/src/nameservice/group.rs @@ -0,0 +1,130 @@ +//! Helpers for [user passwd file](https://man7.org/linux/man-pages/man5/passwd.5.html). +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{anyhow, Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use std::io::{BufRead, BufReader, Write}; + +// Entry from group file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GroupEntry { + pub(crate) name: String, + pub(crate) passwd: String, + pub(crate) gid: u32, + pub(crate) users: Vec, +} + +impl GroupEntry { + /// Parse a single group entry. + pub fn parse_line(s: impl AsRef) -> Option { + let mut parts = s.as_ref().splitn(4, ':'); + let entry = Self { + name: parts.next()?.to_string(), + passwd: parts.next()?.to_string(), + gid: parts.next().and_then(|s| s.parse().ok())?, + users: { + let users = parts.next()?; + users.split(',').map(String::from).collect() + }, + }; + Some(entry) + } + + /// Serialize entry to writer, as a group line. + pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> { + let users: String = self.users.join(","); + std::writeln!( + writer, + "{}:{}:{}:{}", + self.name, + self.passwd, + self.gid, + users, + ) + .with_context(|| "failed to write passwd entry") + } +} + +pub(crate) fn parse_group_content(content: impl BufRead) -> Result> { + let mut groups = vec![]; + for (line_num, line) in content.lines().enumerate() { + let input = + line.with_context(|| format!("failed to read group entry at line {line_num}"))?; + + // Skip empty and comment lines + if input.is_empty() || input.starts_with('#') { + continue; + } + // Skip NSS compat lines, see "Compatibility mode" in + // https://man7.org/linux/man-pages/man5/nsswitch.conf.5.html + if input.starts_with('+') || input.starts_with('-') { + continue; + } + + let entry = GroupEntry::parse_line(&input).ok_or_else(|| { + anyhow!( + "failed to parse group entry at line {}, content: {}", + line_num, + &input + ) + })?; + groups.push(entry); + } + Ok(groups) +} + +pub(crate) fn load_etc_group(rootfs: &Dir) -> Result> { + let r = rootfs.open("etc/group").map(BufReader::new)?; + parse_group_content(r) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn mock_group_entry() -> GroupEntry { + GroupEntry { + name: "staff".to_string(), + passwd: "x".to_string(), + gid: 50, + users: vec!["operator".to_string()], + } + } + + #[test] + fn test_parse_lines() { + let content = r#" ++groupA +-groupB + +root:x:0: +daemon:x:1: +bin:x:2: +sys:x:3: +adm:x:4: +www-data:x:33: +backup:x:34: +operator:x:37: + +# Dummy comment +staff:x:50:operator + ++ +"#; + + let input = Cursor::new(content); + let groups = parse_group_content(input).unwrap(); + assert_eq!(groups.len(), 9); + assert_eq!(groups[8], mock_group_entry()); + } + + #[test] + fn test_write_entry() { + let entry = mock_group_entry(); + let expected = b"staff:x:50:operator\n"; + let mut buf = Vec::new(); + entry.to_writer(&mut buf).unwrap(); + assert_eq!(&buf, expected); + } +} diff --git a/crates/sysusers/src/nameservice/mod.rs b/crates/sysusers/src/nameservice/mod.rs new file mode 100644 index 000000000..14386251a --- /dev/null +++ b/crates/sysusers/src/nameservice/mod.rs @@ -0,0 +1,7 @@ +//! Linux name-service information helpers. +// SPDX-License-Identifier: Apache-2.0 OR MIT +// TODO(lucab): consider moving this to its own crate. + +pub(crate) mod group; +pub(crate) mod passwd; +pub(crate) mod shadow; diff --git a/crates/sysusers/src/nameservice/passwd.rs b/crates/sysusers/src/nameservice/passwd.rs new file mode 100644 index 000000000..216958ef0 --- /dev/null +++ b/crates/sysusers/src/nameservice/passwd.rs @@ -0,0 +1,140 @@ +//! Helpers for [password file](https://man7.org/linux/man-pages/man5/passwd.5.html). +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{anyhow, Context, Result}; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use std::io::{BufRead, BufReader, Write}; + +// Entry from passwd file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PasswdEntry { + pub(crate) name: String, + pub(crate) passwd: String, + pub(crate) uid: u32, + pub(crate) gid: u32, + pub(crate) gecos: String, + pub(crate) home_dir: String, + pub(crate) shell: String, +} + +impl PasswdEntry { + /// Parse a single passwd entry. + pub fn parse_line(s: impl AsRef) -> Option { + let mut parts = s.as_ref().splitn(7, ':'); + let entry = Self { + name: parts.next()?.to_string(), + passwd: parts.next()?.to_string(), + uid: parts.next().and_then(|s| s.parse().ok())?, + gid: parts.next().and_then(|s| s.parse().ok())?, + gecos: parts.next()?.to_string(), + home_dir: parts.next()?.to_string(), + shell: parts.next()?.to_string(), + }; + Some(entry) + } + + /// Serialize entry to writer, as a passwd line. + pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> { + std::writeln!( + writer, + "{}:{}:{}:{}:{}:{}:{}", + self.name, + self.passwd, + self.uid, + self.gid, + self.gecos, + self.home_dir, + self.shell + ) + .with_context(|| "failed to write passwd entry") + } +} + +pub(crate) fn parse_passwd_content(content: impl BufRead) -> Result> { + let mut passwds = vec![]; + for (line_num, line) in content.lines().enumerate() { + let input = + line.with_context(|| format!("failed to read passwd entry at line {line_num}"))?; + + // Skip empty and comment lines + if input.is_empty() || input.starts_with('#') { + continue; + } + // Skip NSS compat lines, see "Compatibility mode" in + // https://man7.org/linux/man-pages/man5/nsswitch.conf.5.html + if input.starts_with('+') || input.starts_with('-') { + continue; + } + + let entry = PasswdEntry::parse_line(&input).ok_or_else(|| { + anyhow!( + "failed to parse passwd entry at line {}, content: {}", + line_num, + &input + ) + })?; + passwds.push(entry); + } + Ok(passwds) +} + +pub(crate) fn load_etc_passwd(rootfs: &Dir) -> Result>> { + if let Some(r) = rootfs.open_optional("etc/passwd")? { + parse_passwd_content(BufReader::new(r)).map(Some) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn mock_passwd_entry() -> PasswdEntry { + PasswdEntry { + name: "someuser".to_string(), + passwd: "x".to_string(), + uid: 1000, + gid: 1000, + gecos: "Foo BAR,,,".to_string(), + home_dir: "/home/foobar".to_string(), + shell: "/bin/bash".to_string(), + } + } + + #[test] + fn test_parse_lines() { + let content = r#" +root:x:0:0:root:/root:/bin/bash + ++userA +-userB + +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +systemd-coredump:x:1:1:systemd Core Dumper:/:/usr/sbin/nologin + ++@groupA +-@groupB + +# Dummy comment +someuser:x:1000:1000:Foo BAR,,,:/home/foobar:/bin/bash + ++ +"#; + + let input = Cursor::new(content); + let groups = parse_passwd_content(input).unwrap(); + assert_eq!(groups.len(), 4); + assert_eq!(groups[3], mock_passwd_entry()); + } + + #[test] + fn test_write_entry() { + let entry = mock_passwd_entry(); + let expected = b"someuser:x:1000:1000:Foo BAR,,,:/home/foobar:/bin/bash\n"; + let mut buf = Vec::new(); + entry.to_writer(&mut buf).unwrap(); + assert_eq!(&buf, expected); + } +} diff --git a/crates/sysusers/src/nameservice/shadow.rs b/crates/sysusers/src/nameservice/shadow.rs new file mode 100644 index 000000000..4bd9dbc7f --- /dev/null +++ b/crates/sysusers/src/nameservice/shadow.rs @@ -0,0 +1,166 @@ +//! Helpers for [shadowed password file](https://man7.org/linux/man-pages/man5/shadow.5.html). +// Copyright (C) 2021 Oracle and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{anyhow, Context, Result}; +use std::io::{BufRead, Write}; + +/// Entry from shadow file. +// Field names taken from (presumably glibc's) /usr/include/shadow.h, descriptions adapted +// from the [shadow(3) manual page](https://man7.org/linux/man-pages/man3/shadow.3.html). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShadowEntry { + /// user login name + pub(crate) namp: String, + /// encrypted password + pub(crate) pwdp: String, + /// days (from Jan 1, 1970) since password was last changed + pub(crate) lstchg: Option, + /// days before which password may not be changed + pub(crate) min: Option, + /// days after which password must be changed + pub(crate) max: Option, + /// days before password is to expire that user is warned of pending password expiration + pub(crate) warn: Option, + /// days after password expires that account is considered inactive and disabled + pub(crate) inact: Option, + /// date (in days since Jan 1, 1970) when account will be disabled + pub(crate) expire: Option, + /// reserved for future use + pub(crate) flag: String, +} + +fn u32_or_none(value: &str) -> Result, std::num::ParseIntError> { + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.parse()?)) + } +} + +fn number_or_empty(value: Option) -> String { + if let Some(number) = value { + format!("{number}") + } else { + "".to_string() + } +} + +impl ShadowEntry { + /// Parse a single shadow entry. + pub fn parse_line(s: impl AsRef) -> Option { + let mut parts = s.as_ref().splitn(9, ':'); + + let entry = Self { + namp: parts.next()?.to_string(), + pwdp: parts.next()?.to_string(), + lstchg: u32_or_none(parts.next()?).ok()?, + min: u32_or_none(parts.next()?).ok()?, + max: u32_or_none(parts.next()?).ok()?, + warn: u32_or_none(parts.next()?).ok()?, + inact: u32_or_none(parts.next()?).ok()?, + expire: u32_or_none(parts.next()?).ok()?, + flag: parts.next()?.to_string(), + }; + Some(entry) + } + + /// Serialize entry to writer, as a shadow line. + pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> { + std::writeln!( + writer, + "{}:{}:{}:{}:{}:{}:{}:{}:{}", + self.namp, + self.pwdp, + number_or_empty(self.lstchg), + number_or_empty(self.min), + number_or_empty(self.max), + number_or_empty(self.warn), + number_or_empty(self.inact), + number_or_empty(self.expire), + self.flag + ) + .with_context(|| "failed to write shadow entry") + } +} + +pub(crate) fn parse_shadow_content(content: impl BufRead) -> Result> { + let mut entries = vec![]; + for (line_num, line) in content.lines().enumerate() { + let input = + line.with_context(|| format!("failed to read shadow entry at line {line_num}"))?; + + // Skip empty and comment lines + if input.is_empty() || input.starts_with('#') { + continue; + } + + let entry = ShadowEntry::parse_line(&input).ok_or_else(|| { + anyhow!( + "failed to parse shadow entry at line {}, content: {}", + line_num, + &input + ) + })?; + entries.push(entry); + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn salted() -> String { + // Hex encoded hashed password to avoid tripping leak detectors + let obfuscated = "24362473616c7453616c7453414c544e61436c247865354c5a47466c656b35334350467765327069495065536947414e6f596f696e5544755157307179645879766f594b566d4c3257524c71445a48586b626e706f4148714c3079616c6939344e526355527445616f51"; + String::from_utf8(hex::decode(obfuscated).unwrap()).unwrap() + } + + fn mock_shadow_entry() -> ShadowEntry { + ShadowEntry { + namp: "salty".to_string(), + pwdp: salted(), + lstchg: Some(18912), + min: Some(0), + max: Some(99999), + warn: Some(7), + inact: None, + expire: None, + flag: "".to_string(), + } + } + + #[test] + fn test_parse_lines() { + let salted = salted(); + let content = format!( + r#" +root:*:18912:0:99999:7::: +daemon:*:18474:0:99999:7::: + +salty:{salted}:18912:0:99999:7::: + +# Dummy comment +systemd-coredump:!!::::::: +systemd-resolve:!!::::::: +rngd:!!::::::: +"# + ); + + let input = Cursor::new(content); + let entries = parse_shadow_content(input).unwrap(); + assert_eq!(entries.len(), 6); + assert_eq!(entries[2], mock_shadow_entry()); + } + + #[test] + fn test_write_entry() { + let entry = mock_shadow_entry(); + let expected = format!("salty:{}:18912:0:99999:7:::\n", salted()); + let mut buf = Vec::new(); + entry.to_writer(&mut buf).unwrap(); + assert_eq!(&buf, expected.as_bytes()); + } +} diff --git a/crates/tests-integration/Cargo.toml b/crates/tests-integration/Cargo.toml new file mode 100644 index 000000000..eb1ca75d5 --- /dev/null +++ b/crates/tests-integration/Cargo.toml @@ -0,0 +1,34 @@ +# Our integration tests +[package] +name = "tests-integration" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[[bin]] +name = "tests-integration" +path = "src/tests-integration.rs" + +[dependencies] +# Workspace dependencies +anyhow = { workspace = true } +camino = { workspace = true } +cap-std-ext = { workspace = true } +clap = { workspace = true, features = ["derive","cargo"] } +fn-error-context = { workspace = true } +indoc = { workspace = true } +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +xshell = { workspace = true } +bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" } + +# Crate-specific dependencies +libtest-mimic = "0.8.0" +oci-spec = "0.8.0" +rexpect = "0.6" + +[lints] +workspace = true diff --git a/crates/tests-integration/README.md b/crates/tests-integration/README.md new file mode 100644 index 000000000..9299670a8 --- /dev/null +++ b/crates/tests-integration/README.md @@ -0,0 +1,34 @@ +# Integration tests crate + +This crate holds integration tests (as distinct from the regular +Rust unit tests run as part of `cargo test`). + +## Building and running + +`cargo run -p tests-integration` +will work. Note that at the current time all test suites target +an externally built bootc-compatible container image. See +how things are set up in e.g. Github Actions, where we first +run a `podman build` with the bootc git sources. + +## Available suites + +### `composefs-bcvk` + +Intended only right now to be used with a sealed UKI image, +and sanity checks the composefs backend. + +### `host-privileged` + +This suite will run the target container image in a way that expects +full privileges, but is *not* destructive. + +### `install-alongside` + +This suite is *DESTRUCTIVE*, executing the bootc `install to-existing-root` +style flow using the host root. Run it in a transient virtual machine. + +### `system-reinstall` + +This suite is *DESTRUCTIVE*, executing the `system-reinstall-bootc` +tests. Run it in a transient virtual machine. diff --git a/crates/tests-integration/src/composefs_bcvk.rs b/crates/tests-integration/src/composefs_bcvk.rs new file mode 100644 index 000000000..35eae10f6 --- /dev/null +++ b/crates/tests-integration/src/composefs_bcvk.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use bootc_kernel_cmdline; +use camino::Utf8Path; +use libtest_mimic::Trial; +use xshell::{cmd, Shell}; + +const BOOTED: &str = ""; + +fn outer_runner(image: &'static str) -> Vec { + [Trial::test("Basic", move || { + let sh = &xshell::Shell::new()?; + const NAME: &str = "bootc-composefs-bcvk-test"; + struct StopTestVM<'a>(&'a Shell); + impl<'a> Drop for StopTestVM<'a> { + fn drop(&mut self) { + let _ = cmd!(self.0, "bcvk libvirt rm --stop --force {NAME}") + .ignore_status() + .ignore_stdout() + .ignore_stderr() + .quiet() + .run(); + } + } + // Clean up any leakage if e.g. the whole process died + drop(StopTestVM(sh)); + // And also do so on drop + let _guard = StopTestVM(sh); + cmd!( + sh, + "bcvk libvirt run --name {NAME} --filesystem=ext4 --firmware=uefi-insecure {image}" + ) + .run()?; + for _ in 0..5 { + if cmd!(sh, "bcvk libvirt ssh {NAME} -- true") + .ignore_stderr() + .run() + .is_ok() + { + break; + } + } + cmd!( + sh, + "bcvk libvirt ssh {NAME} -- bootc-integration-tests composefs-bcvk {BOOTED}" + ) + .run()?; + Ok(()) + })] + .into_iter() + .collect() +} + +fn inner_tests() -> Vec { + [Trial::test("Basic", move || { + let sh = &xshell::Shell::new()?; + let st = cmd!(sh, "bootc status --json").read()?; + let st: serde_json::Value = serde_json::from_str(&st)?; + assert!(st.is_object()); + assert!(Utf8Path::new("/sysroot/composefs").try_exists()?); + assert!(!Utf8Path::new("/sysroot/ostree").try_exists()?); + + let cmdline = bootc_kernel_cmdline::utf8::Cmdline::from_proc()?; + + let cfs = cmdline.find("composefs"); + assert!(cfs.is_some()); + let cfs = cfs.unwrap(); + + let verity_from_cmdline = cfs.value(); + assert!(verity_from_cmdline.is_some()); + let verity_from_cmdline = verity_from_cmdline.unwrap(); + + let verity_from_status = st + .get("status") + .and_then(|s| s.get("booted")) + .and_then(|b| b.get("composefs")) + .and_then(|c| c.get("verity")) + .and_then(|v| v.as_str()); + + assert!(verity_from_status.is_some()); + + assert_eq!(verity_from_status.unwrap(), verity_from_cmdline); + + // Verify that we booted via systemd-gpt-auto-generator by checking + // that /proc/cmdline does NOT contain a root= parameter + let has_root_param = cmdline.iter().any(|entry| { + entry.key() == "root".into() + }); + assert!(!has_root_param, "Sealed composefs image should not have root= in kernel cmdline; systemd-gpt-auto-generator should discover the root partition via DPS"); + + Ok(()) + })] + .into_iter() + .collect() +} + +//#[context("Composefs+bcvk tests")] +pub(crate) fn run(image: &str, testargs: libtest_mimic::Arguments) -> Result<()> { + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + // Handy defaults + + let tests = if image == BOOTED { + inner_tests() + } else { + outer_runner(image) + }; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs new file mode 100644 index 000000000..55874bd10 --- /dev/null +++ b/crates/tests-integration/src/container.rs @@ -0,0 +1,96 @@ +use std::process::Command; + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use fn_error_context::context; +use libtest_mimic::Trial; +use xshell::{cmd, Shell}; + +fn new_test(description: &'static str, f: fn() -> anyhow::Result<()>) -> libtest_mimic::Trial { + Trial::test(description, move || f().map_err(Into::into)) +} + +pub(crate) fn test_bootc_status() -> Result<()> { + let sh = Shell::new()?; + let host: serde_json::Value = serde_json::from_str(&cmd!(sh, "bootc status --json").read()?)?; + assert!(host.get("status").unwrap().get("ty").is_none()); + Ok(()) +} + +pub(crate) fn test_bootc_upgrade() -> Result<()> { + for c in ["upgrade", "update"] { + let o = Command::new("bootc").arg(c).output()?; + let st = o.status; + assert!(!st.success()); + let stderr = String::from_utf8(o.stderr)?; + assert!( + stderr.contains("this command requires a booted host system"), + "stderr: {stderr}", + ); + } + Ok(()) +} + +pub(crate) fn test_bootc_install_config() -> Result<()> { + let sh = &xshell::Shell::new()?; + let config = cmd!(sh, "bootc install print-configuration").read()?; + let config: serde_json::Value = + serde_json::from_str(&config).context("Parsing install config")?; + // Just verify we parsed the config, if any + drop(config); + Ok(()) +} + +/// Previously system-reinstall-bootc bombed out when run as non-root even if passing --help +fn test_system_reinstall_help() -> Result<()> { + let o = Command::new("runuser") + .args(["-u", "bin", "system-reinstall-bootc", "--help"]) + .output()?; + assert!(o.status.success()); + Ok(()) +} + +/// Verify that the values of `variant` and `base` from Justfile actually applied +/// to this container image. +fn test_variant_base_crosscheck() -> Result<()> { + if let Some(variant) = std::env::var("BOOTC_variant").ok() { + // TODO add this to `bootc status` or so? + let boot_efi = Utf8Path::new("/boot/EFI"); + match variant.as_str() { + "ostree" => { + assert!(!boot_efi.try_exists()?); + } + "composefs-sealeduki-sdboot" => { + assert!(boot_efi.try_exists()?); + } + o => panic!("Unhandled variant: {o}"), + } + } + if let Some(base) = std::env::var("BOOTC_base").ok() { + // Hackily reverse back from container pull spec to ID-VERSION_ID + // TODO: move the OsReleaseInfo into an internal crate we use + let osrelease = std::fs::read_to_string("/usr/lib/os-release")?; + if base.contains("centos-bootc") { + assert!(osrelease.contains(r#"ID="centos""#)) + } else if base.contains("fedora-bootc") { + assert!(osrelease.contains(r#"ID=fedora"#)); + } else { + eprintln!("notice: Unhandled base {base}") + } + } + Ok(()) +} + +/// Tests that should be run in a default container image. +#[context("Container tests")] +pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { + let tests = [ + new_test("variant-base-crosscheck", test_variant_base_crosscheck), + new_test("bootc upgrade", test_bootc_upgrade), + new_test("install config", test_bootc_install_config), + new_test("status", test_bootc_status), + new_test("system-reinstall --help", test_system_reinstall_help), + ]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/hostpriv.rs b/crates/tests-integration/src/hostpriv.rs new file mode 100644 index 000000000..1ad420993 --- /dev/null +++ b/crates/tests-integration/src/hostpriv.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use fn_error_context::context; +use libtest_mimic::Trial; +use xshell::cmd; + +struct TestState { + image: String, +} + +fn new_test( + state: &'static TestState, + description: &'static str, + f: fn(&'static str) -> anyhow::Result<()>, +) -> libtest_mimic::Trial { + Trial::test(description, move || f(&state.image).map_err(Into::into)) +} + +fn test_loopback_install(image: &'static str) -> Result<()> { + let base_args = super::install::BASE_ARGS; + let sh = &xshell::Shell::new()?; + let size = 10 * 1000 * 1000 * 1000; + let mut tmpdisk = tempfile::NamedTempFile::new_in("/var/tmp")?; + tmpdisk.as_file_mut().set_len(size)?; + let tmpdisk = tmpdisk.into_temp_path(); + let tmpdisk = tmpdisk.to_str().unwrap(); + cmd!( + sh, + "sudo {base_args...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback /disk" + ) + .run()?; + Ok(()) +} + +/// Tests that require real root (e.g. CAP_SYS_ADMIN) to do things like +/// create loopback devices, but are *not* destructive. At the current time +/// these tests are defined to reference a bootc container image. +#[context("Hostpriv tests")] +pub(crate) fn run_hostpriv(image: &str, testargs: libtest_mimic::Arguments) -> Result<()> { + let state = Box::new(TestState { + image: image.to_string(), + }); + // Make this static because the tests require it + let state: &'static TestState = Box::leak(state); + + let tests = [new_test(&state, "loopback install", test_loopback_install)]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/install.rs b/crates/tests-integration/src/install.rs new file mode 100644 index 000000000..aeef12c8d --- /dev/null +++ b/crates/tests-integration/src/install.rs @@ -0,0 +1,175 @@ +use std::os::fd::AsRawFd; +use std::path::Path; + +use anyhow::Result; +use camino::Utf8Path; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::Dir; +use fn_error_context::context; +use libtest_mimic::Trial; +use xshell::{cmd, Shell}; + +pub(crate) const BASE_ARGS: &[&str] = &["podman", "run", "--rm", "--privileged", "--pid=host"]; + +// Arbitrary +const NON_DEFAULT_STATEROOT: &str = "foo"; + +/// Clear out and delete any ostree roots, leverage bootc hidden wipe-ostree command to get rid of +/// otherwise hard to delete deployment files +pub(crate) fn reset_root(sh: &Shell, image: &str) -> Result<()> { + delete_ostree_deployments(sh, image)?; + delete_ostree(sh)?; + Ok(()) +} + +pub(crate) fn delete_ostree(sh: &Shell) -> Result<(), anyhow::Error> { + if !Path::new("/ostree/").exists() { + return Ok(()); + } + // TODO: This shouldn't be leaking out of installs + cmd!(sh, "sudo umount -Rl /ostree/bootc/storage/overlay") + .ignore_status() + .run()?; + cmd!(sh, "sudo /bin/sh -c 'rm -rf /ostree/'").run()?; + Ok(()) +} + +fn delete_ostree_deployments(sh: &Shell, image: &str) -> Result<(), anyhow::Error> { + if !Path::new("/ostree/deploy/").exists() { + return Ok(()); + } + let mounts = &["-v", "/ostree:/ostree", "-v", "/boot:/boot"]; + cmd!( + sh, + "sudo {BASE_ARGS...} {mounts...} {image} bootc state wipe-ostree" + ) + .run()?; + cmd!(sh, "sudo /bin/sh -c 'rm -rf /ostree/deploy/*'").run()?; + Ok(()) +} + +fn find_deployment_root() -> Result

{ + let _stateroot = "default"; + let d = Dir::open_ambient_dir( + "/ostree/deploy/default/deploy", + cap_std::ambient_authority(), + )?; + for child in d.entries()? { + let child = child?; + if !child.file_type()?.is_dir() { + continue; + } + return Ok(child.open_dir()?); + } + anyhow::bail!("Failed to find deployment root") +} + +// Hook relatively cheap post-install tests here +pub(crate) fn generic_post_install_verification() -> Result<()> { + assert!(Utf8Path::new("/ostree/repo").try_exists()?); + assert!(Utf8Path::new("/ostree/bootc/storage/overlay").try_exists()?); + Ok(()) +} + +#[context("Install tests")] +pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments) -> Result<()> { + // Force all of these tests to be serial because they mutate global state + testargs.test_threads = Some(1); + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + // Handy defaults + + let target_args = &["-v", "/:/target"]; + + let tests = [ + Trial::test("loopback install", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + let size = 10 * 1000 * 1000 * 1000; + let mut tmpdisk = tempfile::NamedTempFile::new_in("/var/tmp")?; + tmpdisk.as_file_mut().set_len(size)?; + let tmpdisk = tmpdisk.into_temp_path(); + let tmpdisk = tmpdisk.to_str().unwrap(); + cmd!(sh, "sudo {BASE_ARGS...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback /disk").run()?; + Ok(()) + }), + Trial::test( + "replace=alongside with ssh keys and a karg, and SELinux disabled", + move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + let tmpd = &sh.create_temp_dir()?; + let tmp_keys = tmpd.path().join("test_authorized_keys"); + let tmp_keys = tmp_keys.to_str().unwrap(); + std::fs::write(&tmp_keys, b"ssh-ed25519 ABC0123 testcase@example.com")?; + cmd!(sh, "sudo {BASE_ARGS...} {target_args...} -v {tmp_keys}:/test_authorized_keys {image} bootc install to-filesystem --acknowledge-destructive --karg=foo=bar --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target").run()?; + + // Also test install finalize here + cmd!( + sh, + "sudo {BASE_ARGS...} {target_args...} {image} bootc install finalize /target" + ) + .run()?; + + generic_post_install_verification()?; + + // Test kargs injected via CLI + cmd!( + sh, + "sudo /bin/sh -c 'grep foo=bar /boot/loader/entries/*.conf'" + ) + .run()?; + // And kargs we added into our default container image + cmd!( + sh, + "sudo /bin/sh -c 'grep localtestkarg=somevalue /boot/loader/entries/*.conf'" + ) + .run()?; + cmd!( + sh, + "sudo /bin/sh -c 'grep testing-kargsd=3 /boot/loader/entries/*.conf'" + ) + .run()?; + let deployment = &find_deployment_root()?; + let cwd = sh.push_dir(format!("/proc/self/fd/{}", deployment.as_raw_fd())); + cmd!( + sh, + "grep authorized_keys etc/tmpfiles.d/bootc-root-ssh.conf" + ) + .run()?; + drop(cwd); + Ok(()) + }, + ), + Trial::test("Install and verify selinux state", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + cmd!(sh, "sudo {BASE_ARGS...} {image} bootc install to-existing-root --acknowledge-destructive").run()?; + generic_post_install_verification()?; + let root = &Dir::open_ambient_dir("/ostree", cap_std::ambient_authority()).unwrap(); + crate::selinux::verify_selinux_recurse(root, false)?; + Ok(()) + }), + Trial::test("Install to non-default stateroot", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + cmd!(sh, "sudo {BASE_ARGS...} {image} bootc install to-existing-root --stateroot {NON_DEFAULT_STATEROOT} --acknowledge-destructive").run()?; + generic_post_install_verification()?; + assert!( + Utf8Path::new(&format!("/ostree/deploy/{NON_DEFAULT_STATEROOT}")).try_exists()? + ); + Ok(()) + }), + Trial::test("without an install config", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh, image)?; + let empty = sh.create_temp_dir()?; + let empty = empty.path().to_str().unwrap(); + cmd!(sh, "sudo {BASE_ARGS...} -v {empty}:/usr/lib/bootc/install {image} bootc install to-existing-root").run()?; + generic_post_install_verification()?; + Ok(()) + }), + ]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/runvm.rs b/crates/tests-integration/src/runvm.rs new file mode 100644 index 000000000..823be9dbd --- /dev/null +++ b/crates/tests-integration/src/runvm.rs @@ -0,0 +1,165 @@ +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::Subcommand; +use fn_error_context::context; +use xshell::{cmd, Shell}; + +const BUILDER_ANNOTATION: &str = "bootc.diskimage-builder"; +const TEST_IMAGE: &str = "localhost/bootc"; +const TESTVMDIR: &str = "testvm"; +const DISK_CACHE: &str = "disk.qcow2"; +const IMAGEID_XATTR: &str = "user.bootc.container-image-digest"; + +#[derive(Debug, Subcommand)] +#[clap(rename_all = "kebab-case")] +pub(crate) enum Opt { + PrepareTmt { + #[clap(long)] + /// The container image to spawn, otherwise one will be built + testimage: Option, + }, + CreateQcow2 { + /// Input container image + container: String, + /// Write disk to this path + disk: Utf8PathBuf, + }, +} + +struct TestContext { + sh: xshell::Shell, + targetdir: Utf8PathBuf, +} + +fn image_digest(sh: &Shell, cimage: &str) -> Result { + let key = "{{ .Digest }}"; + let r = cmd!(sh, "podman inspect --type image --format {key} {cimage}").read()?; + Ok(r) +} + +fn builder_from_image(sh: &Shell, cimage: &str) -> Result { + let mut inspect: serde_json::Value = + serde_json::from_str(&cmd!(sh, "podman inspect --type image {cimage}").read()?)?; + let inspect = inspect + .as_array_mut() + .and_then(|v| v.pop()) + .ok_or_else(|| anyhow::anyhow!("Failed to parse inspect output"))?; + let config = inspect + .get("Config") + .ok_or_else(|| anyhow::anyhow!("Missing config"))?; + let config: oci_spec::image::Config = + serde_json::from_value(config.clone()).context("Parsing config")?; + let builder = config + .labels() + .as_ref() + .and_then(|l| l.get(BUILDER_ANNOTATION)) + .ok_or_else(|| anyhow::anyhow!("Missing {BUILDER_ANNOTATION}"))?; + Ok(builder.to_owned()) +} + +#[context("Running bootc-image-builder")] +fn run_bib(sh: &Shell, cimage: &str, tmpdir: &Utf8Path, diskpath: &Utf8Path) -> Result<()> { + let diskpath: Utf8PathBuf = sh.current_dir().join(diskpath).try_into()?; + let digest = image_digest(sh, cimage)?; + println!("{cimage} digest={digest}"); + if diskpath.try_exists()? { + let mut buf = [0u8; 2048]; + if let Ok(n) = rustix::fs::getxattr(diskpath.as_std_path(), IMAGEID_XATTR, &mut buf) + .context("Reading xattr") + { + let buf = String::from_utf8_lossy(&buf[0..n]); + if &*buf == digest.as_str() { + println!("Existing disk {diskpath} matches container digest {digest}"); + return Ok(()); + } else { + println!("Cache miss; previous digest={buf}"); + } + } + } + let builder = if let Ok(b) = std::env::var("BOOTC_BUILDER") { + b + } else { + builder_from_image(sh, cimage)? + }; + let _g = sh.push_dir(tmpdir); + let bibwork = "bib-work"; + sh.remove_path(bibwork)?; + sh.create_dir(bibwork)?; + let _g = sh.push_dir(bibwork); + let pwd = sh.current_dir(); + cmd!(sh, "podman run --rm --privileged -v /var/lib/containers/storage:/var/lib/containers/storage --security-opt label=type:unconfined_t -v {pwd}:/output {builder} --type qcow2 --local {cimage}").run()?; + let tmp_disk: Utf8PathBuf = sh + .current_dir() + .join("qcow2/disk.qcow2") + .try_into() + .unwrap(); + rustix::fs::setxattr( + tmp_disk.as_std_path(), + IMAGEID_XATTR, + digest.as_bytes(), + rustix::fs::XattrFlags::empty(), + ) + .context("Setting xattr")?; + cmd!(sh, "mv -Tf {tmp_disk} {diskpath}").run()?; + cmd!(sh, "rm -rf {bibwork}").run()?; + Ok(()) +} + +/// Given the input container image reference, create a disk +/// image in the target directory. +#[context("Creating disk")] +fn create_disk(ctx: &TestContext, cimage: &str) -> Result { + let sh = &ctx.sh; + let targetdir = ctx.targetdir.as_path(); + let _targetdir_guard = sh.push_dir(targetdir); + sh.create_dir(TESTVMDIR)?; + let output_disk: Utf8PathBuf = sh + .current_dir() + .join(TESTVMDIR) + .join(DISK_CACHE) + .try_into() + .unwrap(); + + let bibwork = "bib-work"; + sh.remove_path(bibwork)?; + sh.create_dir(bibwork)?; + + run_bib(sh, cimage, bibwork.into(), &output_disk)?; + + Ok(output_disk) +} + +pub(crate) fn run(opt: Opt) -> Result<()> { + let ctx = &{ + let sh = xshell::Shell::new()?; + let mut targetdir: Utf8PathBuf = cmd!(sh, "git rev-parse --show-toplevel").read()?.into(); + targetdir.push("target"); + TestContext { targetdir, sh } + }; + match opt { + Opt::PrepareTmt { mut testimage } => { + let testimage = if let Some(i) = testimage.take() { + i + } else { + let source_date_epoch = cmd!(&ctx.sh, "git log -1 --pretty=%ct").read()?; + cmd!( + &ctx.sh, + "podman build --timestamp={source_date_epoch} --build-arg=variant=tmt -t {TEST_IMAGE} -f hack/Containerfile ." + ) + .run()?; + TEST_IMAGE.to_string() + }; + + let disk = create_disk(ctx, &testimage)?; + println!("Created: {disk}"); + Ok(()) + } + Opt::CreateQcow2 { container, disk } => { + let g = ctx.sh.push_dir(&ctx.targetdir); + ctx.sh.remove_path("tmp")?; + ctx.sh.create_dir("tmp")?; + drop(g); + run_bib(&ctx.sh, &container, "tmp".into(), &disk) + } + } +} diff --git a/crates/tests-integration/src/selinux.rs b/crates/tests-integration/src/selinux.rs new file mode 100644 index 000000000..7fef494f3 --- /dev/null +++ b/crates/tests-integration/src/selinux.rs @@ -0,0 +1,38 @@ +use std::ffi::OsStr; +use std::ops::ControlFlow; +use std::os::fd::AsRawFd; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::{CapStdExtDirExt, WalkConfiguration}; +use rustix::path::DecInt; + +fn verify_selinux_label_exists(d: &Dir, filename: &OsStr) -> Result { + let mut buf = [0u8; 1024]; + let mut fdpath = PathBuf::from("/proc/self/fd"); + fdpath.push(DecInt::new(d.as_raw_fd())); + fdpath.push(filename); + match rustix::fs::lgetxattr(fdpath, "security.selinux", &mut buf) { + // Ignore EOPNOTSUPPORTED + Ok(_) | Err(rustix::io::Errno::OPNOTSUPP) => Ok(true), + Err(rustix::io::Errno::NODATA) => Ok(false), + Err(e) => Err(e.into()), + } +} + +pub(crate) fn verify_selinux_recurse(root: &Dir, warn: bool) -> Result<()> { + root.walk(&WalkConfiguration::default().noxdev(), |e| { + let exists = verify_selinux_label_exists(e.dir, e.filename) + .with_context(|| format!("Failed to look up context for {:?}", e.path))?; + if !exists { + if warn { + eprintln!("No SELinux label found for: {:?}", e.path); + } else { + anyhow::bail!("No SELinux label found for: {:?}", e.path); + } + } + anyhow::Ok(ControlFlow::Continue(())) + })?; + Ok(()) +} diff --git a/crates/tests-integration/src/system_reinstall.rs b/crates/tests-integration/src/system_reinstall.rs new file mode 100644 index 000000000..69699737f --- /dev/null +++ b/crates/tests-integration/src/system_reinstall.rs @@ -0,0 +1,172 @@ +use anyhow::{anyhow, Context, Result}; +use fn_error_context::context; +use libtest_mimic::Trial; +use rexpect::session::PtySession; +use rustix::fs::statfs; +use std::{ + fs::{self}, + path::Path, + time::Duration, +}; + +use crate::install; + +/// A generous timeout since some CI runs may be slower +const TIMEOUT: Duration = std::time::Duration::from_secs(5 * 60); + +fn get_deployment_dir() -> Result { + let base_path = Path::new("/ostree/deploy/default/deploy"); + + let entries: Vec = fs::read_dir(base_path) + .with_context(|| format!("Failed to read directory: {}", base_path.display()))? + .filter_map(|entry| match entry { + Ok(e) if e.path().is_dir() => Some(e), + _ => None, + }) + .collect::>(); + + assert_eq!( + entries.len(), + 1, + "Expected exactly one deployment directory" + ); + + let deploy_dir_entry = &entries[0]; + assert!( + deploy_dir_entry.file_type()?.is_dir(), + "deployment directory entry is not a directory: {}", + base_path.display() + ); + + let hash = deploy_dir_entry.file_name(); + let hash_str = hash + .to_str() + .ok_or_else(|| anyhow!("Deployment directory name {:?} is not valid UTF-8", hash))?; + + println!("Using deployment directory: {hash_str}"); + + Ok(base_path.join(hash_str)) +} + +#[context("System reinstall tests")] +pub(crate) fn run(image: &str, testargs: libtest_mimic::Arguments) -> Result<()> { + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + + let tests = [ + Trial::test("default behavior", move || { + let sh = &xshell::Shell::new()?; + install::reset_root(sh, image)?; + + let mut p: PtySession = rexpect::spawn( + format!("/usr/bin/system-reinstall-bootc {image}").as_str(), + Some(TIMEOUT.as_millis().try_into().unwrap()), + )?; + + // Basic flow stdout verification + p.exp_string( + format!("Image {image} is already present locally, skipping pull.").as_str(), + )?; + p.exp_regex("Found only one user ([^:]+) with ([\\d]+) SSH authorized keys.")?; + p.exp_string("Would you like to import its SSH authorized keys")?; + p.exp_string("into the root user on the new bootc system?")?; + p.exp_string("Then you can login as root@ using those keys. [Y/n]")?; + p.send_line("y")?; + + p.exp_string("Going to run command:")?; + + p.exp_regex(format!("podman run --privileged --pid=host --user=root:root -v /var/lib/containers:/var/lib/containers -v /dev:/dev --security-opt label=type:unconfined_t -v /:/target -v /tmp/([^:]+):/bootc_authorized_ssh_keys/root {image} bootc install to-existing-root --acknowledge-destructive --skip-fetch-check --cleanup --root-ssh-authorized-keys /bootc_authorized_ssh_keys/root").as_str())?; + p.exp_string("NOTICE: This will replace the installed operating system and reboot. Are you sure you want to continue? [y/N]")?; + + p.send_line("y")?; + + p.exp_string(format!("Installing image: docker://{image}").as_str())?; + p.exp_string("Initializing ostree layout")?; + p.exp_string("Operation complete, rebooting in 10 seconds. Press Ctrl-C to cancel reboot, or press enter to continue immediately.")?; + p.send_control('c')?; + + p.exp_eof()?; + + install::generic_post_install_verification()?; + + // Check for destructive cleanup and ssh key files + let target_deployment_dir = + get_deployment_dir().with_context(|| "Failed to get deployment directory")?; + + let files = [ + "usr/lib/bootc/fedora-bootc-destructive-cleanup", + "usr/lib/systemd/system/bootc-destructive-cleanup.service", + "etc/tmpfiles.d/bootc-root-ssh.conf", + ]; + + for f in files { + let full_path = target_deployment_dir.join(f); + assert!( + full_path.exists(), + "File not found: {}", + full_path.display() + ); + } + + Ok(()) + }), + Trial::test("disk space check", move || { + let sh = &xshell::Shell::new()?; + install::reset_root(sh, image)?; + + // Allocate a file with the size of the available space on the root partition + let stat = statfs("/")?; + let available_space_bytes: u64 = stat.f_bsize as u64 * stat.f_bavail as u64; + let file_size = available_space_bytes - (250 * 1024 * 1024); //leave 250 MiB free + + let tempfile = tempfile::Builder::new().tempfile_in("/")?; + let tempfile_path = tempfile.path(); + + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(tempfile_path)?; + + rustix::fs::fallocate(&file, rustix::fs::FallocateFlags::empty(), 0, file_size)?; + + // Run system-reinstall-bootc + let mut p: PtySession = rexpect::spawn( + format!("/usr/bin/system-reinstall-bootc {image}").as_str(), + Some(TIMEOUT.as_millis().try_into().unwrap()), + )?; + + p.exp_regex("Found only one user ([^:]+) with ([\\d]+) SSH authorized keys.")?; + p.exp_string("[Y/n]")?; + p.send_line("y")?; + p.exp_string("NOTICE: This will replace the installed operating system and reboot. Are you sure you want to continue? [y/N]")?; + p.send_line("y")?; + p.exp_string("Insufficient free space")?; + p.exp_eof()?; + Ok(()) + }), + Trial::test("image pull check", move || { + let sh = &xshell::Shell::new()?; + install::reset_root(sh, image)?; + + // Run system-reinstall-bootc + let mut p: PtySession = rexpect::spawn( + "/usr/bin/system-reinstall-bootc quay.io/centos-bootc/centos-bootc:stream9", + Some(600000), // Increase timeout for pulling the image + )?; + + p.exp_string("Image quay.io/centos-bootc/centos-bootc:stream9 is not present locally, pulling it now.")?; + p.exp_regex("Found only one user ([^:]+) with ([\\d]+) SSH authorized keys.")?; + p.exp_string("[Y/n]")?; + p.send_line("y")?; + p.exp_string("NOTICE: This will replace the installed operating system and reboot. Are you sure you want to continue? [y/N]")?; + p.send_line("y")?; + p.exp_string("Operation complete, rebooting in 10 seconds. Press Ctrl-C to cancel reboot, or press enter to continue immediately.")?; + p.send_control('c')?; + p.exp_eof()?; + Ok(()) + }), + ]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs new file mode 100644 index 000000000..477bd0d05 --- /dev/null +++ b/crates/tests-integration/src/tests-integration.rs @@ -0,0 +1,76 @@ +//! Integration tests. + +use camino::Utf8PathBuf; +use cap_std_ext::cap_std::{self, fs::Dir}; +use clap::Parser; + +mod composefs_bcvk; +mod container; +mod hostpriv; +mod install; +mod runvm; +mod selinux; +mod system_reinstall; + +#[derive(Debug, Parser)] +#[clap(name = "bootc-integration-tests", version, rename_all = "kebab-case")] +pub(crate) enum Opt { + SystemReinstall { + /// Source container image reference + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + InstallAlongside { + /// Source container image reference + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + HostPrivileged { + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + ComposefsBcvk { + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + /// Tests which should be executed inside an existing bootc container image. + /// These should be nondestructive. + Container { + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + #[clap(subcommand)] + RunVM(runvm::Opt), + /// Extra helper utility to verify SELinux label presence + #[clap(name = "verify-selinux")] + VerifySELinux { + /// Path to target root + rootfs: Utf8PathBuf, + #[clap(long)] + warn: bool, + }, +} + +fn main() { + let opt = Opt::parse(); + let r = match opt { + Opt::SystemReinstall { image, testargs } => system_reinstall::run(&image, testargs), + Opt::InstallAlongside { image, testargs } => install::run_alongside(&image, testargs), + Opt::HostPrivileged { image, testargs } => hostpriv::run_hostpriv(&image, testargs), + Opt::ComposefsBcvk { image, testargs } => composefs_bcvk::run(&image, testargs), + Opt::Container { testargs } => container::run(testargs), + Opt::RunVM(opts) => runvm::run(opts), + Opt::VerifySELinux { rootfs, warn } => { + let root = &Dir::open_ambient_dir(&rootfs, cap_std::ambient_authority()).unwrap(); + selinux::verify_selinux_recurse(root, warn) + } + }; + if let Err(e) = r { + eprintln!("error: {e:?}"); + std::process::exit(1); + } +} diff --git a/crates/tmpfiles/Cargo.toml b/crates/tmpfiles/Cargo.toml new file mode 100644 index 000000000..b450d1d9c --- /dev/null +++ b/crates/tmpfiles/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "bootc-tmpfiles" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[dependencies] +# Internal crates +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +camino = { workspace = true } +cap-std-ext = { workspace = true } +fn-error-context = { workspace = true } +rustix = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +uzers = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +indoc = { workspace = true } +similar-asserts = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tmpfiles/src/lib.rs b/crates/tmpfiles/src/lib.rs new file mode 100644 index 000000000..395e9ddf2 --- /dev/null +++ b/crates/tmpfiles/src/lib.rs @@ -0,0 +1,714 @@ +//! Parse and generate systemd tmpfiles.d entries. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as WriteFmt; +use std::io::{BufRead, BufReader, Write as StdWrite}; +use std::iter::Peekable; +use std::num::NonZeroUsize; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::{Path, PathBuf}; + +use camino::Utf8PathBuf; +use cap_std::fs::MetadataExt; +use cap_std::fs::{Dir, Permissions, PermissionsExt}; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; +use rustix::fs::Mode; +use rustix::path::Arg; +use thiserror::Error; + +const TMPFILESD: &str = "usr/lib/tmpfiles.d"; +const ETC_TMPFILESD: &str = "etc/tmpfiles.d"; +/// The path to the file we use for generation +const BOOTC_GENERATED_PREFIX: &str = "bootc-autogenerated-var"; + +/// The number of times we've generated a tmpfiles.d +#[derive(Debug, Default)] +struct BootcTmpfilesGeneration(u32); + +impl BootcTmpfilesGeneration { + fn increment(&mut self) { + // SAFETY: We shouldn't ever wrap here + self.0 = self.0.checked_add(1).unwrap(); + } + + fn path(&self) -> Utf8PathBuf { + format!("{TMPFILESD}/{BOOTC_GENERATED_PREFIX}-{}.conf", self.0).into() + } +} + +/// An error when translating tmpfiles.d. +#[derive(Debug, Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("I/O (fmt) error")] + Fmt(#[from] std::fmt::Error), + #[error("I/O error on {path}: {err}")] + PathIo { path: PathBuf, err: std::io::Error }, + #[error("User not found for id {0}")] + UserNotFound(uzers::uid_t), + #[error("Group not found for id {0}")] + GroupNotFound(uzers::gid_t), + #[error("Invalid non-UTF8 username: {uid} {name}")] + NonUtf8User { uid: uzers::uid_t, name: String }, + #[error("Invalid non-UTF8 groupname: {gid} {name}")] + NonUtf8Group { gid: uzers::gid_t, name: String }, + #[error("Missing {TMPFILESD}")] + MissingTmpfilesDir {}, + #[error("Found /var/run as a non-symlink")] + FoundVarRunNonSymlink {}, + #[error("Malformed tmpfiles.d")] + MalformedTmpfilesPath, + #[error("Malformed tmpfiles.d line {0}")] + MalformedTmpfilesEntry(String), + #[error("Unsupported regular file for tmpfiles.d {0}")] + UnsupportedRegfile(PathBuf), + #[error("Unsupported file of type {ty:?} for tmpfiles.d {path}")] + UnsupportedFile { + ty: rustix::fs::FileType, + path: PathBuf, + }, +} + +/// The type of Result. +pub type Result = std::result::Result; + +fn escape_path(path: &Path, out: &mut W) -> std::fmt::Result { + let path_bytes = path.as_os_str().as_bytes(); + if path_bytes.is_empty() { + return Err(std::fmt::Error); + } + + if let Ok(s) = path.as_os_str().as_str() { + if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '/') { + return write!(out, "{s}"); + } + } + + for c in path_bytes.iter().copied() { + let is_special = c == b'\\'; + let is_printable = c.is_ascii_alphanumeric() || c.is_ascii_punctuation(); + if is_printable && !is_special { + out.write_char(c as char)?; + } else { + match c { + b'\\' => out.write_str(r"\\")?, + b'\n' => out.write_str(r"\n")?, + b'\t' => out.write_str(r"\t")?, + b'\r' => out.write_str(r"\r")?, + o => write!(out, "\\x{o:02x}")?, + } + } + } + std::fmt::Result::Ok(()) +} + +fn impl_unescape_path_until( + src: &mut Peekable, + buf: &mut Vec, + end_of_record_is_quote: bool, +) -> Result<()> +where + I: Iterator, +{ + let should_take_next = |c: &u8| { + let c = *c; + if end_of_record_is_quote { + c != b'"' + } else { + !c.is_ascii_whitespace() + } + }; + while let Some(c) = src.next_if(should_take_next) { + if c != b'\\' { + buf.push(c); + continue; + }; + let Some(c) = src.next() else { + return Err(Error::MalformedTmpfilesPath); + }; + let c = match c { + b'\\' => b'\\', + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'x' => { + let mut s = String::new(); + s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into()); + s.push(src.next().ok_or(Error::MalformedTmpfilesPath)?.into()); + + u8::from_str_radix(&s, 16).map_err(|_| Error::MalformedTmpfilesPath)? + } + _ => return Err(Error::MalformedTmpfilesPath), + }; + buf.push(c); + } + Ok(()) +} + +fn unescape_path(src: &mut Peekable) -> Result +where + I: Iterator, +{ + let mut r = Vec::new(); + if src.next_if_eq(&b'"').is_some() { + impl_unescape_path_until(src, &mut r, true)?; + } else { + impl_unescape_path_until(src, &mut r, false)?; + }; + let r = OsString::from_vec(r); + Ok(PathBuf::from(r)) +} + +/// Canonicalize and escape a path value for tmpfiles.d +/// At the current time the only canonicalization we do is remap /var/run -> /run. +fn canonicalize_escape_path(path: &Path, out: &mut W) -> std::fmt::Result { + // systemd-tmpfiles complains loudly about writing to /var/run; + // ideally, all of the packages get fixed for this but...eh. + let path = if path.starts_with("/var/run") { + let rest = &path.as_os_str().as_bytes()[4..]; + Path::new(OsStr::from_bytes(rest)) + } else { + path + }; + escape_path(path, out) +} + +/// In tmpfiles.d we only handle directories and symlinks. Directories +/// just have a mode, and symlinks just have a target. +enum FileMeta { + Directory(Mode), + Symlink(PathBuf), +} + +impl FileMeta { + fn from_fs(dir: &Dir, path: &Path) -> Result> { + let meta = dir.symlink_metadata(path)?; + let ftype = meta.file_type(); + let r = if ftype.is_dir() { + FileMeta::Directory(Mode::from_raw_mode(meta.mode())) + } else if ftype.is_symlink() { + let target = dir.read_link_contents(path)?; + FileMeta::Symlink(target) + } else { + return Ok(None); + }; + Ok(Some(r)) + } +} + +/// Translate a filepath entry to an equivalent tmpfiles.d line. +pub(crate) fn translate_to_tmpfiles_d( + abs_path: &Path, + meta: FileMeta, + username: &str, + groupname: &str, +) -> Result { + let mut bufwr = String::new(); + + let filetype_char = match &meta { + FileMeta::Directory(_) => 'd', + FileMeta::Symlink(_) => 'L', + }; + write!(bufwr, "{filetype_char} ")?; + canonicalize_escape_path(abs_path, &mut bufwr)?; + + match meta { + FileMeta::Directory(mode) => { + write!(bufwr, " {mode:04o} {username} {groupname} - -")?; + } + FileMeta::Symlink(target) => { + bufwr.push_str(" - - - - "); + canonicalize_escape_path(&target, &mut bufwr)?; + } + }; + + Ok(bufwr) +} + +/// The result of a tmpfiles.d generation run +#[derive(Debug, Default)] +pub struct TmpfilesWrittenResult { + /// Set if we generated entries; this is the count and the path. + pub generated: Option<(NonZeroUsize, Utf8PathBuf)>, + /// Total number of unsupported files that were skipped + pub unsupported: usize, +} + +/// Translate the content of `/var` underneath the target root to use tmpfiles.d. +pub fn var_to_tmpfiles( + rootfs: &Dir, + users: &U, + groups: &G, +) -> Result { + let (existing_tmpfiles, generation) = read_tmpfiles(rootfs)?; + + // We should never have /var/run as a non-symlink. Don't recurse into it, it's + // a hard error. + if let Some(meta) = rootfs.symlink_metadata_optional("var/run")? { + if !meta.is_symlink() { + return Err(Error::FoundVarRunNonSymlink {}); + } + } + + // Require that the tmpfiles.d directory exists; it's part of systemd. + if !rootfs.try_exists(TMPFILESD)? { + return Err(Error::MissingTmpfilesDir {}); + } + + let mut entries = BTreeSet::new(); + let mut prefix = PathBuf::from("/var"); + let mut unsupported = Vec::new(); + convert_path_to_tmpfiles_d_recurse( + &TmpfilesConvertConfig { + users, + groups, + rootfs, + existing: &existing_tmpfiles, + readonly: false, + }, + &mut entries, + &mut unsupported, + &mut prefix, + )?; + + // If there's no entries, don't write a file + let Some(entries_count) = NonZeroUsize::new(entries.len()) else { + return Ok(TmpfilesWrittenResult::default()); + }; + + let path = generation.path(); + // This should not exist + assert!(!rootfs.try_exists(&path)?); + + rootfs.atomic_replace_with(&path, |bufwr| -> Result<()> { + let mode = Permissions::from_mode(0o644); + bufwr.get_mut().as_file_mut().set_permissions(mode)?; + + for line in entries.iter() { + bufwr.write_all(line.as_bytes())?; + writeln!(bufwr)?; + } + if !unsupported.is_empty() { + let (samples, rest) = bootc_utils::iterator_split(unsupported.iter(), 5); + for elt in samples { + writeln!(bufwr, "# bootc ignored: {elt:?}")?; + } + let rest = rest.count(); + if rest > 0 { + writeln!(bufwr, "# bootc ignored: ...and {rest} more")?; + } + } + Ok(()) + })?; + + Ok(TmpfilesWrittenResult { + generated: Some((entries_count, path)), + unsupported: unsupported.len(), + }) +} + +/// Configuration for recursive tmpfiles conversion +struct TmpfilesConvertConfig<'a, U: uzers::Users, G: uzers::Groups> { + users: &'a U, + groups: &'a G, + rootfs: &'a Dir, + existing: &'a BTreeMap, + readonly: bool, +} + +/// Recursively explore target directory and translate content to tmpfiles.d entries. See +/// `convert_var_to_tmpfiles_d` for more background. +/// +/// This proceeds depth-first and progressively deletes translated subpaths as it goes. +/// `prefix` is updated at each recursive step, so that in case of errors it can be +/// used to pinpoint the faulty path. +fn convert_path_to_tmpfiles_d_recurse( + config: &TmpfilesConvertConfig<'_, U, G>, + out_entries: &mut BTreeSet, + out_unsupported: &mut Vec, + prefix: &mut PathBuf, +) -> Result<()> { + let relpath = prefix.strip_prefix("/").unwrap(); + for subpath in config.rootfs.read_dir(relpath)? { + let subpath = subpath?; + let meta = subpath.metadata()?; + let fname = subpath.file_name(); + prefix.push(fname); + + let has_tmpfiles_entry = config.existing.contains_key(prefix); + + // Translate this file entry. + if !has_tmpfiles_entry { + let entry = { + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + let Some(tmpfiles_meta) = FileMeta::from_fs(config.rootfs, &relpath)? else { + out_unsupported.push(relpath.into()); + assert!(prefix.pop()); + continue; + }; + let uid = meta.uid(); + let gid = meta.gid(); + let user = config + .users + .get_user_by_uid(meta.uid()) + .ok_or(Error::UserNotFound(uid))?; + let username = user.name(); + let username: &str = username.to_str().ok_or_else(|| Error::NonUtf8User { + uid, + name: username.to_string_lossy().into_owned(), + })?; + let group = config + .groups + .get_group_by_gid(gid) + .ok_or(Error::GroupNotFound(gid))?; + let groupname = group.name(); + let groupname: &str = groupname.to_str().ok_or_else(|| Error::NonUtf8Group { + gid, + name: groupname.to_string_lossy().into_owned(), + })?; + translate_to_tmpfiles_d(&prefix, tmpfiles_meta, &username, &groupname)? + }; + out_entries.insert(entry); + } + + if meta.is_dir() { + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + // Avoid traversing mount points by default + if config.rootfs.open_dir_noxdev(relpath)?.is_some() { + convert_path_to_tmpfiles_d_recurse(config, out_entries, out_unsupported, prefix)?; + let relpath = prefix.strip_prefix("/").unwrap(); + if !config.readonly { + config.rootfs.remove_dir_all(relpath)?; + } + } + } else { + // SAFETY: We know this path is absolute + let relpath = prefix.strip_prefix("/").unwrap(); + if !config.readonly { + config.rootfs.remove_file(relpath)?; + } + } + assert!(prefix.pop()); + } + Ok(()) +} + +/// Convert /var for the current root to use systemd tmpfiles.d. +#[allow(unsafe_code)] +pub fn convert_var_to_tmpfiles_current_root() -> Result { + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + + // See the docs for why this is unsafe + let usergroups = unsafe { uzers::cache::UsersSnapshot::new() }; + + var_to_tmpfiles(&rootfs, &usergroups, &usergroups) +} + +/// The result of processing tmpfiles.d +#[derive(Debug)] +pub struct TmpfilesResult { + /// The resulting tmpfiles.d entries + pub tmpfiles: BTreeSet, + /// Paths which could not be processed + pub unsupported: Vec, +} + +/// Convert /var for the current root to use systemd tmpfiles.d. +#[allow(unsafe_code)] +pub fn find_missing_tmpfiles_current_root() -> Result { + use uzers::cache::UsersSnapshot; + + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + + // See the docs for why this is unsafe + let usergroups = unsafe { UsersSnapshot::new() }; + + let existing_tmpfiles = read_tmpfiles(&rootfs)?.0; + + let mut prefix = PathBuf::from("/var"); + let mut tmpfiles = BTreeSet::new(); + let mut unsupported = Vec::new(); + convert_path_to_tmpfiles_d_recurse( + &TmpfilesConvertConfig { + users: &usergroups, + groups: &usergroups, + rootfs: &rootfs, + existing: &existing_tmpfiles, + readonly: true, + }, + &mut tmpfiles, + &mut unsupported, + &mut prefix, + )?; + Ok(TmpfilesResult { + tmpfiles, + unsupported, + }) +} + +/// Read all tmpfiles.d entries from a single directory +fn read_tmpfiles_from_dir( + rootfs: &Dir, + dir_path: &str, + generation: &mut BootcTmpfilesGeneration, +) -> Result> { + let Some(tmpfiles_dir) = rootfs.open_dir_optional(dir_path)? else { + return Ok(Default::default()); + }; + let mut result = BTreeMap::new(); + for entry in tmpfiles_dir.entries()? { + let entry = entry?; + let name = entry.file_name(); + let (Some(stem), Some(extension)) = + (Path::new(&name).file_stem(), Path::new(&name).extension()) + else { + continue; + }; + if extension != "conf" { + continue; + } + if let Ok(s) = stem.as_str() { + if s.starts_with(BOOTC_GENERATED_PREFIX) { + generation.increment(); + } + } + let r = BufReader::new(entry.open()?); + for line in r.lines() { + let line = line?; + if line.is_empty() || line.starts_with("#") { + continue; + } + let path = tmpfiles_entry_get_path(&line)?; + result.insert(path.to_owned(), line); + } + } + Ok(result) +} + +/// Read all tmpfiles.d entries in the target directory, and return a mapping +/// from (file path) => (single tmpfiles.d entry line) +/// +/// This function reads from both `/usr/lib/tmpfiles.d` and `/etc/tmpfiles.d`, +/// with `/etc` entries taking precedence (matching systemd's behavior). +fn read_tmpfiles(rootfs: &Dir) -> Result<(BTreeMap, BootcTmpfilesGeneration)> { + let mut generation = BootcTmpfilesGeneration::default(); + + // Read from /usr/lib/tmpfiles.d first (system/package-provided) + let mut result = read_tmpfiles_from_dir(rootfs, TMPFILESD, &mut generation)?; + + // Read from /etc/tmpfiles.d and merge (user-provided, takes precedence) + let etc_result = read_tmpfiles_from_dir(rootfs, ETC_TMPFILESD, &mut generation)?; + // /etc entries override /usr/lib entries for the same path + result.extend(etc_result); + + Ok((result, generation)) +} + +fn tmpfiles_entry_get_path(line: &str) -> Result { + let err = || Error::MalformedTmpfilesEntry(line.to_string()); + let mut it = line.as_bytes().iter().copied().peekable(); + // Skip leading whitespace + while it.next_if(|c| c.is_ascii_whitespace()).is_some() {} + // Skip the file type + let mut found_ftype = false; + while it.next_if(|c| !c.is_ascii_whitespace()).is_some() { + found_ftype = true + } + if !found_ftype { + return Err(err()); + } + // Skip trailing whitespace + while it.next_if(|c| c.is_ascii_whitespace()).is_some() {} + unescape_path(&mut it) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std::fs::DirBuilder; + use cap_std_ext::cap_std::fs::DirBuilderExt as _; + + #[test] + fn test_tmpfiles_entry_get_path() { + let cases = [ + ("z /dev/kvm 0666 - kvm -", "/dev/kvm"), + ("d /run/lock/lvm 0700 root root -", "/run/lock/lvm"), + ("a+ /var/lib/tpm2-tss/system/keystore - - - - default:group:tss:rwx", "/var/lib/tpm2-tss/system/keystore"), + ("d \"/run/file with spaces/foo\" 0700 root root -", "/run/file with spaces/foo"), + ( + r#"d /spaces\x20\x20here/foo 0700 root root -"#, + "/spaces here/foo", + ), + ]; + for (input, expected) in cases { + let path = tmpfiles_entry_get_path(input).unwrap(); + assert_eq!(path, Path::new(expected), "Input: {input}"); + } + } + + fn newroot() -> Result { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all(TMPFILESD)?; + Ok(root) + } + + fn mock_userdb() -> uzers::mock::MockUsers { + let testuid = rustix::process::getuid(); + let testgid = rustix::process::getgid(); + let mut users = uzers::mock::MockUsers::with_current_uid(testuid.as_raw()); + users.add_user(uzers::User::new( + testuid.as_raw(), + "testuser", + testgid.as_raw(), + )); + users.add_group(uzers::Group::new(testgid.as_raw(), "testgroup")); + users + } + + #[test] + fn test_tmpfiles_d_translation() -> anyhow::Result<()> { + // Prepare a minimal rootfs as playground. + let rootfs = &newroot()?; + let userdb = &mock_userdb(); + + let mut db = DirBuilder::new(); + db.recursive(true); + db.mode(0o755); + + rootfs.write( + Path::new(TMPFILESD).join("systemd.conf"), + indoc::indoc! { r#" + d /var/lib 0755 - - - + d /var/lib/private 0700 root root - + d /var/log/private 0700 root root - + "#}, + )?; + + // Also test /etc/tmpfiles.d (user-provided configs) + rootfs.create_dir_all(ETC_TMPFILESD)?; + rootfs.write( + Path::new(ETC_TMPFILESD).join("user.conf"), + "d /var/lib/user 0755 root root - -\n", + )?; + + // Add test content. + rootfs.ensure_dir_with("var/lib/systemd", &db)?; + rootfs.ensure_dir_with("var/lib/private", &db)?; + rootfs.ensure_dir_with("var/lib/nfs", &db)?; + rootfs.ensure_dir_with("var/lib/user", &db)?; + let global_rwx = Permissions::from_mode(0o777); + rootfs.ensure_dir_with("var/lib/test/nested", &db).unwrap(); + rootfs.set_permissions("var/lib/test", global_rwx.clone())?; + rootfs.set_permissions("var/lib/test/nested", global_rwx)?; + rootfs.symlink("../", "var/lib/test/nested/symlink")?; + rootfs.symlink_contents("/var/lib/foo", "var/lib/test/absolute-symlink")?; + + var_to_tmpfiles(rootfs, userdb, userdb).unwrap(); + + // This is the first run + let mut gen = BootcTmpfilesGeneration(0); + let autovar_path = &gen.path(); + assert!(rootfs.try_exists(autovar_path).unwrap()); + let entries: Vec = rootfs + .read_to_string(autovar_path) + .unwrap() + .lines() + .map(|s| s.to_owned()) + .collect(); + let expected = &[ + "L /var/lib/test/absolute-symlink - - - - /var/lib/foo", + "L /var/lib/test/nested/symlink - - - - ../", + "d /var/lib/nfs 0755 testuser testgroup - -", + "d /var/lib/systemd 0755 testuser testgroup - -", + "d /var/lib/test 0777 testuser testgroup - -", + "d /var/lib/test/nested 0777 testuser testgroup - -", + ]; + similar_asserts::assert_eq!(entries, expected); + assert!(!rootfs.try_exists("var/lib").unwrap()); + + // Now pretend we're doing a layered container build, and so we need + // a new tmpfiles.d run + rootfs.create_dir_all("var/lib/gen2-test")?; + let w = var_to_tmpfiles(rootfs, userdb, userdb).unwrap(); + let wg = w.generated.as_ref().unwrap(); + assert_eq!(wg.0, NonZeroUsize::new(1).unwrap()); + assert_eq!(w.unsupported, 0); + gen.increment(); + let autovar_path = &gen.path(); + assert_eq!(autovar_path, &wg.1); + assert!(rootfs.try_exists(autovar_path).unwrap()); + Ok(()) + } + + /// Verify that we emit ignores for regular files + #[test] + fn test_log_regfile() -> anyhow::Result<()> { + // Prepare a minimal rootfs as playground. + let rootfs = &newroot()?; + let userdb = &mock_userdb(); + + rootfs.create_dir_all("var/log/dnf")?; + rootfs.write("var/log/dnf/dnf.log", b"some dnf log")?; + rootfs.create_dir_all("var/log/foo")?; + rootfs.write("var/log/foo/foo.log", b"some other log")?; + + let gen = BootcTmpfilesGeneration(0); + var_to_tmpfiles(rootfs, userdb, userdb).unwrap(); + let tmpfiles = rootfs.read_to_string(&gen.path()).unwrap(); + let ignored = tmpfiles + .lines() + .filter(|line| line.starts_with("# bootc ignored")) + .count(); + assert_eq!(ignored, 2); + Ok(()) + } + + #[test] + fn test_canonicalize_escape_path() { + let intact_cases = vec!["/", "/var", "/var/foo", "/run/foo"]; + for entry in intact_cases { + let mut s = String::new(); + canonicalize_escape_path(Path::new(entry), &mut s).unwrap(); + similar_asserts::assert_eq!(&s, entry); + } + + let quoting_cases = &[ + ("/var/foo bar", r#"/var/foo\x20bar"#), + ("/var/run", "/run"), + ("/var/run/foo bar", r#"/run/foo\x20bar"#), + ]; + for (input, expected) in quoting_cases { + let mut s = String::new(); + canonicalize_escape_path(Path::new(input), &mut s).unwrap(); + similar_asserts::assert_eq!(&s, expected); + } + } + + #[test] + fn test_translate_to_tmpfiles_d() { + let path = Path::new(r#"/var/foo bar"#); + let username = "testuser"; + let groupname = "testgroup"; + { + // Directory + let meta = FileMeta::Directory(Mode::from_raw_mode(0o721)); + let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap(); + let expected = r#"d /var/foo\x20bar 0721 testuser testgroup - -"#; + similar_asserts::assert_eq!(out, expected); + } + { + // Symlink + let meta = FileMeta::Symlink("/mytarget".into()); + let out = translate_to_tmpfiles_d(path, meta, username, groupname).unwrap(); + let expected = r#"L /var/foo\x20bar - - - - /mytarget"#; + similar_asserts::assert_eq!(out, expected); + } + } +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml new file mode 100644 index 000000000..229bf8d9e --- /dev/null +++ b/crates/utils/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bootc-internal-utils" +description = "Internal implementation component of bootc; do not use" +version = "0.0.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/bootc-dev/bootc" + +[dependencies] +# Workspace dependencies +anstream = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true, features = ["std"] } +owo-colors = { workspace = true } +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +shlex = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["process", "rt", "macros"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-journald = { workspace = true } + +[dev-dependencies] +similar-asserts = { workspace = true } +static_assertions = { workspace = true } + +[lints] +workspace = true diff --git a/crates/utils/src/command.rs b/crates/utils/src/command.rs new file mode 100644 index 000000000..321c10895 --- /dev/null +++ b/crates/utils/src/command.rs @@ -0,0 +1,377 @@ +//! Helpers intended for [`std::process::Command`] and related structures. + +use std::{ + fmt::Write, + io::{Read, Seek}, + os::unix::process::CommandExt, + process::Command, +}; + +use anyhow::{Context, Result}; + +/// Helpers intended for [`std::process::Command`]. +pub trait CommandRunExt { + /// Log (at debug level) the full child commandline. + fn log_debug(&mut self) -> &mut Self; + + /// Execute the child process and wait for it to exit. + /// + /// # Streams + /// + /// - stdin, stdout, stderr: All inherited + /// + /// # Errors + /// + /// An non-successful exit status will result in an error. + fn run_inherited(&mut self) -> Result<()>; + + /// Execute the child process and wait for it to exit. + /// + /// # Streams + /// + /// - stdin, stdout: Inherited + /// - stderr: captured and included in error + /// + /// # Errors + /// + /// An non-successful exit status will result in an error. + fn run_capture_stderr(&mut self) -> Result<()>; + + /// Execute the child process and wait for it to exit; the + /// complete argument list will be included in the error. + /// + /// # Streams + /// + /// - stdin, stdout, stderr: All nherited + /// + /// # Errors + /// + /// An non-successful exit status will result in an error. + fn run_inherited_with_cmd_context(&mut self) -> Result<()>; + + /// Ensure the child does not outlive the parent. + fn lifecycle_bind(&mut self) -> &mut Self; + + /// Execute the child process and capture its output. This uses `run_capture_stderr` internally + /// and will return an error if the child process exits abnormally. + fn run_get_output(&mut self) -> Result>; + + /// Execute the child process and capture its output as a string. + /// This uses `run_capture_stderr` internally. + fn run_get_string(&mut self) -> Result; + + /// Execute the child process, parsing its stdout as JSON. This uses `run_capture_stderr` internally + /// and will return an error if the child process exits abnormally. + fn run_and_parse_json(&mut self) -> Result; + + /// Print the command as it would be typed into a terminal + fn to_string_pretty(&self) -> String; +} + +/// Helpers intended for [`std::process::ExitStatus`]. +pub trait ExitStatusExt { + /// If the exit status signals it was not successful, return an error. + /// Note that we intentionally *don't* include the command string + /// in the output; we leave it to the caller to add that if they want, + /// as it may be verbose. + fn check_status(&mut self) -> Result<()>; + + /// If the exit status signals it was not successful, return an error; + /// this also includes the contents of `stderr`. + /// + /// Otherwise this is the same as [`Self::check_status`]. + fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()>; +} + +/// Parse the last chunk (e.g. 1024 bytes) from the provided file, +/// ensure it's UTF-8, and return that value. This function is infallible; +/// if the file cannot be read for some reason, a copy of a static string +/// is returned. +fn last_utf8_content_from_file(mut f: std::fs::File) -> String { + // u16 since we truncate to just the trailing bytes here + // to avoid pathological error messages + const MAX_STDERR_BYTES: u16 = 1024; + let size = f + .metadata() + .map_err(|e| { + tracing::warn!("failed to fstat: {e}"); + }) + .map(|m| m.len().try_into().unwrap_or(u16::MAX)) + .unwrap_or(0); + let size = size.min(MAX_STDERR_BYTES); + let seek_offset = -(size as i32); + let mut stderr_buf = Vec::with_capacity(size.into()); + // We should never fail to seek()+read() really, but let's be conservative + let r = match f + .seek(std::io::SeekFrom::End(seek_offset.into())) + .and_then(|_| f.read_to_end(&mut stderr_buf)) + { + Ok(_) => String::from_utf8_lossy(&stderr_buf), + Err(e) => { + tracing::warn!("failed seek+read: {e}"); + "".into() + } + }; + (&*r).to_owned() +} + +impl ExitStatusExt for std::process::ExitStatus { + fn check_status(&mut self) -> Result<()> { + if self.success() { + return Ok(()); + } + anyhow::bail!(format!("Subprocess failed: {self:?}")) + } + fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()> { + let stderr_buf = last_utf8_content_from_file(stderr); + if self.success() { + return Ok(()); + } + anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}")) + } +} + +impl CommandRunExt for Command { + fn run_inherited(&mut self) -> Result<()> { + tracing::trace!("exec: {self:?}"); + self.status()?.check_status() + } + + /// Synchronously execute the child, and return an error if the child exited unsuccessfully. + fn run_capture_stderr(&mut self) -> Result<()> { + let stderr = tempfile::tempfile()?; + self.stderr(stderr.try_clone()?); + tracing::trace!("exec: {self:?}"); + self.status()?.check_status_with_stderr(stderr) + } + + #[allow(unsafe_code)] + fn lifecycle_bind(&mut self) -> &mut Self { + // SAFETY: This API is safe to call in a forked child. + unsafe { + self.pre_exec(|| { + rustix::process::set_parent_process_death_signal(Some( + rustix::process::Signal::TERM, + )) + .map_err(Into::into) + }) + } + } + + /// Output a debug-level log message with this command. + fn log_debug(&mut self) -> &mut Self { + // We unconditionally log at trace level, so avoid double logging + if !tracing::enabled!(tracing::Level::TRACE) { + tracing::debug!("exec: {self:?}"); + } + self + } + + fn run_get_output(&mut self) -> Result> { + let mut stdout = tempfile::tempfile()?; + self.stdout(stdout.try_clone()?); + self.run_capture_stderr()?; + stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?; + Ok(Box::new(std::io::BufReader::new(stdout))) + } + + fn run_get_string(&mut self) -> Result { + let mut s = String::new(); + let mut o = self.run_get_output()?; + o.read_to_string(&mut s)?; + Ok(s) + } + + /// Synchronously execute the child, and parse its stdout as JSON. + fn run_and_parse_json(&mut self) -> Result { + let output = self.run_get_output()?; + serde_json::from_reader(output).map_err(Into::into) + } + + fn run_inherited_with_cmd_context(&mut self) -> Result<()> { + self.status()? + .success() + .then_some(()) + // The [`Debug`] output of command contains a properly shell-escaped commandline + // representation that the user can copy paste into their shell + .context(format!("Failed to run command: {self:#?}")) + } + + fn to_string_pretty(&self) -> String { + std::iter::once(self.get_program()) + .chain(self.get_args()) + .fold(String::new(), |mut acc, element| { + if !acc.is_empty() { + acc.push(' '); + } + // SAFETY: Writes to string can't fail + write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap(); + acc + }) + } +} + +/// Helpers intended for [`tokio::process::Command`]. +#[allow(async_fn_in_trait)] +pub trait AsyncCommandRunExt { + /// Asynchronously execute the child, and return an error if the child exited unsuccessfully. + async fn run(&mut self) -> Result<()>; +} + +impl AsyncCommandRunExt for tokio::process::Command { + async fn run(&mut self) -> Result<()> { + let stderr = tempfile::tempfile()?; + self.stderr(stderr.try_clone()?); + self.status().await?.check_status_with_stderr(stderr) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_run_inherited() { + // Test successful command + Command::new("true").run_inherited().unwrap(); + + // Test failed command + assert!(Command::new("false").run_inherited().is_err()); + + // Test that stderr is not captured (just check error format) + let e = Command::new("/bin/sh") + .args(["-c", "echo should-not-be-captured 1>&2; exit 1"]) + .run_inherited() + .err() + .unwrap(); + // Should not contain the stderr message since it's inherited + assert_eq!( + e.to_string(), + "Subprocess failed: ExitStatus(unix_wait_status(256))" + ); + } + + #[test] + fn command_run_capture_stderr() { + // The basics + Command::new("true").run_capture_stderr().unwrap(); + assert!(Command::new("false").run_capture_stderr().is_err()); + + // Verify we capture stderr + let e = Command::new("/bin/sh") + .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"]) + .run_capture_stderr() + .err() + .unwrap(); + similar_asserts::assert_eq!( + e.to_string(), + "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n" + ); + + // Ignoring invalid UTF-8 + let e = Command::new("/bin/sh") + .args([ + "-c", + r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1", + ]) + .run_capture_stderr() + .err() + .unwrap(); + similar_asserts::assert_eq!( + e.to_string(), + "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n" + ); + } + + #[test] + fn exit_status_check_status() { + use std::process::Command; + + // Test successful exit status + let mut success_status = Command::new("true").status().unwrap(); + success_status.check_status().unwrap(); + + // Test failed exit status + let mut fail_status = Command::new("false").status().unwrap(); + let e = fail_status.check_status().err().unwrap(); + assert_eq!( + e.to_string(), + "Subprocess failed: ExitStatus(unix_wait_status(256))" + ); + } + + #[test] + fn exit_status_check_status_with_stderr() { + use std::io::Write; + use std::process::Command; + + // Test successful exit status + let mut success_status = Command::new("true").status().unwrap(); + let temp_stderr = tempfile::tempfile().unwrap(); + success_status + .check_status_with_stderr(temp_stderr) + .unwrap(); + + // Test failed exit status with stderr content + let mut fail_status = Command::new("false").status().unwrap(); + let mut temp_stderr = tempfile::tempfile().unwrap(); + write!(temp_stderr, "test error message").unwrap(); + let e = fail_status + .check_status_with_stderr(temp_stderr) + .err() + .unwrap(); + assert!(e + .to_string() + .contains("Subprocess failed: ExitStatus(unix_wait_status(256))")); + assert!(e.to_string().contains("test error message")); + } + + #[test] + fn command_run_ext_json() { + #[derive(serde::Deserialize)] + struct Foo { + a: String, + b: u32, + } + let v: Foo = Command::new("echo") + .arg(r##"{"a": "somevalue", "b": 42}"##) + .run_and_parse_json() + .unwrap(); + assert_eq!(v.a, "somevalue"); + assert_eq!(v.b, 42); + } + + #[tokio::test] + async fn async_command_run_ext() { + use tokio::process::Command as AsyncCommand; + let mut success = AsyncCommand::new("true"); + let mut fail = AsyncCommand::new("false"); + // Run these in parallel just because we can + let (success, fail) = tokio::join!(success.run(), fail.run(),); + success.unwrap(); + assert!(fail.is_err()); + } + + #[test] + fn to_string_pretty() { + let mut cmd = Command::new("podman"); + cmd.args([ + "run", + "--privileged", + "--pid=host", + "--user=root:root", + "-v", + "/var/lib/containers:/var/lib/containers", + "-v", + "this has spaces", + "label=type:unconfined_t", + "--env=RUST_LOG=trace", + "quay.io/ckyrouac/bootc-dev", + "bootc", + "install", + "to-existing-root", + ]); + + similar_asserts::assert_eq!(cmd.to_string_pretty(), "podman run --privileged --pid=host --user=root:root -v /var/lib/containers:/var/lib/containers -v 'this has spaces' label=type:unconfined_t --env=RUST_LOG=trace quay.io/ckyrouac/bootc-dev bootc install to-existing-root"); + } +} diff --git a/crates/utils/src/iterators.rs b/crates/utils/src/iterators.rs new file mode 100644 index 000000000..ccecee599 --- /dev/null +++ b/crates/utils/src/iterators.rs @@ -0,0 +1,99 @@ +use std::num::NonZeroUsize; + +/// Given an iterator that's cloneable, split it into two iterators +/// at a given maximum number of elements. +pub fn iterator_split( + it: I, + max: usize, +) -> (impl Iterator, impl Iterator) +where + I: Iterator + Clone, +{ + let rest = it.clone(); + (it.take(max), rest.skip(max)) +} + +/// Gather the first N items, and provide the count of the remaining items. +/// The max count cannot be zero as that's a pathological case. +pub fn collect_until(it: I, max: NonZeroUsize) -> Option<(Vec, usize)> +where + I: Iterator, +{ + let mut items = Vec::with_capacity(max.get()); + + let mut it = it.peekable(); + // If there's nothing, just return + let _ = it.peek()?; + + for next in it.by_ref() { + items.push(next); + + // If we've reached max items, stop collecting + if items.len() == max.get() { + break; + } + } + // Count remaining items + let remaining = it.count(); + items.shrink_to_fit(); + Some((items, remaining)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_it_split() { + let a: &[&str] = &[]; + for v in [0, 1, 5] { + let (first, rest) = iterator_split(a.iter(), v); + assert_eq!(first.count(), 0); + assert_eq!(rest.count(), 0); + } + let a = &["foo"]; + for v in [1, 5] { + let (first, rest) = iterator_split(a.iter(), v); + assert_eq!(first.count(), 1); + assert_eq!(rest.count(), 0); + } + let (first, rest) = iterator_split(a.iter(), 1); + assert_eq!(first.count(), 1); + assert_eq!(rest.count(), 0); + let a = &["foo", "bar", "baz", "blah", "other"]; + let (first, rest) = iterator_split(a.iter(), 2); + assert_eq!(first.count(), 2); + assert_eq!(rest.count(), 3); + } + + #[test] + fn test_split_empty_iterator() { + let a: &[&str] = &[]; + for v in [1, 5].into_iter().map(|v| NonZeroUsize::new(v).unwrap()) { + assert!(collect_until(a.iter(), v).is_none()); + } + } + + #[test] + fn test_split_nonempty_iterator() { + let a = &["foo"]; + + let Some((elts, 0)) = collect_until(a.iter(), NonZeroUsize::new(1).unwrap()) else { + panic!() + }; + assert_eq!(elts.len(), 1); + + let Some((elts, 0)) = collect_until(a.iter(), const { NonZeroUsize::new(5).unwrap() }) + else { + panic!() + }; + assert_eq!(elts.len(), 1); + + let a = &["foo", "bar", "baz", "blah", "other"]; + let Some((elts, 3)) = collect_until(a.iter(), const { NonZeroUsize::new(2).unwrap() }) + else { + panic!() + }; + assert_eq!(elts.len(), 2); + } +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs new file mode 100644 index 000000000..bd9948daa --- /dev/null +++ b/crates/utils/src/lib.rs @@ -0,0 +1,39 @@ +//! The inevitable catchall "utils" crate. Generally only add +//! things here that only depend on the standard library and +//! "core" crates. +//! +mod command; +pub use command::*; +mod path; +pub use path::*; +mod iterators; +pub use iterators::*; +mod timestamp; +pub use timestamp::*; +mod tracing_util; +pub use tracing_util::*; +/// Re-execute the current process +pub mod reexec; +mod result_ext; +pub use result_ext::*; + +/// The name of our binary +pub const NAME: &str = "bootc"; + +/// Intended for use in `main`, calls an inner function and +/// handles errors by printing them. +pub fn run_main(f: F) +where + F: FnOnce() -> anyhow::Result<()>, +{ + use std::io::Write as _; + + use owo_colors::OwoColorize; + + if let Err(e) = f() { + let mut stderr = anstream::stderr(); + // Don't panic if writing fails. + let _ = writeln!(stderr, "{}{:#}", "error: ".red(), e); + std::process::exit(1); + } +} diff --git a/crates/utils/src/path.rs b/crates/utils/src/path.rs new file mode 100644 index 000000000..5409fecd9 --- /dev/null +++ b/crates/utils/src/path.rs @@ -0,0 +1,106 @@ +use std::fmt::Display; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +/// Helper to format a path. +#[derive(Debug)] +pub struct PathQuotedDisplay<'a> { + path: &'a Path, +} + +/// A pretty conservative check for "shell safe" characters. These +/// are basically ones which are very common in filenames or command line +/// arguments, which are the primary use case for this. There are definitely +/// characters such as '+' which are typically safe, but it's fine if +/// we're overly conservative. +/// +/// For bash for example: https://www.gnu.org/software/bash/manual/html_node/Definitions.html#index-metacharacter +fn is_shellsafe(c: char) -> bool { + matches!(c, '/' | '.' | '-' | '_' | ',' | '=' | ':') || c.is_alphanumeric() +} + +impl<'a> Display for PathQuotedDisplay<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(s) = self.path.to_str() { + if s.chars().all(is_shellsafe) { + return f.write_str(s); + } + } + if let Ok(r) = shlex::bytes::try_quote(self.path.as_os_str().as_bytes()) { + let s = String::from_utf8_lossy(&r); + return f.write_str(&s); + } + // Should not happen really + Err(std::fmt::Error) + } +} + +impl<'a> PathQuotedDisplay<'a> { + /// Given a path, quote it in a way that it would be parsed by a default + /// POSIX shell. If the path is UTF-8 with no spaces or shell meta-characters, + /// it will be exactly the same as the input. + pub fn new>(path: &'a P) -> PathQuotedDisplay<'a> { + PathQuotedDisplay { + path: path.as_ref(), + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + + use super::*; + + #[test] + fn test_unquoted() { + for v in [ + "", + "foo", + "/foo/bar", + "/foo/bar/../baz", + "/foo9/bar10", + "--foo", + "--virtiofs=/foo,/bar", + "/foo:/bar", + "--label=type=unconfined_t", + ] { + assert_eq!(v, format!("{}", PathQuotedDisplay::new(&v))); + } + } + + #[test] + fn test_bash_metachars() { + // https://www.gnu.org/software/bash/manual/html_node/Definitions.html#index-metacharacter + let bash_metachars = "|&;()<>"; + for c in bash_metachars.chars() { + assert!(!is_shellsafe(c)); + } + } + + #[test] + fn test_quoted() { + let cases = [ + (" ", "' '"), + ("/some/path with spaces/", "'/some/path with spaces/'"), + ("/foo/!/bar&", "'/foo/!/bar&'"), + (r#"/path/"withquotes'"#, r#""/path/\"withquotes'""#), + ]; + for (v, quoted) in cases { + let q = PathQuotedDisplay::new(&v).to_string(); + assert_eq!(quoted, q.as_str()); + // Also sanity check there's exactly one token + let token = shlex::split(&q).unwrap(); + assert_eq!(1, token.len()); + assert_eq!(v, token[0]); + } + } + + #[test] + fn test_nonutf8() { + let p = Path::new(OsStr::from_bytes(b"/foo/somenonutf8\xEE/bar")); + assert!(p.to_str().is_none()); + let q = PathQuotedDisplay::new(&p).to_string(); + assert_eq!(q, r#"'/foo/somenonutf8�/bar'"#); + } +} diff --git a/crates/utils/src/reexec.rs b/crates/utils/src/reexec.rs new file mode 100644 index 000000000..7dd6e5941 --- /dev/null +++ b/crates/utils/src/reexec.rs @@ -0,0 +1,42 @@ +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Result; + +/// Environment variable holding a reference to our original binary +pub const ORIG: &str = "_BOOTC_ORIG_EXE"; + +/// Return the path to our own executable. In some cases (SELinux) we may have +/// performed a re-exec with a temporary copy of the binary and +/// this environment variable will hold the path to the original binary. +pub fn executable_path() -> Result { + if let Some(p) = std::env::var_os(ORIG) { + Ok(p.into()) + } else { + std::env::current_exe().map_err(Into::into) + } +} + +/// Re-execute the current process if the provided environment variable is not set. +pub fn reexec_with_guardenv(k: &str, prefix_args: &[&str]) -> Result<()> { + if std::env::var_os(k).is_some() { + tracing::trace!("Skipping re-exec due to env var {k}"); + return Ok(()); + } + let self_exe = executable_path()?; + let mut prefix_args = prefix_args.iter(); + let mut cmd = if let Some(p) = prefix_args.next() { + let mut c = Command::new(p); + c.args(prefix_args); + c.arg(self_exe); + c + } else { + Command::new(self_exe) + }; + cmd.env(k, "1"); + cmd.args(std::env::args_os().skip(1)); + cmd.arg0(crate::NAME); + tracing::debug!("Re-executing current process for {k}"); + Err(cmd.exec().into()) +} diff --git a/crates/utils/src/result_ext.rs b/crates/utils/src/result_ext.rs new file mode 100644 index 000000000..998b6b039 --- /dev/null +++ b/crates/utils/src/result_ext.rs @@ -0,0 +1,35 @@ +/// Extension trait for Result types that provides logging capabilities +pub trait ResultExt { + /// Return the Ok value unchanged. In the err case, log it, and call the closure to compute the default + fn log_err_or_else(self, default: F) -> T + where + F: FnOnce() -> T; + /// Return the Ok value unchanged. In the err case, log it, and return the default value + fn log_err_default(self) -> T + where + T: Default; +} + +impl ResultExt for Result { + #[track_caller] + fn log_err_or_else(self, default: F) -> T + where + F: FnOnce() -> T, + { + match self { + Ok(r) => r, + Err(e) => { + tracing::debug!("{e}"); + default() + } + } + } + + #[track_caller] + fn log_err_default(self) -> T + where + T: Default, + { + self.log_err_or_else(|| Default::default()) + } +} diff --git a/crates/utils/src/timestamp.rs b/crates/utils/src/timestamp.rs new file mode 100644 index 000000000..8ec8f2e19 --- /dev/null +++ b/crates/utils/src/timestamp.rs @@ -0,0 +1,12 @@ +use anyhow::Context; + +/// Try to parse an RFC 3339, warn on error. +pub fn try_deserialize_timestamp(t: &str) -> Option> { + match chrono::DateTime::parse_from_rfc3339(t).context("Parsing timestamp") { + Ok(t) => Some(t.into()), + Err(e) => { + tracing::warn!("Invalid timestamp in image: {:#}", e); + None + } + } +} diff --git a/crates/utils/src/tracing_util.rs b/crates/utils/src/tracing_util.rs new file mode 100644 index 000000000..0f9f4ed94 --- /dev/null +++ b/crates/utils/src/tracing_util.rs @@ -0,0 +1,41 @@ +//! Helpers related to tracing, used by main entrypoints + +use tracing_subscriber::prelude::*; + +/// Initialize tracing with the default configuration. +pub fn initialize_tracing() { + // Always try to use journald subscriber if we're running as root; + // This ensures key messages (info, warn, error) go to the journal + let journald_layer = if rustix::process::getuid().is_root() { + tracing_journald::layer() + .ok() + .map(|layer| layer.with_filter(tracing_subscriber::filter::LevelFilter::INFO)) + } else { + None + }; + + // Always add the stdout/stderr layer for RUST_LOG support + // This preserves the existing workflow for users + let format = tracing_subscriber::fmt::format() + .without_time() + .with_target(false) + .compact(); + + let fmt_layer = tracing_subscriber::fmt::layer() + .event_format(format) + .with_writer(std::io::stderr) + .with_filter(tracing_subscriber::EnvFilter::from_default_env()); + + // Build the registry with layers, handling the journald layer conditionally + match journald_layer { + Some(journald) => { + tracing_subscriber::registry() + .with(fmt_layer) + .with(journald) + .init(); + } + None => { + tracing_subscriber::registry().with(fmt_layer).init(); + } + } +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 000000000..73ec3a486 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,36 @@ +# See https://github.com/matklad/cargo-xtask +# This is an implementation detail of bootc +[package] +name = "xtask" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[[bin]] +name = "xtask" +path = "src/xtask.rs" + +[dependencies] +# Workspace dependencies +anyhow = { workspace = true } +anstream = { workspace = true } +camino = { workspace = true } +chrono = { workspace = true, features = ["std"] } +clap = { workspace = true, features = ["derive"] } +fn-error-context = { workspace = true } +owo-colors = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } +xshell = { workspace = true } + +# Crate-specific dependencies +mandown = "1.1.0" +rand = "0.9" +serde_yaml = "0.9" +tar = "0.4" + +[lints] +workspace = true diff --git a/crates/xtask/src/man.rs b/crates/xtask/src/man.rs new file mode 100644 index 000000000..ab1bcfc17 --- /dev/null +++ b/crates/xtask/src/man.rs @@ -0,0 +1,643 @@ +//! Man page generation and synchronization +//! +//! This module handles both the generation of man pages from markdown sources +//! and the synchronization of CLI options from Rust code to those markdown templates. + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use fn_error_context::context; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Write}; +use xshell::{cmd, Shell}; + +/// Represents a CLI option extracted from the JSON dump +#[derive(Debug, Serialize, Deserialize)] +pub struct CliOption { + /// The long flag (e.g., "wipe", "block-setup") + pub long: String, + /// The short flag if any (e.g., "h") + pub short: Option, + /// The value name if the option takes an argument + pub value_name: Option, + /// The default value if any + pub default: Option, + /// The help text (doc comment from Rust) + pub help: String, + /// Possible values for enums + pub possible_values: Vec, + /// Whether the option is required + pub required: bool, + /// Whether this is a boolean flag + pub is_boolean: bool, +} + +/// Represents a CLI command from the JSON dump +#[derive(Debug, Serialize, Deserialize)] +pub struct CliCommand { + pub name: String, + pub about: Option, + pub options: Vec, + pub positionals: Vec, + pub subcommands: Vec, +} + +/// Represents a positional argument +#[derive(Debug, Serialize, Deserialize)] +pub struct CliPositional { + pub name: String, + pub help: Option, + pub required: bool, + pub multiple: bool, +} + +/// Extract CLI structure by running the JSON dump command +#[context("Extracting CLI")] +pub fn extract_cli_json(sh: &Shell) -> Result { + // If we have a release binary, assume that we should compile + // in release mode as hopefully we'll have incremental compilation + // enabled. + let releasebin = Utf8Path::new("target/release/bootc"); + let release = releasebin + .try_exists() + .context("Querying release bin")? + .then_some("--release"); + let json_output = cmd!( + sh, + "cargo run {release...} --features=docgen -- internals dump-cli-json" + ) + .read() + .context("Running CLI JSON dump command")?; + + let cli_structure: CliCommand = + serde_json::from_str(&json_output).context("Parsing CLI JSON output")?; + + Ok(cli_structure) +} + +/// Find a subcommand by path +pub fn find_subcommand<'a>(cli: &'a CliCommand, path: &[&str]) -> Option<&'a CliCommand> { + if path.is_empty() { + return Some(cli); + } + + let first = path[0]; + let rest = &path[1..]; + + cli.subcommands + .iter() + .find(|cmd| cmd.name == first) + .and_then(|cmd| find_subcommand(cmd, rest)) +} + +/// Convert CLI subcommands to markdown table format (like podman) +fn format_subcommands_as_table(subcommands: &[CliCommand], parent_path: &[&str]) -> String { + if subcommands.is_empty() { + return String::new(); + } + + let mut result = String::new(); + + // Table header + result.push_str("| Command | Description |\n"); + result.push_str("|---------|-------------|\n"); + + // Table rows + for subcmd in subcommands { + let mut full_path = vec!["bootc"]; + full_path.extend_from_slice(parent_path); + full_path.push(&subcmd.name); + + let cmd_name = format!("**{}**", full_path.join(" ")); + let description = subcmd.about.as_deref().unwrap_or("").trim_end_matches('.'); + result.push_str(&format!("| {} | {} |\n", cmd_name, description)); + } + + result.push('\n'); + result +} + +/// Convert CLI options to markdown format +fn format_options_as_markdown(options: &[CliOption], positionals: &[CliPositional]) -> String { + let mut result = String::new(); + + // Format positional arguments first + for pos in positionals { + let name = pos.name.to_uppercase(); + result.push_str(&format!("**{}**\n\n", name)); + + if let Some(help) = &pos.help { + result.push_str(&format!(" {}\n\n", help)); + } + + if pos.required { + result.push_str(" This argument is required.\n\n"); + } + } + + // Format options + for opt in options { + let mut flag_line = String::new(); + + // Add short flag if available + if let Some(short) = &opt.short { + flag_line.push_str(&format!("**-{}**", short)); + flag_line.push_str(", "); + } + + // Add long flag + flag_line.push_str(&format!("**--{}**", opt.long)); + + // Add value name if option takes argument (but not for boolean flags) + // Boolean flags are detected by having no value_name (set to None in cli_json.rs) + if let Some(value_name) = &opt.value_name { + flag_line.push_str(&format!("=*{}*", value_name)); + } + + result.push_str(&format!("{}\n\n", flag_line)); + result.push_str(&format!(" {}\n\n", opt.help)); + + // Add possible values for enums (but not for boolean flags) + if !opt.possible_values.is_empty() && !opt.is_boolean { + result.push_str(" Possible values:\n"); + for value in &opt.possible_values { + result.push_str(&format!(" - {}\n", value)); + } + result.push('\n'); + } + + // Add default value if present + if let Some(default) = &opt.default { + result.push_str(&format!(" Default: {}\n\n", default)); + } + } + + result +} + +/// Update markdown file with generated subcommands +pub fn update_markdown_with_subcommands( + markdown_path: &Utf8Path, + subcommands: &[CliCommand], + parent_path: &[&str], +) -> Result<()> { + let content = + fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?; + + let begin_marker = ""; + let end_marker = ""; + + let Some((before, rest)) = content.split_once(begin_marker) else { + return Ok(()); // Skip files without markers + }; + + let Some((_, after)) = rest.split_once(end_marker) else { + anyhow::bail!( + "Found BEGIN SUBCOMMANDS marker but not END marker in {}", + markdown_path + ); + }; + + let generated_subcommands = format_subcommands_as_table(subcommands, parent_path); + + // Trim trailing whitespace from before section and ensure exactly one blank line + let before = before.trim_end(); + + let new_content = format!( + "{}\n\n{}\n{}{}{}", + before, begin_marker, generated_subcommands, end_marker, after + ); + + // Only write if content has changed to avoid updating mtime unnecessarily + if new_content != content { + fs::write(markdown_path, new_content) + .with_context(|| format!("Writing to {}", markdown_path))?; + println!("Updated subcommands in {}", markdown_path); + } + Ok(()) +} + +/// Update markdown file with generated options +pub fn update_markdown_with_options( + markdown_path: &Utf8Path, + options: &[CliOption], + positionals: &[CliPositional], +) -> Result<()> { + let content = + fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?; + + let begin_marker = ""; + let end_marker = ""; + + let Some((before, rest)) = content.split_once(begin_marker) else { + return Ok(()); // Skip files without markers + }; + + let Some((_, after)) = rest.split_once(end_marker) else { + anyhow::bail!("Found BEGIN marker but not END marker in {}", markdown_path); + }; + + let generated_options = format_options_as_markdown(options, positionals); + + // Trim trailing whitespace from before section + let mut before = before.trim_end(); + + // Remove # OPTIONS header if it's right before the marker + if before.ends_with("# OPTIONS") { + before = before.strip_suffix("# OPTIONS").unwrap().trim_end(); + } + + // Only add OPTIONS header if there are options or positionals + let new_content = if !options.is_empty() || !positionals.is_empty() { + format!( + "{}\n\n# OPTIONS\n\n{}\n{}{}{}", + before, begin_marker, generated_options, end_marker, after + ) + } else { + format!("{}\n\n{}\n{}{}", before, begin_marker, end_marker, after) + }; + + // Only write if content has changed to avoid updating mtime unnecessarily + if new_content != content { + fs::write(markdown_path, new_content) + .with_context(|| format!("Writing to {}", markdown_path))?; + println!("Updated {}", markdown_path); + } + Ok(()) +} + +/// Discover man page files and infer their command paths from filenames +#[context("Querying man page mappings")] +fn discover_man_page_mappings( + cli_structure: &CliCommand, +) -> Result>)>> { + let man_dir = Utf8Path::new("docs/src/man"); + let mut mappings = Vec::new(); + + // Read all .md files in the man directory + for entry in fs::read_dir(man_dir).context("Reading docs/src/man")? { + let entry = entry?; + let path = entry.path(); + + if let Some(extension) = path.extension() { + if extension != "md" { + continue; + } + } else { + continue; + } + + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?; + + // Check if the file contains generation markers + let content = fs::read_to_string(&path).with_context(|| format!("Reading {path:?}"))?; + if !content.contains("") + && !content.contains("") + { + continue; + } + + // Infer command path from filename by matching against CLI structure + let command_path = if let Some(cmd_part) = filename + .strip_prefix("bootc-") + .and_then(|s| s.strip_suffix(".md")) + .and_then(|s| s.rsplit_once('.').map(|(name, _section)| name)) + { + let path = find_command_path_for_filename(cli_structure, cmd_part); + path + } else { + None + }; + + mappings.push((filename.to_string(), command_path)); + } + + Ok(mappings) +} + +/// Find the command path for a filename by searching the CLI structure +fn find_command_path_for_filename( + cli_structure: &CliCommand, + filename_part: &str, +) -> Option> { + // First, try to match top-level commands + if let Some(subcommand) = cli_structure + .subcommands + .iter() + .find(|cmd| cmd.name == filename_part) + { + return Some(vec![subcommand.name.clone()]); + } + + // Then, try to match subcommands with pattern COMMAND-SUBCOMMAND + for subcommand in &cli_structure.subcommands { + for sub_subcommand in &subcommand.subcommands { + let expected_pattern = format!("{}-{}", subcommand.name, sub_subcommand.name); + if expected_pattern == filename_part { + return Some(vec![subcommand.name.clone(), sub_subcommand.name.clone()]); + } + } + } + + None +} + +/// Sync all man pages with their corresponding CLI commands +#[context("Syncing man pages")] +pub fn sync_all_man_pages(sh: &Shell) -> Result<()> { + let cli_structure = extract_cli_json(sh)?; + + // Discover man page files automatically + let mappings = discover_man_page_mappings(&cli_structure)?; + + for (filename, subcommand_path) in mappings { + let markdown_path = Utf8Path::new("docs/src/man").join(&filename); + + if !markdown_path.exists() { + continue; + } + + // Navigate to the right subcommand + let target_cmd = if let Some(ref path) = subcommand_path { + let path_refs: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + find_subcommand(&cli_structure, &path_refs) + .ok_or_else(|| anyhow::anyhow!("Subcommand {:?} not found", path))? + } else { + &cli_structure + }; + + // Update options if the file has options markers + let content = fs::read_to_string(&markdown_path)?; + if content.contains("") { + update_markdown_with_options( + &markdown_path, + &target_cmd.options, + &target_cmd.positionals, + )?; + } + + // Update subcommands if the file has subcommands markers + if content.contains("") { + let parent_path: Vec<&str> = if let Some(path) = &subcommand_path { + path.iter().map(|s| s.as_str()).collect() + } else { + vec![] + }; + update_markdown_with_subcommands( + &markdown_path, + &target_cmd.subcommands, + &parent_path, + )?; + } + } + + Ok(()) +} + +/// Generate man pages from hand-written markdown sources +#[context("Generating manpages")] +pub fn generate_man_pages(sh: &Shell) -> Result<()> { + let man_src_dir = Utf8Path::new("docs/src/man"); + let man_output_dir = Utf8Path::new("target/man"); + + // Ensure output directory exists + sh.create_dir(man_output_dir) + .with_context(|| format!("Creating {man_output_dir}"))?; + + // First, sync the markdown files with current CLI options + sync_all_man_pages(sh)?; + + // Get version for replacement during generation + let version = get_package_version()?; + + // Convert each markdown file to man page format + for entry in fs::read_dir(man_src_dir).context("Reading manpages")? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?; + + // Parse section from filename (e.g., bootc.8, bootc-config.5) + // All man page files must have a section number + let (base_name, section) = filename + .rsplit_once('.') + .and_then(|(name, section_str)| { + section_str.parse::().ok().map(|section| (name, section)) + }) + .ok_or_else(|| anyhow::anyhow!("Man page filename must include section number (e.g., bootc.8.md, bootc-config.5.md): {}.md", filename))?; + + let output_file = man_output_dir.join(format!("{}.{}", base_name, section)); + + // Read markdown content and replace version placeholders + let content = fs::read_to_string(&path).with_context(|| format!("Reading {path:?}"))?; + let content_with_version = content.replace("", &version); + + // Check if we need to regenerate by comparing input and output modification times + let should_regenerate = if let (Ok(input_meta), Ok(output_meta)) = + (fs::metadata(&path), fs::metadata(&output_file)) + { + input_meta.modified().unwrap_or(std::time::UNIX_EPOCH) + > output_meta.modified().unwrap_or(std::time::UNIX_EPOCH) + } else { + // If output doesn't exist or we can't get metadata, regenerate + true + }; + + if should_regenerate { + // Create temporary file with version-replaced content + let mut tmpf = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + tmpf.write_all(content_with_version.as_bytes())?; + let tmpf = tmpf.path(); + + cmd!(sh, "go-md2man -in {tmpf} -out {output_file}") + .run() + .with_context(|| format!("Converting {} to man page", path.display()))?; + + println!("Generated {}", output_file); + } + } + + // Apply post-processing fixes for apostrophe handling + apply_man_page_fixes(sh, man_output_dir)?; + + Ok(()) +} + +/// Get version from Cargo.toml +#[context("Querying package version")] +fn get_package_version() -> Result { + let cargo_toml = + fs::read_to_string("crates/lib/Cargo.toml").context("Reading crates/lib/Cargo.toml")?; + + let parsed: toml::Table = cargo_toml.parse().context("Parsing Cargo.toml")?; + + let version = parsed + .get("package") + .and_then(|p| p.as_table()) + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Could not find package.version in Cargo.toml"))?; + + Ok(format!("v{}", version)) +} + +/// Single command to update all man pages - auto-discover new commands and sync existing ones +pub fn update_manpages(sh: &Shell) -> Result<()> { + println!("Discovering CLI structure..."); + let cli_structure = extract_cli_json(sh)?; + + println!("Checking for missing man pages..."); + let mut created_count = 0; + + // Auto-discover commands that need man pages + let mut commands_to_check = Vec::new(); + + // Add top-level commands + for cmd in &cli_structure.subcommands { + commands_to_check.push(vec![cmd.name.clone()]); + } + + // Add subcommands + for cmd in &cli_structure.subcommands { + for subcmd in &cmd.subcommands { + commands_to_check.push(vec![cmd.name.clone(), subcmd.name.clone()]); + } + } + + // Check each command and create man page if missing + for command_parts in commands_to_check { + let filename = if command_parts.len() == 1 { + format!("bootc-{}.8.md", command_parts[0]) + } else { + format!("bootc-{}.8.md", command_parts.join("-")) + }; + + let filepath = format!("docs/src/man/{}", filename); + + if !std::path::Path::new(&filepath).exists() { + // Find the command in CLI structure + let command_parts_refs: Vec<&str> = command_parts.iter().map(|s| s.as_str()).collect(); + let target_cmd = find_subcommand(&cli_structure, &command_parts_refs); + + if let Some(cmd) = target_cmd { + let command_name_full = format!("bootc {}", command_parts.join(" ")); + let command_description = cmd.about.as_deref().unwrap_or("TODO: Add description"); + + // Generate SYNOPSIS line with proper arguments + let mut synopsis = format!("**{}** \\[*OPTIONS...*\\]", command_name_full); + + // Add positional arguments + for positional in &cmd.positionals { + if positional.required { + synopsis.push_str(&format!(" <*{}*>", positional.name.to_uppercase())); + } else { + synopsis.push_str(&format!(" \\[*{}*\\]", positional.name.to_uppercase())); + } + } + + // Add subcommand if this command has subcommands + if !cmd.subcommands.is_empty() { + synopsis.push_str(" <*SUBCOMMAND*>"); + } + + let template = format!( + r#"# NAME + +{} - {} + +# SYNOPSIS + +{} + +# DESCRIPTION + +{} + + + + + +# EXAMPLES + +TODO: Add practical examples showing how to use this command. + +# SEE ALSO + +**bootc**(8) + +# VERSION + + +"#, + command_name_full.replace(" ", "-"), + command_description, + command_name_full, + command_description + ); + + std::fs::write(&filepath, template) + .with_context(|| format!("Writing template to {}", filepath))?; + + println!("Created man page template: {}", filepath); + created_count += 1; + } + } + } + + if created_count > 0 { + println!("Created {} new man page templates", created_count); + } else { + println!("All commands already have man pages"); + } + + println!("Syncing OPTIONS sections..."); + sync_all_man_pages(sh)?; + + println!("Man pages updated."); + println!(""); + println!("Next steps for new templates:"); + println!(" - Edit the templates to add detailed descriptions and examples"); + println!(" - Run 'cargo xtask manpages' to generate final man pages"); + + Ok(()) +} + +/// Apply post-processing fixes to generated man pages +#[context("Fixing man pages")] +fn apply_man_page_fixes(sh: &Shell, dir: &Utf8Path) -> Result<()> { + // Fix apostrophe rendering issue + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path + .extension() + .and_then(|s| s.to_str()) + .map_or(false, |e| e.chars().all(|c| c.is_numeric())) + { + // Check if the file already has the fix applied + let content = fs::read_to_string(&path).with_context(|| format!("Reading {path:?}"))?; + if content.starts_with(".ds Aq \\(aq\n") { + // Already fixed, skip + continue; + } + + // Apply the same sed fixes as before + let groffsub = r"1i .ds Aq \\(aq"; + let dropif = r"/\.g \.ds Aq/d"; + let dropelse = r"/.el .ds Aq '/d"; + cmd!(sh, "sed -i -e {groffsub} -e {dropif} -e {dropelse} {path}").run()?; + } + } + + Ok(()) +} diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs new file mode 100644 index 000000000..0d148ccd1 --- /dev/null +++ b/crates/xtask/src/tmt.rs @@ -0,0 +1,934 @@ +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use fn_error_context::context; +use rand::Rng; +use xshell::{cmd, Shell}; + +// Generation markers for integration.fmf +const PLAN_MARKER_BEGIN: &str = "# BEGIN GENERATED PLANS\n"; +const PLAN_MARKER_END: &str = "# END GENERATED PLANS\n"; + +// VM and SSH connectivity timeouts for bcvk integration +// Cloud-init can take 2-3 minutes to start SSH +const VM_READY_TIMEOUT_SECS: u64 = 60; +const SSH_CONNECTIVITY_MAX_ATTEMPTS: u32 = 60; +const SSH_CONNECTIVITY_RETRY_DELAY_SECS: u64 = 3; + +const COMMON_INST_ARGS: &[&str] = &[ + // TODO: Pass down the Secure Boot keys for tests if present + "--firmware=uefi-insecure", + "--label=bootc.test=1", + "--bind-storage-ro", +]; + +// Import the argument types from xtask.rs +use crate::{RunTmtArgs, TmtProvisionArgs}; + +/// Generate a random alphanumeric suffix for VM names +fn generate_random_suffix() -> String { + let mut rng = rand::rng(); + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + (0..8) + .map(|_| { + let idx = rng.random_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +/// Sanitize a plan name for use in a VM name +/// Replaces non-alphanumeric characters (except - and _) with dashes +/// Returns "plan" if the result would be empty +fn sanitize_plan_name(plan: &str) -> String { + let sanitized = plan + .replace('/', "-") + .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-") + .trim_matches('-') + .to_string(); + + if sanitized.is_empty() { + "plan".to_string() + } else { + sanitized + } +} + +/// Check that required dependencies are available +#[context("Checking dependencies")] +fn check_dependencies(sh: &Shell) -> Result<()> { + for tool in ["bcvk", "tmt", "rsync"] { + cmd!(sh, "which {tool}") + .ignore_stdout() + .run() + .with_context(|| format!("{} is not available in PATH", tool))?; + } + Ok(()) +} + +/// Wait for a bcvk VM to be ready and return SSH connection info +#[context("Waiting for VM to be ready")] +fn wait_for_vm_ready(sh: &Shell, vm_name: &str) -> Result<(u16, String)> { + use std::thread; + use std::time::Duration; + + for attempt in 1..=VM_READY_TIMEOUT_SECS { + if let Ok(json_output) = cmd!(sh, "bcvk libvirt inspect {vm_name} --format=json") + .ignore_stderr() + .read() + { + if let Ok(json) = serde_json::from_str::(&json_output) { + if let (Some(ssh_port), Some(ssh_key)) = ( + json.get("ssh_port").and_then(|v| v.as_u64()), + json.get("ssh_private_key").and_then(|v| v.as_str()), + ) { + let ssh_port = ssh_port as u16; + return Ok((ssh_port, ssh_key.to_string())); + } + } + } + + if attempt < VM_READY_TIMEOUT_SECS { + thread::sleep(Duration::from_secs(1)); + } + } + + anyhow::bail!( + "VM {} did not become ready within {} seconds", + vm_name, + VM_READY_TIMEOUT_SECS + ) +} + +/// Verify SSH connectivity to the VM +/// Uses a more complex command similar to what TMT runs to ensure full readiness +#[context("Verifying SSH connectivity")] +fn verify_ssh_connectivity(sh: &Shell, port: u16, key_path: &Utf8Path) -> Result<()> { + use std::thread; + use std::time::Duration; + + let port_str = port.to_string(); + for attempt in 1..=SSH_CONNECTIVITY_MAX_ATTEMPTS { + // Test with a complex command like TMT uses (exports + whoami) + // Use IdentitiesOnly=yes to prevent ssh-agent from offering other keys + let result = cmd!( + sh, + "ssh -i {key_path} -p {port_str} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o IdentitiesOnly=yes root@localhost 'export TEST=value; whoami'" + ) + .ignore_stderr() + .read(); + + match &result { + Ok(output) if output.trim() == "root" => { + return Ok(()); + } + _ => {} + } + + if attempt % 10 == 0 { + println!( + "Waiting for SSH... attempt {}/{}", + attempt, SSH_CONNECTIVITY_MAX_ATTEMPTS + ); + } + + if attempt < SSH_CONNECTIVITY_MAX_ATTEMPTS { + thread::sleep(Duration::from_secs(SSH_CONNECTIVITY_RETRY_DELAY_SECS)); + } + } + + anyhow::bail!( + "SSH connectivity check failed after {} attempts", + SSH_CONNECTIVITY_MAX_ATTEMPTS + ) +} + +/// Run TMT tests using bcvk for VM management +/// This spawns a separate VM per test plan to avoid state leakage between tests. +#[context("Running TMT tests")] +pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { + // Check dependencies first + check_dependencies(sh)?; + + let image = &args.image; + let filter_args = &args.filters; + let context = args + .context + .iter() + .map(|v| v.as_str()) + .chain(std::iter::once("running_env=image_mode")) + .map(|v| format!("--context={v}")) + .collect::>(); + let preserve_vm = args.preserve_vm; + + println!("Using bcvk image: {}", image); + + // Create tmt-workdir and copy tmt bits to it + // This works around https://github.com/teemtee/tmt/issues/4062 + let workdir = Utf8Path::new("target/tmt-workdir"); + sh.create_dir(workdir) + .with_context(|| format!("Creating {}", workdir))?; + + // rsync .fmf and tmt directories to workdir + cmd!(sh, "rsync -a --delete --force .fmf tmt {workdir}/") + .run() + .with_context(|| format!("Copying tmt files to {}", workdir))?; + + // Change to workdir for running tmt commands + let _dir = sh.push_dir(workdir); + + // Get the list of plans + println!("Discovering test plans..."); + let plans_output = cmd!(sh, "tmt plan ls") + .read() + .context("Getting list of test plans")?; + + let mut plans: Vec<&str> = plans_output + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty() && line.starts_with("/")) + .collect(); + + // Filter plans based on user arguments + if !filter_args.is_empty() { + let original_count = plans.len(); + plans.retain(|plan| filter_args.iter().any(|arg| plan.contains(arg.as_str()))); + if plans.len() < original_count { + println!( + "Filtered from {} to {} plan(s) based on arguments: {:?}", + original_count, + plans.len(), + filter_args + ); + } + } + + if plans.is_empty() { + println!("No test plans found"); + return Ok(()); + } + + println!("Found {} test plan(s): {:?}", plans.len(), plans); + + // Generate a random suffix for VM names + let random_suffix = generate_random_suffix(); + + // Track overall success/failure + let mut all_passed = true; + let mut test_results: Vec<(String, bool, Option)> = Vec::new(); + + // Run each plan in its own VM + for plan in plans { + let plan_name = sanitize_plan_name(plan); + let vm_name = format!("bootc-tmt-{}-{}", random_suffix, plan_name); + + println!("\n========================================"); + println!("Running plan: {}", plan); + println!("VM name: {}", vm_name); + println!("========================================\n"); + + // Launch VM with bcvk + + let launch_result = cmd!( + sh, + "bcvk libvirt run --name {vm_name} --detach {COMMON_INST_ARGS...} {image}" + ) + .run() + .context("Launching VM with bcvk"); + + if let Err(e) = launch_result { + eprintln!("Failed to launch VM for plan {}: {:#}", plan, e); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + + // Ensure VM cleanup happens even on error (unless --preserve-vm is set) + let cleanup_vm = || { + if preserve_vm { + return; + } + if let Err(e) = cmd!(sh, "bcvk libvirt rm --stop --force {vm_name}") + .ignore_stderr() + .ignore_status() + .run() + { + eprintln!("Warning: Failed to cleanup VM {}: {}", vm_name, e); + } + }; + + // Wait for VM to be ready and get SSH info + let vm_info = wait_for_vm_ready(sh, &vm_name); + let (ssh_port, ssh_key) = match vm_info { + Ok((port, key)) => (port, key), + Err(e) => { + eprintln!("Failed to get VM info for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + }; + + println!("VM ready, SSH port: {}", ssh_port); + + // Save SSH private key to a temporary file + let key_file = tempfile::NamedTempFile::new().context("Creating temporary SSH key file"); + + let key_file = match key_file { + Ok(f) => f, + Err(e) => { + eprintln!("Failed to create SSH key file for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + }; + + let key_path = Utf8PathBuf::try_from(key_file.path().to_path_buf()) + .context("Converting key path to UTF-8"); + + let key_path = match key_path { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to convert key path for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + }; + + if let Err(e) = std::fs::write(&key_path, ssh_key) { + eprintln!("Failed to write SSH key for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + + // Set proper permissions on the key file (SSH requires 0600) + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + if let Err(e) = std::fs::set_permissions(&key_path, perms) { + eprintln!("Failed to set key permissions for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + } + + // Verify SSH connectivity + println!("Verifying SSH connectivity..."); + if let Err(e) = verify_ssh_connectivity(sh, ssh_port, &key_path) { + eprintln!("SSH verification failed for plan {}: {:#}", plan, e); + cleanup_vm(); + all_passed = false; + test_results.push((plan.to_string(), false, None)); + continue; + } + + println!("SSH connectivity verified"); + + let ssh_port_str = ssh_port.to_string(); + + // Run tmt for this specific plan using connect provisioner + println!("Running tmt tests for plan {}...", plan); + + // Generate a unique run ID for this test + // Use the VM name which already contains a random suffix for uniqueness + let run_id = vm_name.clone(); + + // Run tmt for this specific plan + // Note: provision must come before plan for connect to work properly + let context = context.clone(); + let how = ["--how=connect", "--guest=localhost", "--user=root"]; + let env = ["TMT_SCRIPTS_DIR=/var/lib/tmt/scripts", "BCVK_EXPORT=1"] + .into_iter() + .chain(args.env.iter().map(|v| v.as_str())) + .flat_map(|v| ["--environment", v]); + let test_result = cmd!( + sh, + "tmt {context...} run --id {run_id} --all {env...} provision {how...} --port {ssh_port_str} --key {key_path} plan --name {plan}" + ) + .run(); + + // Clean up VM regardless of test result (unless --preserve-vm is set) + cleanup_vm(); + + match test_result { + Ok(_) => { + println!("Plan {} completed successfully", plan); + test_results.push((plan.to_string(), true, Some(run_id))); + } + Err(e) => { + eprintln!("Plan {} failed: {:#}", plan, e); + all_passed = false; + test_results.push((plan.to_string(), false, Some(run_id))); + } + } + + // Print VM connection details if preserving + if preserve_vm { + // Copy SSH key to a persistent location + let persistent_key_path = Utf8Path::new("target").join(format!("{}.ssh-key", vm_name)); + if let Err(e) = std::fs::copy(&key_path, &persistent_key_path) { + eprintln!("Warning: Failed to save persistent SSH key: {}", e); + } else { + println!("\n========================================"); + println!("VM preserved for debugging:"); + println!("========================================"); + println!("VM name: {}", vm_name); + println!("SSH port: {}", ssh_port_str); + println!("SSH key: {}", persistent_key_path); + println!("\nTo connect via SSH:"); + println!( + " ssh -i {} -p {} -o IdentitiesOnly=yes root@localhost", + persistent_key_path, ssh_port_str + ); + println!("\nTo cleanup:"); + println!(" bcvk libvirt rm --stop --force {}", vm_name); + println!("========================================\n"); + } + } + } + + // Print summary + println!("\n========================================"); + println!("Test Summary"); + println!("========================================"); + for (plan, passed, _) in &test_results { + let status = if *passed { "PASSED" } else { "FAILED" }; + println!("{}: {}", plan, status); + } + println!("========================================\n"); + + // Print detailed error reports for failed tests + let failed_tests: Vec<_> = test_results + .iter() + .filter(|(_, passed, _)| !passed) + .collect(); + + if !failed_tests.is_empty() { + println!("\n========================================"); + println!("Detailed Error Reports"); + println!("========================================\n"); + + for (plan, _, run_id) in failed_tests { + println!("----------------------------------------"); + println!("Plan: {}", plan); + println!("----------------------------------------"); + + if let Some(id) = run_id { + println!("Run ID: {}\n", id); + + // Run tmt with the specific run ID and generate verbose report + let report_result = cmd!(sh, "tmt run -i {id} report -vvv") + .ignore_status() + .run(); + + match report_result { + Ok(_) => {} + Err(e) => { + eprintln!( + "Warning: Failed to generate detailed report for {}: {:#}", + plan, e + ); + } + } + } else { + println!("Run ID not available - cannot generate detailed report"); + } + + println!("\n"); + } + + println!("========================================\n"); + } + + if !all_passed { + anyhow::bail!("Some test plans failed"); + } + + Ok(()) +} + +/// Provision a VM for manual tmt testing +/// Wraps bcvk libvirt run and waits for SSH connectivity +/// +/// Prints SSH connection details for use with tmt provision --how connect +#[context("Provisioning VM for TMT")] +pub(crate) fn tmt_provision(sh: &Shell, args: &TmtProvisionArgs) -> Result<()> { + // Check for bcvk + if cmd!(sh, "which bcvk").ignore_status().read().is_err() { + anyhow::bail!("bcvk is not available in PATH"); + } + + let image = &args.image; + let vm_name = args + .vm_name + .clone() + .unwrap_or_else(|| format!("bootc-tmt-manual-{}", generate_random_suffix())); + + println!("Provisioning VM..."); + println!(" Image: {}", image); + println!(" VM name: {}\n", vm_name); + + // Launch VM with bcvk + // Use ds=iid-datasource-none to disable cloud-init for faster boot + cmd!( + sh, + "bcvk libvirt run --name {vm_name} --detach {COMMON_INST_ARGS...} {image}" + ) + .run() + .context("Launching VM with bcvk")?; + + println!("VM launched, waiting for SSH..."); + + // Wait for VM to be ready and get SSH info + let (ssh_port, ssh_key) = wait_for_vm_ready(sh, &vm_name)?; + + // Save SSH private key to target directory + let key_dir = Utf8Path::new("target"); + sh.create_dir(key_dir) + .context("Creating target directory")?; + let key_path = key_dir.join(format!("{}.ssh-key", vm_name)); + + std::fs::write(&key_path, ssh_key).context("Writing SSH key file")?; + + // Set proper permissions on key file (0600) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .context("Setting SSH key file permissions")?; + } + + println!("SSH key saved to: {}", key_path); + + // Verify SSH connectivity + verify_ssh_connectivity(sh, ssh_port, &key_path)?; + + println!("\n========================================"); + println!("VM provisioned successfully!"); + println!("========================================"); + println!("VM name: {}", vm_name); + println!("SSH port: {}", ssh_port); + println!("SSH key: {}", key_path); + println!("\nTo use with tmt:"); + println!(" tmt run --all provision --how connect \\"); + println!(" --guest localhost --port {} \\", ssh_port); + println!(" --user root --key {} \\", key_path); + println!(" plan --name "); + println!("\nTo connect via SSH:"); + println!( + " ssh -i {} -p {} -o IdentitiesOnly=yes root@localhost", + key_path, ssh_port + ); + println!("\nTo cleanup:"); + println!(" bcvk libvirt rm --stop --force {}", vm_name); + println!("========================================\n"); + + Ok(()) +} + +/// Parse tmt metadata from a test file +/// Looks for: +/// # number: N +/// # tmt: +/// # +fn parse_tmt_metadata(content: &str) -> Result> { + let mut number = None; + let mut in_tmt_block = false; + let mut yaml_lines = Vec::new(); + + for line in content.lines().take(50) { + let trimmed = line.trim(); + + // Look for "# number: N" line + if let Some(rest) = trimmed.strip_prefix("# number:") { + number = Some( + rest.trim() + .parse::() + .context("Failed to parse number field")?, + ); + continue; + } + + if trimmed == "# tmt:" { + in_tmt_block = true; + continue; + } else if in_tmt_block { + // Stop if we hit a line that doesn't start with #, or is just "#" + if !trimmed.starts_with('#') || trimmed == "#" { + break; + } + // Remove the leading # and preserve indentation + if let Some(yaml_line) = line.strip_prefix('#') { + yaml_lines.push(yaml_line); + } + } + } + + let Some(number) = number else { + return Ok(None); + }; + + let yaml_content = yaml_lines.join("\n"); + let extra: serde_yaml::Value = if yaml_content.trim().is_empty() { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + } else { + serde_yaml::from_str(&yaml_content) + .with_context(|| format!("Failed to parse tmt metadata YAML:\n{}", yaml_content))? + }; + + Ok(Some(TmtMetadata { number, extra })) +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +struct TmtMetadata { + /// Test number for ordering and naming + number: u32, + /// All other fmf attributes (summary, duration, adjust, require, etc.) + /// Note: summary and duration are typically required by fmf + #[serde(flatten)] + extra: serde_yaml::Value, +} + +#[derive(Debug)] +struct TestDef { + number: u32, + name: String, + test_command: String, + /// All fmf attributes to pass through (summary, duration, adjust, etc.) + extra: serde_yaml::Value, +} + +/// Generate tmt/plans/integration.fmf from test definitions +#[context("Updating TMT integration.fmf")] +pub(crate) fn update_integration() -> Result<()> { + // Define tests in order + let mut tests = vec![]; + + // Scan for test-*.nu and test-*.sh files in tmt/tests/booted/ + let booted_dir = Utf8Path::new("tmt/tests/booted"); + + for entry in std::fs::read_dir(booted_dir)? { + let entry = entry?; + let path = entry.path(); + let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + // Extract stem (filename without "test-" prefix and extension) + let Some(stem) = filename + .strip_prefix("test-") + .and_then(|s| s.strip_suffix(".nu").or_else(|| s.strip_suffix(".sh"))) + else { + continue; + }; + + let content = + std::fs::read_to_string(&path).with_context(|| format!("Reading {}", filename))?; + + let metadata = parse_tmt_metadata(&content) + .with_context(|| format!("Parsing tmt metadata from {}", filename))? + .with_context(|| format!("Missing tmt metadata in {}", filename))?; + + // Remove number prefix if present (e.g., "01-readonly" -> "readonly", "26-examples-build" -> "examples-build") + let display_name = stem + .split_once('-') + .and_then(|(prefix, suffix)| { + if prefix.chars().all(|c| c.is_ascii_digit()) { + Some(suffix.to_string()) + } else { + None + } + }) + .unwrap_or_else(|| stem.to_string()); + + // Derive relative path from booted_dir + let relative_path = path + .strip_prefix("tmt/tests/") + .with_context(|| format!("Failed to get relative path for {}", filename))?; + + // Determine test command based on file extension + let extension = if filename.ends_with(".nu") { + "nu" + } else if filename.ends_with(".sh") { + "bash" + } else { + anyhow::bail!("Unsupported test file extension: {}", filename); + }; + + let test_command = format!("{} {}", extension, relative_path.display()); + + tests.push(TestDef { + number: metadata.number, + name: display_name, + test_command, + extra: metadata.extra, + }); + } + + // Sort tests by number + tests.sort_by_key(|t| t.number); + + // Generate single tests.fmf file using structured YAML + let tests_dir = Utf8Path::new("tmt/tests"); + let tests_fmf_path = tests_dir.join("tests.fmf"); + + // Build YAML structure + let mut tests_mapping = serde_yaml::Mapping::new(); + for test in &tests { + let test_key = format!("/test-{:02}-{}", test.number, test.name); + + // Start with the extra metadata (summary, duration, adjust, etc.) + let mut test_value = if let serde_yaml::Value::Mapping(map) = &test.extra { + map.clone() + } else { + serde_yaml::Mapping::new() + }; + + // Add the test command (derived from file type, not in metadata) + test_value.insert( + serde_yaml::Value::String("test".to_string()), + serde_yaml::Value::String(test.test_command.clone()), + ); + + tests_mapping.insert( + serde_yaml::Value::String(test_key), + serde_yaml::Value::Mapping(test_value), + ); + } + + // Serialize to YAML + let tests_yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(tests_mapping)) + .context("Serializing tests to YAML")?; + + // Post-process YAML to add blank lines between tests for readability + let mut tests_yaml_formatted = String::new(); + for line in tests_yaml.lines() { + if line.starts_with("/test-") && !tests_yaml_formatted.is_empty() { + tests_yaml_formatted.push('\n'); + } + tests_yaml_formatted.push_str(line); + tests_yaml_formatted.push('\n'); + } + + // Build final content with header + let mut tests_content = String::new(); + tests_content.push_str("# THIS IS GENERATED CODE - DO NOT EDIT\n"); + tests_content.push_str("# Generated by: cargo xtask tmt\n"); + tests_content.push_str("\n"); + tests_content.push_str(&tests_yaml_formatted); + + // Only write if content changed + let needs_update = match std::fs::read_to_string(&tests_fmf_path) { + Ok(existing) => existing != tests_content, + Err(_) => true, + }; + + if needs_update { + std::fs::write(&tests_fmf_path, tests_content).context("Writing tests.fmf")?; + println!("Generated {}", tests_fmf_path); + } else { + println!("Unchanged: {}", tests_fmf_path); + } + + // Generate plans section using structured YAML + let mut plans_mapping = serde_yaml::Mapping::new(); + for test in &tests { + let plan_key = format!("/plan-{:02}-{}", test.number, test.name); + let mut plan_value = serde_yaml::Mapping::new(); + + // Extract summary from extra metadata + if let serde_yaml::Value::Mapping(map) = &test.extra { + if let Some(summary) = map.get(&serde_yaml::Value::String("summary".to_string())) { + plan_value.insert( + serde_yaml::Value::String("summary".to_string()), + summary.clone(), + ); + } + } + + // Build discover section + let mut discover = serde_yaml::Mapping::new(); + discover.insert( + serde_yaml::Value::String("how".to_string()), + serde_yaml::Value::String("fmf".to_string()), + ); + let test_path = format!("/tmt/tests/tests/test-{:02}-{}", test.number, test.name); + discover.insert( + serde_yaml::Value::String("test".to_string()), + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(test_path)]), + ); + plan_value.insert( + serde_yaml::Value::String("discover".to_string()), + serde_yaml::Value::Mapping(discover), + ); + + // Extract and add adjust section if present + if let serde_yaml::Value::Mapping(map) = &test.extra { + if let Some(adjust) = map.get(&serde_yaml::Value::String("adjust".to_string())) { + plan_value.insert( + serde_yaml::Value::String("adjust".to_string()), + adjust.clone(), + ); + } + } + + plans_mapping.insert( + serde_yaml::Value::String(plan_key), + serde_yaml::Value::Mapping(plan_value), + ); + } + + // Serialize plans to YAML + let plans_yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(plans_mapping)) + .context("Serializing plans to YAML")?; + + // Post-process YAML to add blank lines between plans for readability + // and fix indentation for test list items + let mut plans_section = String::new(); + for line in plans_yaml.lines() { + if line.starts_with("/plan-") && !plans_section.is_empty() { + plans_section.push('\n'); + } + // Fix indentation: YAML serializer uses 2-space indent for list items, + // but we want them at 6 spaces (4 for discover + 2 for test) + if line.starts_with(" - /tmt/tests/") { + plans_section.push_str(" "); + plans_section.push_str(line.trim_start()); + } else { + plans_section.push_str(line); + } + plans_section.push('\n'); + } + + // Update integration.fmf with generated plans + let output_path = Utf8Path::new("tmt/plans/integration.fmf"); + let existing_content = + std::fs::read_to_string(output_path).context("Reading integration.fmf")?; + + // Replace plans section + let (before_plans, rest) = existing_content + .split_once(PLAN_MARKER_BEGIN) + .context("Missing # BEGIN GENERATED PLANS marker in integration.fmf")?; + let (_old_plans, after_plans) = rest + .split_once(PLAN_MARKER_END) + .context("Missing # END GENERATED PLANS marker in integration.fmf")?; + + let new_content = format!( + "{}{}{}{}{}", + before_plans, PLAN_MARKER_BEGIN, plans_section, PLAN_MARKER_END, after_plans + ); + + // Only write if content changed + let needs_update = match std::fs::read_to_string(output_path) { + Ok(existing) => existing != new_content, + Err(_) => true, + }; + + if needs_update { + std::fs::write(output_path, new_content)?; + println!("Generated {}", output_path); + } else { + println!("Unchanged: {}", output_path); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tmt_metadata_basic() { + let content = r#"# number: 1 +# tmt: +# summary: Execute booted readonly/nondestructive tests +# duration: 30m +# +# Run all readonly tests in sequence +use tap.nu +"#; + + let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + assert_eq!(metadata.number, 1); + + // Verify extra fields are captured + let extra = metadata.extra.as_mapping().unwrap(); + assert_eq!( + extra.get(&serde_yaml::Value::String("summary".to_string())), + Some(&serde_yaml::Value::String( + "Execute booted readonly/nondestructive tests".to_string() + )) + ); + assert_eq!( + extra.get(&serde_yaml::Value::String("duration".to_string())), + Some(&serde_yaml::Value::String("30m".to_string())) + ); + } + + #[test] + fn test_parse_tmt_metadata_with_adjust() { + let content = r#"# number: 27 +# tmt: +# summary: Execute custom selinux policy test +# duration: 30m +# adjust: +# - when: running_env != image_mode +# enabled: false +# because: these tests require features only available in image mode +# +use std assert +"#; + + let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + assert_eq!(metadata.number, 27); + + // Verify adjust section is in extra + let extra = metadata.extra.as_mapping().unwrap(); + assert!(extra.contains_key(&serde_yaml::Value::String("adjust".to_string()))); + } + + #[test] + fn test_parse_tmt_metadata_no_metadata() { + let content = r#"# Just a comment +use std assert +"#; + + let result = parse_tmt_metadata(content).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_parse_tmt_metadata_shell_script() { + let content = r#"# number: 26 +# tmt: +# summary: Test bootc examples build scripts +# duration: 45m +# adjust: +# - when: running_env != image_mode +# enabled: false +# +#!/bin/bash +set -eux +"#; + + let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + assert_eq!(metadata.number, 26); + + let extra = metadata.extra.as_mapping().unwrap(); + assert_eq!( + extra.get(&serde_yaml::Value::String("duration".to_string())), + Some(&serde_yaml::Value::String("45m".to_string())) + ); + assert!(extra.contains_key(&serde_yaml::Value::String("adjust".to_string()))); + } +} diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs new file mode 100644 index 000000000..8ac734e69 --- /dev/null +++ b/crates/xtask/src/xtask.rs @@ -0,0 +1,400 @@ +//! See https://github.com/matklad/cargo-xtask +//! This project now has a Justfile and a Makefile. +//! Commands here are not always intended to be run directly +//! by the user - add commands here which otherwise might +//! end up as a lot of nontrivial bash code. + +use std::fs::File; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::process::Command; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::{Args, Parser, Subcommand}; +use fn_error_context::context; +use xshell::{cmd, Shell}; + +mod man; +mod tmt; + +const NAME: &str = "bootc"; +const TAR_REPRODUCIBLE_OPTS: &[&str] = &[ + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime", +]; + +/// Build tasks for bootc +#[derive(Debug, Parser)] +#[command(name = "xtask")] +#[command(about = "Build tasks for bootc", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Generate man pages + Manpages, + /// Update generated files (man pages, JSON schemas) + UpdateGenerated, + /// Package the source code + Package, + /// Package source RPM + PackageSrpm, + /// Generate spec file + Spec, + /// Run TMT tests using bcvk + RunTmt(RunTmtArgs), + /// Provision a VM for manual TMT testing + TmtProvision(TmtProvisionArgs), +} + +/// Arguments for run-tmt command +#[derive(Debug, Args)] +pub(crate) struct RunTmtArgs { + /// Image name (e.g., "localhost/bootc-integration") + pub(crate) image: String, + + /// Test plan filters (e.g., "readonly") + #[arg(value_name = "FILTER")] + pub(crate) filters: Vec, + + /// Include additional context values + #[clap(long)] + pub(crate) context: Vec, + + /// Set environment variables in the test + #[clap(long)] + pub(crate) env: Vec, + + /// Preserve VMs after test completion (useful for debugging) + #[arg(long)] + pub(crate) preserve_vm: bool, +} + +/// Arguments for tmt-provision command +#[derive(Debug, Args)] +pub(crate) struct TmtProvisionArgs { + /// Image name (e.g., "localhost/bootc-integration") + pub(crate) image: String, + + /// VM name (defaults to "bootc-tmt-manual-") + #[arg(value_name = "VM_NAME")] + pub(crate) vm_name: Option, +} + +fn main() { + use std::io::Write as _; + + use owo_colors::OwoColorize; + if let Err(e) = try_main() { + let mut stderr = anstream::stderr(); + // Don't panic if writing fails. + let _ = writeln!(stderr, "{}{:#}", "error: ".red(), e); + std::process::exit(1); + } +} + +fn try_main() -> Result<()> { + // Ensure our working directory is the toplevel (if we're in a git repo) + { + if let Ok(toplevel_path) = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + { + if toplevel_path.status.success() { + let path = String::from_utf8(toplevel_path.stdout)?; + std::env::set_current_dir(path.trim()).context("Changing to toplevel")?; + } + } + // Otherwise verify we're in the toplevel + if !Utf8Path::new("ADOPTERS.md") + .try_exists() + .context("Checking for toplevel")? + { + anyhow::bail!("Not in toplevel") + } + } + + let cli = Cli::parse(); + let sh = xshell::Shell::new()?; + + match cli.command { + Commands::Manpages => man::generate_man_pages(&sh), + Commands::UpdateGenerated => update_generated(&sh), + Commands::Package => package(&sh), + Commands::PackageSrpm => package_srpm(&sh), + Commands::Spec => spec(&sh), + Commands::RunTmt(args) => tmt::run_tmt(&sh, &args), + Commands::TmtProvision(args) => tmt::tmt_provision(&sh, &args), + } +} + +fn gitrev_to_version(v: &str) -> String { + let v = v.trim().trim_start_matches('v'); + v.replace('-', ".") +} + +#[context("Finding gitrev")] +fn gitrev(sh: &Shell) -> Result { + if let Ok(rev) = cmd!(sh, "git describe --tags --exact-match") + .ignore_stderr() + .read() + { + Ok(gitrev_to_version(&rev)) + } else { + // Grab the abbreviated commit + let abbrev_commit = cmd!(sh, "git rev-parse HEAD") + .read()? + .chars() + .take(10) + .collect::(); + let timestamp = git_timestamp(sh)?; + // We always inject the timestamp first to ensure that newer is better. + Ok(format!("{timestamp}.g{abbrev_commit}")) + } +} + +/// Return a string formatted version of the git commit timestamp, up to the minute +/// but not second because, well, we're not going to build more than once a second. +#[context("Finding git timestamp")] +fn git_timestamp(sh: &Shell) -> Result { + let ts = cmd!(sh, "git show -s --format=%ct").read()?; + let ts = ts.trim().parse::()?; + let ts = chrono::DateTime::from_timestamp(ts, 0) + .ok_or_else(|| anyhow::anyhow!("Failed to parse timestamp"))?; + Ok(ts.format("%Y%m%d%H%M").to_string()) +} + +struct Package { + version: String, + srcpath: Utf8PathBuf, + vendorpath: Utf8PathBuf, +} + +/// Return the timestamp of the latest git commit in seconds since the Unix epoch. +fn git_source_date_epoch(dir: &Utf8Path) -> Result { + let o = Command::new("git") + .args(["log", "-1", "--pretty=%ct"]) + .current_dir(dir) + .output()?; + if !o.status.success() { + anyhow::bail!("git exited with an error: {:?}", o); + } + let buf = String::from_utf8(o.stdout).context("Failed to parse git log output")?; + let r = buf.trim().parse()?; + Ok(r) +} + +/// When using cargo-vendor-filterer --format=tar, the config generated has a bogus source +/// directory. This edits it to refer to vendor/ as a stable relative reference. +#[context("Editing vendor config")] +fn edit_vendor_config(config: &str) -> Result { + let mut config: toml::Value = toml::from_str(config)?; + let config = config.as_table_mut().unwrap(); + let source_table = config.get_mut("source").unwrap(); + let source_table = source_table.as_table_mut().unwrap(); + let vendored_sources = source_table.get_mut("vendored-sources").unwrap(); + let vendored_sources = vendored_sources.as_table_mut().unwrap(); + let previous = + vendored_sources.insert("directory".into(), toml::Value::String("vendor".into())); + assert!(previous.is_some()); + + Ok(config.to_string()) +} + +#[context("Packaging")] +fn impl_package(sh: &Shell) -> Result { + let source_date_epoch = git_source_date_epoch(".".into())?; + let v = gitrev(sh)?; + + let namev = format!("{NAME}-{v}"); + let p = Utf8Path::new("target").join(format!("{namev}.tar")); + let prefix = format!("{namev}/"); + cmd!(sh, "git archive --format=tar --prefix={prefix} -o {p} HEAD").run()?; + // Generate the vendor directory now, as we want to embed the generated config to use + // it in our source. + let vendorpath = Utf8Path::new("target").join(format!("{namev}-vendor.tar.zstd")); + let vendor_config = cmd!( + sh, + "cargo vendor-filterer --prefix=vendor --format=tar.zstd {vendorpath}" + ) + .read()?; + let vendor_config = edit_vendor_config(&vendor_config)?; + // Append .cargo/vendor-config.toml (a made up filename) into the tar archive. + { + let tmpdir = tempfile::tempdir_in("target")?; + let tmpdir_path = tmpdir.path(); + let path = tmpdir_path.join("vendor-config.toml"); + std::fs::write(&path, vendor_config)?; + let source_date_epoch = format!("{source_date_epoch}"); + cmd!( + sh, + "tar -r -C {tmpdir_path} {TAR_REPRODUCIBLE_OPTS...} --mtime=@{source_date_epoch} --transform=s,^,{prefix}.cargo/, -f {p} vendor-config.toml" + ) + .run()?; + } + // Compress with zstd + let srcpath: Utf8PathBuf = format!("{p}.zstd").into(); + cmd!(sh, "zstd --rm -f {p} -o {srcpath}").run()?; + + Ok(Package { + version: v, + srcpath, + vendorpath, + }) +} + +fn package(sh: &Shell) -> Result<()> { + let p = impl_package(sh)?.srcpath; + println!("Generated: {p}"); + Ok(()) +} + +fn update_spec(sh: &Shell) -> Result { + let p = Utf8Path::new("target"); + let pkg = impl_package(sh)?; + let srcpath = pkg.srcpath.file_name().unwrap(); + let v = pkg.version; + let src_vendorpath = pkg.vendorpath.file_name().unwrap(); + { + let specin = File::open(format!("contrib/packaging/{NAME}.spec")) + .map(BufReader::new) + .context("Opening spec")?; + let mut o = File::create(p.join(format!("{NAME}.spec"))).map(BufWriter::new)?; + for line in specin.lines() { + let line = line?; + if line.starts_with("Version:") { + writeln!(o, "# Replaced by cargo xtask spec")?; + writeln!(o, "Version: {v}")?; + } else if line.starts_with("Source0") { + writeln!(o, "Source0: {srcpath}")?; + } else if line.starts_with("Source1") { + writeln!(o, "Source1: {src_vendorpath}")?; + } else { + writeln!(o, "{line}")?; + } + } + } + let spec_path = p.join(format!("{NAME}.spec")); + Ok(spec_path) +} + +fn spec(sh: &Shell) -> Result<()> { + let s = update_spec(sh)?; + println!("Generated: {s}"); + Ok(()) +} +fn impl_srpm(sh: &Shell) -> Result { + { + let _g = sh.push_dir("target"); + for name in sh.read_dir(".")? { + if let Some(name) = name.to_str() { + if name.ends_with(".src.rpm") { + sh.remove_path(name)?; + } + } + } + } + let pkg = impl_package(sh)?; + let td = tempfile::tempdir_in("target").context("Allocating tmpdir")?; + let td = td.keep(); + let td: &Utf8Path = td.as_path().try_into().unwrap(); + let srcpath = &pkg.srcpath; + cmd!(sh, "mv {srcpath} {td}").run()?; + let v = pkg.version; + let src_vendorpath = &pkg.vendorpath; + cmd!(sh, "mv {src_vendorpath} {td}").run()?; + { + let specin = File::open(format!("contrib/packaging/{NAME}.spec")) + .map(BufReader::new) + .context("Opening spec")?; + let mut o = File::create(td.join(format!("{NAME}.spec"))).map(BufWriter::new)?; + for line in specin.lines() { + let line = line?; + if line.starts_with("Version:") { + writeln!(o, "# Replaced by cargo xtask package-srpm")?; + writeln!(o, "Version: {v}")?; + } else { + writeln!(o, "{line}")?; + } + } + } + let d = sh.push_dir(td); + let mut cmd = cmd!(sh, "rpmbuild"); + for k in [ + "_sourcedir", + "_specdir", + "_builddir", + "_srcrpmdir", + "_rpmdir", + ] { + cmd = cmd.arg("--define"); + cmd = cmd.arg(format!("{k} {td}")); + } + cmd.arg("--define") + .arg(format!("_buildrootdir {td}/.build")) + .args(["-bs", "bootc.spec"]) + .run()?; + drop(d); + let mut srpm = None; + for e in std::fs::read_dir(td)? { + let e = e?; + let n = e.file_name(); + let Some(n) = n.to_str() else { + continue; + }; + if n.ends_with(".src.rpm") { + srpm = Some(td.join(n)); + break; + } + } + let srpm = srpm.ok_or_else(|| anyhow::anyhow!("Failed to find generated .src.rpm"))?; + let dest = Utf8Path::new("target").join(srpm.file_name().unwrap()); + std::fs::rename(&srpm, &dest)?; + Ok(dest) +} + +fn package_srpm(sh: &Shell) -> Result<()> { + let srpm = impl_srpm(sh)?; + println!("Generated: {srpm}"); + Ok(()) +} + +/// Update JSON schema files +#[context("Updating JSON schemas")] +fn update_json_schemas(sh: &Shell) -> Result<()> { + for (of, target) in [ + ("host", "docs/src/host-v1.schema.json"), + ("progress", "docs/src/progress-v0.schema.json"), + ] { + let schema = cmd!(sh, "cargo run -q -- internals print-json-schema --of={of}").read()?; + std::fs::write(target, &schema)?; + println!("Updated {target}"); + } + Ok(()) +} + +/// Update all generated files +/// This is the main command developers should use to update generated content. +/// It handles: +/// - Creating new man page templates for new commands +/// - Syncing CLI options to existing man pages +/// - Updating JSON schema files +#[context("Updating generated files")] +fn update_generated(sh: &Shell) -> Result<()> { + // Update man pages (create new templates + sync options) + man::update_manpages(sh)?; + + // Update JSON schemas + update_json_schemas(sh)?; + + // Update TMT integration.fmf + tmt::update_integration()?; + + Ok(()) +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 000000000..a55fe74d6 --- /dev/null +++ b/deny.toml @@ -0,0 +1,15 @@ +[licenses] +allow = ["Apache-2.0", "Apache-2.0 WITH LLVM-exception", "MIT", + "BSD-3-Clause", "BSD-2-Clause", "Zlib", + "Unlicense", "CC0-1.0", "BSL-1.0", + "Unicode-DFS-2016", "Unicode-3.0"] +private = { ignore = true } + +[[bans.deny]] +# We want to require FIPS validation downstream, so we use openssl +name = "ring" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = ["https://github.com/containers/composefs-rs"] diff --git a/devenv/.dockerignore b/devenv/.dockerignore deleted file mode 100644 index 63378d283..000000000 --- a/devenv/.dockerignore +++ /dev/null @@ -1,23 +0,0 @@ -# Exclude everything by default, then include just what we need -# Especially note this means that .git is not included, and not tests/ -# to avoid spurious rebuilds. -* -# And explicit includes -!packages.txt -!packages-common.txt -!packages-debian.txt -!packages-c10s.txt -!packages-ubuntu.txt -!npm.txt -!build-deps.txt -!build-deps-debian.txt -!build-deps-c10s.txt -!build-deps-ubuntu.txt -!devenv-init.sh -!fetch-tools.py -!tool-versions.txt -!install-rust.sh -!install-uv.sh -!install-kani.sh -!devenv-selftest.sh -!userns-setup diff --git a/devenv/Containerfile.c10s b/devenv/Containerfile.c10s deleted file mode 100644 index 6818b7a10..000000000 --- a/devenv/Containerfile.c10s +++ /dev/null @@ -1,111 +0,0 @@ -# These aren't packages, just low-dependency binaries dropped in /usr/local/bin -# so we can fetch them independently in a separate build. -ARG base=quay.io/centos/centos:stream10 -FROM $base as base -# Life is too short to care about dash -RUN ln -sfr /bin/bash /bin/sh -RUN < /etc/yum.repos.d/gh-cli.repo <<'EOREPO' -[gh-cli] -name=GitHub CLI -baseurl=https://cli.github.com/packages/rpm -enabled=1 -gpgcheck=1 -gpgkey=https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x23F3D4EA75716059 -EOREPO - -# Update after adding repositories -dnf -y makecache -EORUN - -FROM base as tools -# renovate: datasource=github-releases depName=astral-sh/uv -ARG uvversion=0.11.2 -COPY fetch-tools.py tool-versions.txt install-uv.sh /run/src/ -RUN /run/src/fetch-tools.py -RUN uvversion=$uvversion /run/src/install-uv.sh - -FROM base as rust -# renovate: datasource=custom.rust-nightly depName=rust-nightly versioning=rust-release-channel -ARG rust_nightly=nightly-2026-03-29 -COPY install-rust.sh /run/src/ -RUN rust_nightly=$rust_nightly /run/src/install-rust.sh - -# Kani formal verification tool - requires rustup for toolchain management -FROM rust as kani -# renovate: datasource=crate depName=kani-verifier -ARG kaniversion=0.67.0 -COPY install-kani.sh /run/src/ -RUN kaniversion=$kaniversion /run/src/install-kani.sh - -# This builds the image. -# Build this using `just devenv-build-c10s` from the root of the repository. -FROM base -COPY packages-common.txt packages-c10s.txt build-deps-c10s.txt /run/src/ -WORKDIR /run/src -RUN < /etc/sudoers.d/devenv && chmod 0440 /etc/sudoers.d/devenv -# TODO: /etc/shadow permissions need fixing for PAM/sudo with --userns=keep-id -# See https://github.com/bootc-dev/infra/issues/XXX -EORUN -# To avoid overlay-on-overlay with nested containers -VOLUME [ "/var/lib/containers", "/home/devenv/.local/share/containers/" ] -USER devenv diff --git a/devenv/Containerfile.debian b/devenv/Containerfile.debian deleted file mode 100644 index bce8da5a5..000000000 --- a/devenv/Containerfile.debian +++ /dev/null @@ -1,101 +0,0 @@ -# These aren't packages, just low-dependency binaries dropped in /usr/local/bin -# so we can fetch them independently in a separate build. -ARG base=docker.io/library/debian:sid -FROM $base AS base -# Life is too short to care about dash -RUN ln -sfr /bin/bash /bin/sh -RUN < /etc/apt/apt.conf.d/99sandbox-disable - -# Initialize some basic packages -apt -y update && apt -y install ca-certificates curl time bzip2 - -# Enable deb-src repositories for build-dep -sed -i "s/^deb /deb [arch=$(dpkg --print-architecture)] /" /etc/apt/sources.list.d/debian.sources -sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/debian.sources - -# Enable gh CLI repository -mkdir -p -m 755 /etc/apt/keyrings -curl -fLo /etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg -chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg -mkdir -p -m 755 /etc/apt/sources.list.d -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list - -# And re-update after we've fetched repos -apt -y update -EORUN - -FROM base AS tools -# renovate: datasource=github-releases depName=astral-sh/uv -ARG uvversion=0.11.2 -COPY fetch-tools.py tool-versions.txt install-uv.sh /run/src/ -RUN apt -y install python3 && /run/src/fetch-tools.py && apt -y purge python3 && apt -y autoremove -RUN uvversion=$uvversion /run/src/install-uv.sh - -FROM base AS rust -# renovate: datasource=custom.rust-nightly depName=rust-nightly versioning=rust-release-channel -ARG rust_nightly=nightly-2026-03-29 -COPY install-rust.sh /run/src/ -RUN rust_nightly=$rust_nightly /run/src/install-rust.sh - -# Kani formal verification tool - requires rustup for toolchain management -FROM rust AS kani -# renovate: datasource=crate depName=kani-verifier -ARG kaniversion=0.67.0 -COPY install-kani.sh /run/src/ -RUN kaniversion=$kaniversion /run/src/install-kani.sh - -# This builds the image. -# Build this using `just devenv-build-debian` from the root of the repository. -FROM base -COPY packages-common.txt packages-debian.txt build-deps-debian.txt /run/src/ -WORKDIR /run/src -RUN < /etc/sudoers.d/devenv && chmod 0440 /etc/sudoers.d/devenv -EORUN -# To avoid overlay-on-overlay with nested containers -VOLUME [ "/var/lib/containers", "/home/devenv/.local/share/containers/" ] -USER devenv diff --git a/devenv/Containerfile.ubuntu b/devenv/Containerfile.ubuntu deleted file mode 100644 index c74e36afe..000000000 --- a/devenv/Containerfile.ubuntu +++ /dev/null @@ -1,116 +0,0 @@ -# These aren't packages, just low-dependency binaries dropped in /usr/local/bin -# so we can fetch them independently in a separate build. -ARG base=docker.io/library/ubuntu:24.04 -FROM $base AS base -# Life is too short to care about dash -RUN ln -sfr /bin/bash /bin/sh -RUN < /etc/apt/apt.conf.d/99sandbox-disable - -# Initialize some basic packages -apt -y update && apt -y install ca-certificates curl time bzip2 software-properties-common - -# Enable deb-src repositories for build-dep -sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/ubuntu.sources - -# Enable universe repository (needed for some packages like just, fsverity) -add-apt-repository -y universe - -# Cherry-pick newer container stack from plucky (Ubuntu 25.04). -# Keep in sync with actions/bootc-ubuntu-setup. -# The main archive only carries amd64; arm64 uses ports.ubuntu.com. -if [ "$(dpkg --print-architecture)" = "amd64" ]; then - mirror="http://archive.ubuntu.com/ubuntu" -else - mirror="http://ports.ubuntu.com/ubuntu-ports" -fi -echo "deb ${mirror} plucky universe main" > /etc/apt/sources.list.d/plucky.list - -# Enable gh CLI repository -mkdir -p -m 755 /etc/apt/keyrings -curl -fLo /etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg -chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg -mkdir -p -m 755 /etc/apt/sources.list.d -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list - -# And re-update after we've fetched repos -apt -y update -EORUN - -FROM base AS tools -# renovate: datasource=github-releases depName=astral-sh/uv -ARG uvversion=0.11.2 -COPY fetch-tools.py tool-versions.txt install-uv.sh /run/src/ -RUN apt -y install python3 && /run/src/fetch-tools.py && apt -y purge python3 && apt -y autoremove -RUN uvversion=$uvversion /run/src/install-uv.sh - -FROM base AS rust -# renovate: datasource=custom.rust-nightly depName=rust-nightly versioning=rust-release-channel -ARG rust_nightly=nightly-2026-03-29 -COPY install-rust.sh /run/src/ -RUN rust_nightly=$rust_nightly /run/src/install-rust.sh - -# Kani formal verification tool - requires rustup for toolchain management -FROM rust AS kani -# renovate: datasource=crate depName=kani-verifier -ARG kaniversion=0.67.0 -COPY install-kani.sh /run/src/ -RUN kaniversion=$kaniversion /run/src/install-kani.sh - -# This builds the image. -# Build this using `just devenv-build-ubuntu` from the root of the repository. -FROM base -COPY packages-common.txt packages-ubuntu.txt build-deps-ubuntu.txt /run/src/ -WORKDIR /run/src -RUN < /etc/sudoers.d/devenv && chmod 0440 /etc/sudoers.d/devenv -EORUN -# To avoid overlay-on-overlay with nested containers -VOLUME [ "/var/lib/containers", "/home/devenv/.local/share/containers/" ] -USER devenv diff --git a/devenv/README.md b/devenv/README.md deleted file mode 100644 index 6d005c04e..000000000 --- a/devenv/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# A devcontainer for work on bootc-dev projects - -This is an image designed for the [devcontainer ecosystem](https://containers.dev/) -along with targeting the development of projects in this bootc-dev -organization, especially bootc. - -## Components - -- Rust, Go and C/C++ toolchains -- podman (for nested containers, see below) -- `nu` -- [jj (Jujutsu)](https://github.com/jj-vcs/jj) as a Git-compatible VCS frontend -- [bcvk](https://github.com/bootc-dev/bcvk/) to launch bootc VMs -- [tmt](https://tmt.readthedocs.io/) since bootc testing requires it -- [Kani](https://model-checking.github.io/kani/usage.html) - -## Base images - -There are two images: - -- [ghcr.io/bootc-dev/devenv-debian](https://github.com/orgs/bootc-dev/packages/container/package/devenv-debian) which uses Debian sid as a base -- [ghcr.io/bootc-dev/devenv-c10s](https://github.com/orgs/bootc-dev/packages/container/package/devenv-c10s) which uses CentOS Stream 10 as a base - -## Nested container support - -This image supports running `podman` and `podman build` inside the container -(podman-in-podman). The [userns-setup](userns-setup) script configures the environment at -container startup, handling both constrained (Codespaces, rootless) and full UID namespaces. - -Note that in order to enable this you will also need to pair it with -a [devcontainer JSON](../common/.devcontainer/devcontainer.json). - -## Building locally - -See the `Justfile`, but it's just a thin wrapper around a default -of `podman build` of this directory. diff --git a/devenv/build-deps-c10s.txt b/devenv/build-deps-c10s.txt deleted file mode 100644 index c920542d4..000000000 --- a/devenv/build-deps-c10s.txt +++ /dev/null @@ -1 +0,0 @@ -ostree diff --git a/devenv/build-deps-debian.txt b/devenv/build-deps-debian.txt deleted file mode 100644 index c920542d4..000000000 --- a/devenv/build-deps-debian.txt +++ /dev/null @@ -1 +0,0 @@ -ostree diff --git a/devenv/build-deps-ubuntu.txt b/devenv/build-deps-ubuntu.txt deleted file mode 100644 index c920542d4..000000000 --- a/devenv/build-deps-ubuntu.txt +++ /dev/null @@ -1 +0,0 @@ -ostree diff --git a/devenv/devenv-init.sh b/devenv/devenv-init.sh deleted file mode 100755 index ef287dc27..000000000 --- a/devenv/devenv-init.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Initialize development environment -set -euo pipefail - -# Set up podman for nested containers -python3 /usr/lib/devenv/userns-setup "$@" diff --git a/devenv/devenv-selftest.sh b/devenv/devenv-selftest.sh deleted file mode 100755 index 5af5abdaf..000000000 --- a/devenv/devenv-selftest.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# Test that nested podman and VMs work correctly in this devcontainer. -# This script is designed to be run inside the container after devenv-init.sh -# has already been executed (e.g., via postCreateCommand). -set -euo pipefail - -echo "=== Testing nested podman and VMs ===" - -echo "Podman version:" -podman --version - -echo "Podman info (rootless):" -podman info --format '{{.Host.Security.Rootless}}' - -# Use CentOS Stream 10 as the test image for both container and VM -image="quay.io/centos-bootc/centos-bootc:stream10" - -echo "Pulling $image..." -podman pull "$image" - -echo "Running nested container..." -podman run --rm "$image" echo "Hello from nested podman!" - -echo "=== Nested container test passed ===" - -# Test bcvk (VM) if available and /dev/kvm exists. -# This is best-effort: in nested containers /dev/kvm can appear accessible -# but fail at runtime due to namespace restrictions, so we don't fail the -# overall selftest if bcvk fails. -if command -v bcvk >/dev/null 2>&1 && [ -e /dev/kvm ]; then - echo "" - echo "=== Testing bcvk VM (best-effort) ===" - echo "bcvk version:" - bcvk --version - - echo "Running bcvk ephemeral VM with SSH..." - if bcvk ephemeral run-ssh "$image" -- echo "Hello from bcvk VM!"; then - echo "=== bcvk VM test passed ===" - else - echo "=== bcvk VM test failed (KVM may not be functional in this environment) ===" - fi -else - echo "" - echo "=== Skipping bcvk VM test (bcvk not available or /dev/kvm missing) ===" -fi - -echo "" -echo "=== All tests passed ===" diff --git a/devenv/fetch-tools.py b/devenv/fetch-tools.py deleted file mode 100755 index 4139bcc8d..000000000 --- a/devenv/fetch-tools.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -"""Fetch pre-built binary tools into /usr/local/bin. - -Reads tool names and versions from tool-versions.txt, downloads the -appropriate architecture-specific release archive from GitHub, and -extracts the binary into /usr/local/bin. - -This script is shared between c10s and debian container builds. -""" - -import platform -import re -import subprocess -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path - -INSTALL_DIR = Path("/usr/local/bin") - - -@dataclass -class Tool: - """Download specification for a single tool.""" - - # GitHub owner/repo (e.g. "jj-vcs/jj") - repo: str - # Map from uname machine string to the arch token used in release filenames. - # Missing arch means the tool is unavailable on that platform. - arch_map: dict[str, str] - # Format string for the release tag, given {version}. - tag_fmt: str - # Format string for the tarball filename, given {version} and {arch}. - tarball_fmt: str - # Path to the binary inside the extracted archive, given {version} and {arch}. - # Relative to the extraction directory. - binary_path_fmt: str - # Name of the installed binary in /usr/local/bin. - binary_name: str - - -TOOLS: dict[str, Tool] = { - "bcvk": Tool( - repo="bootc-dev/bcvk", - arch_map={"x86_64": "x86_64"}, # x86_64 only - tag_fmt="{version}", # version already includes 'v' prefix - tarball_fmt="bcvk-{arch}-unknown-linux-gnu.tar.gz", - binary_path_fmt="bcvk-{arch}-unknown-linux-gnu", - binary_name="bcvk", - ), - "scorecard": Tool( - repo="ossf/scorecard", - arch_map={"x86_64": "amd64", "aarch64": "arm64"}, - tag_fmt="{version}", # version already includes 'v' prefix - tarball_fmt="scorecard_{version_bare}_linux_{arch}.tar.gz", - binary_path_fmt="scorecard", - binary_name="scorecard", - ), - "nushell": Tool( - repo="nushell/nushell", - arch_map={"x86_64": "x86_64", "aarch64": "aarch64"}, - tag_fmt="{version}", # no 'v' prefix - tarball_fmt="nu-{version}-{arch}-unknown-linux-gnu.tar.gz", - binary_path_fmt="nu-{version}-{arch}-unknown-linux-gnu/nu", - binary_name="nu", - ), - "jj": Tool( - repo="jj-vcs/jj", - arch_map={"x86_64": "x86_64", "aarch64": "aarch64"}, - tag_fmt="v{version}", # add 'v' prefix - tarball_fmt="jj-v{version}-{arch}-unknown-linux-musl.tar.gz", - binary_path_fmt="jj", - binary_name="jj", - ), -} - - -# Version strings must be alphanumeric with dots, hyphens, and an optional -# leading 'v'. This rejects path traversal sequences and other surprises. -_VERSION_RE = re.compile(r"^v?[A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*$") - - -def parse_tool_versions(path: Path) -> dict[str, str]: - """Parse tool-versions.txt, returning {name: version}.""" - versions = {} - for lineno, line in enumerate(path.read_text().splitlines(), 1): - line = line.strip() - if not line or line.startswith("#"): - continue - if "@" not in line: - print(f"warning: skipping malformed line: {line}", file=sys.stderr) - continue - name, version = line.split("@", 1) - if not _VERSION_RE.match(version): - print( - f"error: {path}:{lineno}: invalid version string: {version!r}", - file=sys.stderr, - ) - sys.exit(1) - versions[name] = version - return versions - - -def fetch_tool(name: str, version: str, arch: str) -> None: - """Download and install a single tool.""" - tool = TOOLS[name] - - mapped_arch = tool.arch_map.get(arch) - if mapped_arch is None: - print(f"{name} unavailable for {arch}") - return - - tag = tool.tag_fmt.format(version=version) - # version_bare strips a leading 'v' for tools like scorecard that use - # it in the tarball name but not in the tag - version_bare = version.lstrip("v") - fmt_vars = {"version": version, "version_bare": version_bare, "arch": mapped_arch} - - tarball = tool.tarball_fmt.format(**fmt_vars) - url = f"https://github.com/{tool.repo}/releases/download/{tag}/{tarball}" - binary_path = tool.binary_path_fmt.format(**fmt_vars) - - with tempfile.TemporaryDirectory() as td: - subprocess.run( - ["curl", "-fLO", url], - cwd=td, - check=True, - ) - subprocess.run( - ["tar", "xzf", tarball], - cwd=td, - check=True, - ) - src = Path(td) / binary_path - dst = INSTALL_DIR / tool.binary_name - dst.write_bytes(src.read_bytes()) - dst.chmod(0o755) - print(f"installed {dst}") - - -def main() -> None: - script_dir = Path(__file__).parent - versions_file = script_dir / "tool-versions.txt" - versions = parse_tool_versions(versions_file) - - arch = platform.machine() - print(f"arch: {arch}") - - # Clear out old versions of tools managed by this script - for tool in TOOLS.values(): - path = INSTALL_DIR / tool.binary_name - if path.is_file(): - path.unlink() - print(f"removed {path}") - - unknown = set(versions) - set(TOOLS) - if unknown: - print(f"error: unknown tools in tool-versions.txt: {unknown}", file=sys.stderr) - sys.exit(1) - - for name in TOOLS: - if name not in versions: - print( - f"error: {name} defined in TOOLS but missing from tool-versions.txt", - file=sys.stderr, - ) - sys.exit(1) - fetch_tool(name, versions[name], arch) - - -if __name__ == "__main__": - main() diff --git a/devenv/install-kani.sh b/devenv/install-kani.sh deleted file mode 100755 index 8611322dc..000000000 --- a/devenv/install-kani.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Install Kani formal verification tool -# This script is shared between c10s, debian, and ubuntu container builds. -# Prerequisites: rustup must already be installed (via install-rust.sh) -set -xeuo pipefail - -# Required environment variable (passed as build ARG) -: "${kaniversion:?kaniversion is required}" - -# Install gcc (required to compile Kani's C stubs) -if command -v dnf >/dev/null; then - dnf install -y gcc && dnf clean all -elif command -v apt-get >/dev/null; then - apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/* -else - echo "error: unsupported package manager" >&2 - exit 1 -fi - -export RUSTUP_HOME=/usr/local/rustup -export CARGO_HOME=/usr/local/cargo -export PATH="/usr/local/bin:$PATH" - -# Install Kani to a system-wide location so all users can access it -export KANI_HOME=/usr/local/kani - -# Install kani-verifier -/bin/time -f '%E %C' cargo install --locked kani-verifier --version $kaniversion - -# Run kani setup - downloads bundle and installs required nightly toolchain -/bin/time -f '%E %C' /usr/local/cargo/bin/cargo-kani setup - -# Move kani binaries to /usr/local/bin, keep rustup symlink -mv /usr/local/cargo/bin/cargo-kani /usr/local/cargo/bin/kani /usr/local/bin/ diff --git a/devenv/install-rust.sh b/devenv/install-rust.sh deleted file mode 100755 index 8c1605ded..000000000 --- a/devenv/install-rust.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# Install Rust system-wide into /usr/local -# This script is shared between c10s and debian container builds. -set -xeuo pipefail - -export RUSTUP_HOME=/usr/local/rustup -export CARGO_HOME=/usr/local/cargo - -# Install Rust system-wide -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile default - -# Install nightly toolchain if requested (pinned date, e.g. nightly-2026-03-02) -if [ -n "${rust_nightly:-}" ]; then - /usr/local/cargo/bin/rustup toolchain install "${rust_nightly}" --profile minimal - # Symlink the dated nightly as "nightly" so `cargo +nightly` works without - # requiring write access to RUSTUP_HOME for channel updates. - host=$(/usr/local/cargo/bin/rustc --print host-tuple) - ln -sf "${rust_nightly}-${host}" "$RUSTUP_HOME/toolchains/nightly-${host}" -fi - -# Move binaries to /usr/local/bin (system-managed, root-owned) -mv /usr/local/cargo/bin/* /usr/local/bin/ - -# Recreate bin directory with symlink to rustup - rustup's self-update check -# looks for itself at $CARGO_HOME/bin/rustup -mkdir -p /usr/local/cargo/bin -ln -sf /usr/local/bin/rustup /usr/local/cargo/bin/rustup diff --git a/devenv/install-uv.sh b/devenv/install-uv.sh deleted file mode 100755 index 78e0078ff..000000000 --- a/devenv/install-uv.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Install uv system-wide into /usr/local -# This script is shared between c10s and debian container builds. -# Similar to rustup, we install the binary to /usr/local/bin and configure -# tools to be installed system-wide via environment variables. -set -xeuo pipefail - -: "${uvversion:?uvversion is required}" - -arch=$(arch) - -# Map arch to uv naming convention -case "${arch}" in - x86_64) uvarch=x86_64 ;; - aarch64) uvarch=aarch64 ;; - *) echo "uv unavailable for $arch"; exit 1 ;; -esac - -target=uv-${uvarch}-unknown-linux-gnu.tar.gz - -td=$(mktemp -d) -( - cd $td - /bin/time -f '%E %C' curl -fLO https://github.com/astral-sh/uv/releases/download/${uvversion}/$target - tar xvzf $target - # The extracted directory has the same name as the archive without .tar.gz - extracted_dir=uv-${uvarch}-unknown-linux-gnu - mv $extracted_dir/uv /usr/local/bin/uv - mv $extracted_dir/uvx /usr/local/bin/uvx -) -rm -rf $td - -# Verify installation -/usr/local/bin/uv --version diff --git a/devenv/npm.txt b/devenv/npm.txt deleted file mode 100644 index 1d439e8b7..000000000 --- a/devenv/npm.txt +++ /dev/null @@ -1,2 +0,0 @@ -# renovate: datasource=npm depName=opencode-ai -opencode-ai@1.3.7 diff --git a/devenv/packages-c10s.txt b/devenv/packages-c10s.txt deleted file mode 100644 index e082b1376..000000000 --- a/devenv/packages-c10s.txt +++ /dev/null @@ -1,19 +0,0 @@ -# CentOS Stream 10 specific package names -# Common packages are in packages-common.txt - -# General build env -clang-tools-extra -krb5-devel -libvirt-devel -ostree-devel - -# Runtime virt -xorriso -qemu-img -libvirt-daemon-kvm - -# Testing framework -tmt - -# TUI editors -vim-enhanced diff --git a/devenv/packages-common.txt b/devenv/packages-common.txt deleted file mode 100644 index 6c82022b5..000000000 --- a/devenv/packages-common.txt +++ /dev/null @@ -1,39 +0,0 @@ -# Key dependencies that have the same package name across distributions -just -podman -curl -git -sudo - -# Generic utilities -acl -rsync - -# Container image tools (bootc/composefs testing) -skopeo - -# Sandboxing (used by devaipod and flatpak) -bubblewrap - -# Terminal multiplexer (used by devaipod tmux command) -tmux - -# General build env (note: we install rust through rustup later) -gcc -clang -golang -pkg-config -go-md2man - -# Runtime virt (common packages) -qemu-kvm -virtiofsd - -# TUI editors -nano - -# Dependency of other things like gemini CLI -npm - -# From 3rd party repos -gh diff --git a/devenv/packages-debian.txt b/devenv/packages-debian.txt deleted file mode 100644 index 472b3b690..000000000 --- a/devenv/packages-debian.txt +++ /dev/null @@ -1,22 +0,0 @@ -# Debian-specific package names -# Common packages are in packages-common.txt - -# General build env -clang-format -libkrb5-dev -libvirt-dev -libostree-dev - -# Python dev headers (needed for uv to build libvirt-python from source for tmt) -python3-dev - -# Runtime virt -genisoimage -qemu-utils -libvirt-daemon-system - -# Filesystem verity utilities (composefs testing) -fsverity - -# TUI editors -vim diff --git a/devenv/packages-ubuntu.txt b/devenv/packages-ubuntu.txt deleted file mode 100644 index 0bde3caed..000000000 --- a/devenv/packages-ubuntu.txt +++ /dev/null @@ -1,22 +0,0 @@ -# Ubuntu-specific package names -# Common packages are in packages-common.txt - -# General build env -clang-format -libkrb5-dev -libvirt-dev -libostree-dev - -# Python dev headers (needed for uv to build libvirt-python from source for tmt) -python3-dev - -# Runtime virt -genisoimage -qemu-utils -libvirt-daemon-system - -# Filesystem verity utilities (composefs testing) -fsverity - -# TUI editors -vim diff --git a/devenv/tool-versions.txt b/devenv/tool-versions.txt deleted file mode 100644 index 3388e5014..000000000 --- a/devenv/tool-versions.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Tool versions for fetch-tools.py -# Format: name@version (one per line) -# Renovate annotations allow automated version updates. - -# renovate: datasource=github-releases depName=bootc-dev/bcvk -bcvk@v0.13.0 -# renovate: datasource=github-releases depName=ossf/scorecard -scorecard@v5.4.0 -# renovate: datasource=github-releases depName=nushell/nushell -nushell@0.111.0 -# renovate: datasource=github-releases depName=jj-vcs/jj -jj@0.39.0 diff --git a/devenv/userns-setup b/devenv/userns-setup deleted file mode 100644 index 36c608895..000000000 --- a/devenv/userns-setup +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -""" -Set up nested podman inside privileged docker/podman containers (codespaces, devpod). - -This handles: -- Mount propagation fixes -- /dev/kvm permissions -- subuid/subgid configuration for constrained UID namespaces -- containers.conf configuration for nested operation - -Reference: quay.io/podman/stable image configuration - https://github.com/containers/image_build/tree/main/podman -""" - -import argparse -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path - - -def run_cmd(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess: - """Run a command, optionally capturing output.""" - return subprocess.run(cmd, check=check, capture_output=capture, text=True) - - -def get_mount_propagation(target: str) -> str: - """Get mount propagation type for a given mount point.""" - result = run_cmd(["findmnt", "-J", "-o", "TARGET,PROPAGATION", target], capture=True, check=False) - if result.returncode != 0: - return "unknown" - try: - data = json.loads(result.stdout) - return data.get("filesystems", [{}])[0].get("propagation", "unknown") - except (json.JSONDecodeError, IndexError, KeyError): - return "unknown" - - -def fix_mount_propagation() -> None: - """Fix root mount propagation if needed (e.g., in codespaces).""" - propagation = get_mount_propagation("/") - if propagation == "private": - result = run_cmd(["mount", "-o", "remount", "--make-shared", "/"], check=False) - if result.returncode == 0: - print("Set / to shared propagation") - else: - print("Warning: Could not set / to shared propagation (may not be needed)") - - -def fix_kvm_permissions() -> None: - """Make /dev/kvm accessible to all users (safe, like Fedora derivatives do).""" - kvm = Path("/dev/kvm") - if kvm.exists(): - try: - kvm.chmod(0o666) - except PermissionError: - pass - - -def detect_constrained_namespace() -> tuple[bool, int]: - """ - Detect whether we're in a constrained UID namespace. - - Returns: - (is_constrained, max_uid): True if constrained (1000-100000 UIDs available), - along with the maximum usable UID. - """ - max_uid = 0 - try: - with open("/proc/self/uid_map") as f: - for line in f: - parts = line.split() - if len(parts) >= 3: - inside = int(parts[0]) - count = int(parts[2]) - end = inside + count - if end > max_uid: - max_uid = end - except (OSError, ValueError): - return False, 0 - - # Constrained if between 1000 and 100000 UIDs - is_constrained = 1000 < max_uid < 100000 - return is_constrained, max_uid - - -def configure_subuid_subgid(target_user: str | None = None) -> None: - """ - Configure subuid/subgid for nested rootless podman in constrained UID namespaces. - - Args: - target_user: Username to configure. Defaults to SUDO_USER or current user. - """ - # Only proceed if podman is available - if not shutil.which("podman"): - return - - # Check for newuidmap/newgidmap - if not shutil.which("newuidmap"): - print("Warning: newuidmap not found, nested podman may fail") - - is_constrained, max_uid = detect_constrained_namespace() - if not is_constrained: - print(f"Full UID namespace available (max={max_uid}), using default podman config") - return - - # Determine target user - if target_user is None: - target_user = os.environ.get("SUDO_USER") - if target_user is None: - import pwd - target_user = pwd.getpwuid(os.getuid()).pw_name - - # Get target user's UID - import pwd - try: - target_uid = pwd.getpwnam(target_user).pw_uid - except KeyError: - print(f"Warning: User {target_user} not found") - return - - # Calculate subuid range - subuid_start = target_uid + 1 - subuid_count = max_uid - subuid_start - - if subuid_count < 1000: - print(f"Insufficient UID range for nested podman (only {subuid_count} UIDs available)") - return - - expected = f"{target_user}:{subuid_start}:{subuid_count}" - - # Check if already configured correctly - subuid_path = Path("/etc/subuid") - if subuid_path.exists(): - current = None - for line in subuid_path.read_text().splitlines(): - if line.startswith(f"{target_user}:"): - current = line - break - if current == expected: - print(f"Nested podman subuid/subgid already configured for {target_user}") - return - - print(f"Configuring nested podman for {target_user} (subuid {subuid_start}:{subuid_count})") - - # Configure subuid/subgid - for path in [Path("/etc/subuid"), Path("/etc/subgid")]: - lines = [] - if path.exists(): - lines = [line for line in path.read_text().splitlines() - if not line.startswith(f"{target_user}:")] - lines.append(expected) - path.write_text("\n".join(lines) + "\n") - - # Reset podman storage if it exists (may have wrong UID mappings) - import pwd - user_home = Path(pwd.getpwnam(target_user).pw_dir) - storage_dir = user_home / ".local/share/containers/storage" - if storage_dir.exists(): - print("Resetting podman storage for new UID mappings") - shutil.rmtree(storage_dir) - - print("Nested podman subuid/subgid configured successfully") - - -def configure_containers_conf() -> None: - """Configure containers.conf for nested container operation.""" - if not shutil.which("podman"): - return - - is_constrained, _ = detect_constrained_namespace() - - conf_dir = Path("/etc/containers") - conf_dir.mkdir(parents=True, exist_ok=True) - conf_path = conf_dir / "containers.conf" - - if not is_constrained: - header = "# Generated for nested container support" - container_settings = 'cgroups = "no-conmon"' - else: - header = "# Generated for nested container support in constrained UID namespace" - container_settings = 'cgroups = "disabled"\nutsns = "host"' - - conf_path.write_text(f"""\ -{header} -# Reference: https://github.com/containers/image_build/tree/main/podman -[containers] -# Disable default sysctls - /proc/sys is read-only in nested containers -# (specifically net.ipv4.ping_group_range causes "Read-only file system" errors) -# See: https://github.com/containers/common/blob/main/pkg/config/containers.conf -default_sysctls = [] -{container_settings} - -[engine] -cgroup_manager = "cgroupfs" -""") - if is_constrained: - print("Configured containers.conf for constrained UID namespace") - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Configure nested podman for devcontainers" - ) - parser.add_argument( - "user", - nargs="?", - help="Target user for subuid/subgid configuration (default: SUDO_USER or current user)", - ) - args = parser.parse_args() - - fix_mount_propagation() - fix_kvm_permissions() - configure_subuid_subgid(args.user) - configure_containers_conf() - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..452845469 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +book +mermaid*.js diff --git a/docs/Dockerfile.mdbook b/docs/Dockerfile.mdbook new file mode 100644 index 000000000..3020eeb56 --- /dev/null +++ b/docs/Dockerfile.mdbook @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi10/ubi:latest +# An intermediate layer which caches the RPMS +RUN <Red Hat, Inc. and others." + +# Footer last edited timestamp +last_edit_timestamp: true +last_edit_time_format: "%b %e %Y at %I:%M %p" + +# Footer "Edit this page on GitHub" link text +gh_edit_link: true +gh_edit_link_text: "Edit this page on GitHub" +gh_edit_repository: "https://github.com/bootc-dev/bootc" +gh_edit_branch: "main" +gh_edit_source: docs +gh_edit_view_mode: "tree" + +compress_html: + clippings: all + comments: all + endings: all + startings: [] + blanklines: false + profile: false diff --git a/docs/_sass/color_schemes/coreos.scss b/docs/_sass/color_schemes/coreos.scss new file mode 100644 index 000000000..a4554be88 --- /dev/null +++ b/docs/_sass/color_schemes/coreos.scss @@ -0,0 +1 @@ +$link-color: #53a3da; diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 000000000..9e3eb4e06 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,18 @@ +[book] +authors = ["Colin Walters"] +language = "en" +multilingual = false +src = "src" +title = "bootc" + +[preprocessor.mermaid] +command = "mdbook-mermaid" + +[preprocessor.header-footer] +headers = [] +footers = [ + { padding = "\n---\n

The Linux Foundation® (TLF) has registered trademarks and uses trademarks. For a list of TLF trademarks, see Trademark Usage.

" } +] + +[output.html] +additional-js = ["mermaid.min.js", "mermaid-init.js"] diff --git a/docs/repository-structure.md b/docs/repository-structure.md deleted file mode 100644 index f6f1659f7..000000000 --- a/docs/repository-structure.md +++ /dev/null @@ -1,86 +0,0 @@ -# Repository structure - -The bootc-dev organization contains a number of repositories. While not every -repository will function in exactly in the same way, there are -"baseline" configuration and procedures that should generally apply. - -## Maintainers - -There should be a `maintainers` team with the **Maintain** permission -that is used by repositories by default. - -## Renovate - -The organization uses a centralized Renovate configuration managed from this -repository. To enable Renovate on a new repository, create a `renovate.json` -file in the repository root: - -```json -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "local>bootc-dev/infra:renovate-shared-config.json" - ] -} -``` - -## PR gating and merging - -Each repository MUST enable the following settings via a branch protection rule for `main`: - -- Require a pull request before merging -- Require approvals - -### required-checks - -Having some kind of CI is also required. Repositories SHOULD enable the automatic merge setting, -and configure at least one gating CI check. - -The ["required-checks" pattern](https://github.com/bootc-dev/bootc/blob/main/.github/workflows/ci.yml) -is where the repository configuration gates solely on that check which in turn gates on others, allowing easy dynamic -reconfiguration of the required checks without requiring administrator intervention. - -## Language - -In this organization, Rust is preferred. - -## Developer experience - -Repositories SHOULD have a [Justfile](https://just.systems/) which acts as a development entry point. It -is strongly encouraged to follow the pattern in [bootc Justfile](https://github.com/bootc-dev/bootc/blob/main/Justfile) -where e.g. GitHub Actions mostly invoke `just ` so all CI flows are easily -replicable outside of GHA. - -## Devcontainer - -Repositories SHOULD have a `.devcontainer.json` (one is synchronized by default from this repo) -and key targets in the `Justfile` should run in that environment. - -## Unit and integration tests - -Any nontrivial code SHOULD have unit tests and integration tests. An integration test MUST -be a separately built artifact that tests a production build in a "black box" fashion. - -### Integration test environments - -Integration tests SHOULD be flexible and adaptable. In particular, there are multiple -"test suite runner environments" which our integration tests should work with. - -- [Debian autopkgtest](https://wiki.debian.org/autopkgtest) -- [tmt](https://tmt.readthedocs.io/en/stable/) (and [Fedora CI](https://packit.dev/fedora-ci/)) - -Privileged and destructive tests should be clearly distinct. - -At the current time, bootc has some "bridging" between a custom integration test suite -and tmt. However, a pattern used in other repositories is to have an integration test -binary written in Rust that uses `libtest-mimic` - in this pattern all tests are just -part of a simple single binary. - -- [composefs-rs](https://github.com/composefs/composefs-rs/tree/main/crates/integration-tests) -- [bcvk](https://github.com/bootc-dev/bcvk/blob/main/Justfile) - -### Future direction - -Goal: Convert all repositories to a pattern like this, and create reliable bridging -between it and tmt and Debian autopkgtest. For example, support converting -the tests into the relevant framework format. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 000000000..c12144d3e --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,72 @@ +# Summary + +- [Introduction](intro.md) + +# Installation + +- [Installation](installation.md) + +# Building images + +- [Building images](building/guidance.md) +- [Container runtime vs bootc runtime](building/bootc-runtime.md) +- [Users, groups, SSH keys](building/users-and-groups.md) +- [Kernel arguments](building/kernel-arguments.md) +- [Secrets](building/secrets.md) +- [Management Services](building/management-services.md) + +# Using bootc + +- [Upgrade and rollback](upgrades.md) +- [Accessing registries and offline updates](registries-and-offline.md) +- [Logically bound images](logically-bound-images.md) +- [Booting local builds](booting-local-builds.md) +- [`man bootc`](man/bootc.8.md) +- [`man bootc-status`](man/bootc-status.8.md) +- [`man bootc-upgrade`](man/bootc-upgrade.8.md) +- [`man bootc-switch`](man/bootc-switch.8.md) +- [`man bootc-rollback`](man/bootc-rollback.8.md) +- [`man bootc-usr-overlay`](man/bootc-usr-overlay.8.md) +- [`man bootc-fetch-apply-updates.service`](man/bootc-fetch-apply-updates.service.5.md) +- [`man bootc-status-updated.path`](man/bootc-status-updated.path.5.md) +- [`man bootc-status-updated.target`](man/bootc-status-updated.target.5.md) +- [Controlling bootc via API](bootc-via-api.md) + +# Using `bootc install` + +- [Understanding `bootc install`](bootc-install.md) +- [`man bootc-install`](man/bootc-install.8.md) +- [`man bootc-install-config`](man/bootc-install-config.5.md) +- [`man bootc-install-to-disk`](man/bootc-install-to-disk.8.md) +- [`man bootc-install-to-filesystem`](man/bootc-install-to-filesystem.8.md) +- [`man bootc-install-to-existing-root`](man/bootc-install-to-existing-root.8.md) + +# Bootc usage in containers + +- [Read-only when in a default container](bootc-in-container.md) +- [`man bootc-container-lint`](man/bootc-container-lint.8.md) + +# Architecture + +- [Image layout](bootc-images.md) +- [Filesystem](filesystem.md) +- [Filesystem: sysroot](filesystem-sysroot.md) +- [Container storage](filesystem-storage.md) +- [Bootloader](bootloaders.md) + +# Experimental features + +- [bootc image](experimental-bootc-image.md) +- [composefs backend](experimental-composefs.md) +- [`man bootc-root-setup.service`](man/bootc-root-setup.service.5.md) +- [fsck](experimental-fsck.md) +- [install reset](experimental-install-reset.md) +- [--progress-fd](experimental-progress-fd.md) + +# More information + +- [Packaging and integration](packaging-and-integration.md) +- [Package manager integration](package-managers.md) +- [Relationship with other projects](relationships.md) +- [Relationship with OCI artifacs](relationship-oci-artifacts.md) +- [Relationship with systemd "particles"](relationship-particles.md) diff --git a/docs/src/bootc-images.md b/docs/src/bootc-images.md new file mode 100644 index 000000000..58a459c75 --- /dev/null +++ b/docs/src/bootc-images.md @@ -0,0 +1,154 @@ +# "bootc compatible" images + +It is a toplevel goal of this project to tightly integrate +with the OCI ecosystem and make booting containers a normal +activity. + +However, there are a number of basic requirements and integration +points, some of which have distribution-specific variants. + +Further at the current time, the bootc project makes a lot +of use of ostree, and this can appear in the base image +requirements. + +## ostree-in-container + +With [bootc 1.1.3](https://github.com/bootc-dev/bootc/releases/tag/v1.1.3) +or later, it is no longer required to have a `/ostree` directory +present in the base image. + +To generate container images which do include `/ostree` from scratch, +the underlying `ostree container` tooling is designed to operate +on an existing ostree commit, and the `ostree container encapsulate` +command can turn the commit into an OCI image. If you already +have a pipeline which prdouces ostree commits as an output +(e.g. using [osbuild](https://www.osbuild.org/guides/image-builder-on-premises/building-ostree-images.html) + to produce `ostree` commit artifacts), then this allows a +seamless transition to a bootc/OCI compatible ecosystem. + +## Higher level base image build tooling + +A well tested tool to produce compatible base images is +[`rpm-ostree compose image`](https://coreos.github.io/rpm-ostree/container/#creating-base-images), +which is used by the [Fedora base image](https://gitlab.com/fedora/bootc/base-images). + +## Standard image content + +The bootc project provides a [baseimage](https://github.com/bootc-dev/bootc/tree/main/baseimage) reference +set of configuration files for base images. In particular at +the current time the content defined by `base` must be used +(or recreated). There is also suggested integration there with +e.g. `dracut` to ensure the initramfs is set up, etc. + +## Standard metadata for bootc compatible images + +It is strongly recommended to do: + +```dockerfile +LABEL containers.bootc 1 +``` + +This will signal that this image is intended to be usable with `bootc`. + +## Deriving from existing base images + +It's important to emphasize that from one +of these specially-formatted base images, every +tool and technique for container building applies! +In other words it will Just Work to do + +```Dockerfile +FROM +RUN dnf -y install foo && dnf clean all +``` + +You can then use `podman build`, `buildah`, `docker build`, or any other container +build tool to produce your customized image. The only requirement is that the +container build tool supports producing OCI container images. + +## Kernel + +The Linux kernel (and optionally initramfs) is embedded in the container image; the canonical location +is `/usr/lib/modules/$kver/vmlinuz`, and the initramfs should be in `initramfs.img` +in that directory. You should *not* include any content in `/boot` in your container image. +Bootc will take care of copying the kernel/initramfs as needed from the container image to +`/boot`. + +Future work for supporting UKIs will follow the recommendations of the uapi-group in [Locations for Distribution-built UKIs Installed by Package Managers](https://uapi-group.org/specifications/specs/unified_kernel_image/#locations-for-distribution-built-ukis-installed-by-package-managers). + +The `bootc container lint` command will check this. + +## The `ostree container commit` command + +You may find some references to this; it is no longer very useful +and is not recommended. + +## The bootloader setup + +At the current time bootc relies on the [bootupd](https://github.com/coreos/bootupd/) +project which handles bootloader installs and upgrades. The invocation of +`bootc install` will always run `bootupd` to perform installations. +Additionally, `bootc upgrade` will currently not upgrade the bootloader; +you must invoke `bootupctl update`. + +## SELinux + +Container runtimes such as `podman` and `docker` commonly +apply a "coarse" SELinux policy to running containers. +See [container-selinux](https://github.com/containers/container-selinux/blob/main/container_selinux.8). +It is very important to understand that non-bootc base +images do not (usually) have any embedded `security.selinux` metadata +at all; all labels on the toplevel container image +are *dynamically* generated per container invocation, +and there are no individually distinct e.g. `etc_t` and +`usr_t` types. + +In contrast, with the current OSTree backend for bootc, +it is possible to include label metadata (and precomputed ostree +checksums) in special metadata files in `/sysroot/ostree` that correspond +to components of the base image. This is optional as of bootc v1.1.3. + +File content in derived layers will be labeled using the default file +contexts (from `/etc/selinux`). For example, you can do this (as of +bootc 1.1.0): + +``` +RUN semanage fcontext -a -t httpd_sys_content_t "/web(/.*)?" +``` + +(This command will write to `/etc/selinux/$policy/policy/`.) + +It will currently not work to do e.g.: + +``` +RUN chcon -t foo_t /usr/bin/foo +``` + +Because the container runtime state will deny the attempt to +"physically" set the `security.selinux` extended attribute. + +In the future, it is likely however that we add support +for handling the `security.selinux` extended attribute in tar +streams; but this can only currently be done with a custom +build process. + +### Toplevel directories + +In particular, a common problem is that inside a container image, +it's easy to create arbitrary toplevel directories such as +e.g. `/app` or `/aimodel` etc. But in some SELinux policies +such as Fedora derivatives, these will be labeled as `default_t` +which few domains can access. + +References: + +- + +## composefs + +It is strongly recommended to enable the ostree composefs +backend (but not strictly required) for bootc. + +A reference enablement file to do so is in the base image content referenced above. + +More in [ostree-prepare-root](https://ostreedev.github.io/ostree/man/ostree-prepare-root.html). diff --git a/docs/src/bootc-in-container.md b/docs/src/bootc-in-container.md new file mode 100644 index 000000000..8cd15c804 --- /dev/null +++ b/docs/src/bootc-in-container.md @@ -0,0 +1,20 @@ +# bootc is read-only when run in a default container + +Currently, running e.g. `podman run bootc upgrade` will not work. +There are a variety of reasons for this, such as the basic fact that by +default a `docker|podman run ` doesn't know where to update itself; +the image reference is not exposed into the target image (for security/operational +reasons). + +## Supported operations + +There are only two supported operations in a container environment today: + +- `bootc status`: This can reliably be used to detect whether the system is + actually booted via bootc or not. +- `bootc container lint`: See [man/bootc-container-lint.8.md](man/bootc-container-lint.8.md). + +### Testing bootc in a container + +Eventually we would like to support having bootc run inside a container environment +primarily for testing purposes. For this, please see the [tracking issue](https://github.com/bootc-dev/bootc/issues/400). diff --git a/docs/src/bootc-install.md b/docs/src/bootc-install.md new file mode 100644 index 000000000..ed356d915 --- /dev/null +++ b/docs/src/bootc-install.md @@ -0,0 +1,302 @@ +# Installing "bootc compatible" images + +A key goal of the bootc project is to think of bootable operating systems +as container images. Docker/OCI container images are just tarballs +wrapped with some JSON. But in order to boot a system (whether on bare metal +or virtualized), one needs a few key components: + +- bootloader +- kernel (and optionally initramfs) +- root filesystem (xfs/ext4/btrfs etc.) + +The bootloader state is managed by the external [bootupd](https://github.com/coreos/bootupd/) +project which abstracts over bootloader installs and upgrades. The invocation of +`bootc install` will always run `bootupd` to handle bootloader installation +to the target disk. The default expectation is that bootloader contents and install logic +come from the container image in a `bootc` based system. + +The Linux kernel (and optionally initramfs) is embedded in the container image; the canonical location +is `/usr/lib/modules/$kver/vmlinuz`, and the initramfs should be in `initramfs.img` +in that directory. + +The `bootc install` command bridges the two worlds of a standard, runnable OCI image +and a bootable system by running tooling logic embedded +in the container image to create the filesystem and bootloader setup dynamically. +This requires running the container via `--privileged`; it uses the running Linux kernel +on the host to write the file content from the running container image; not the kernel +inside the container. + +There are two sub-commands: `bootc install to-disk` and `boot install to-filesystem`. + +However, nothing *else* (external) is required to perform a basic installation +to disk - the container image itself comes with a baseline self-sufficient installer +that sets things up ready to boot. + +## Internal vs external installers + +The `bootc install to-disk` process only sets up a very simple +filesystem layout, using the default filesystem type defined in the container image, +plus hardcoded requisite platform-specific partitions such as the ESP. + +In general, the `to-disk` flow should be considered mainly a "demo" for +the `bootc install to-filesystem` flow, which can be used by "external" installers +today. For example, in the [Fedora/CentOS bootc project](https://docs.fedoraproject.org/en-US/bootc/) +project, there are two "external" installers in Anaconda and `bootc-image-builder`. + +More on this below. + +## Executing `bootc install` + +The two installation commands allow you to install the container image +either directly to a block device (`bootc install to-disk`) or to an existing +filesystem (`bootc install to-filesystem`). + +The installation commands **MUST** be run **from** the container image +that will be installed, using `--privileged` and a few +other options. This means you are (currently) not able to install `bootc` +to an existing system and install your container image. Failure to run +`bootc` from a container image will result in an error. + +Here's an example of using `bootc install` (root/elevated permission required): + +```bash +podman run --rm --privileged --pid=host -v /var/lib/containers:/var/lib/containers -v /dev:/dev --security-opt label=type:unconfined_t bootc install to-disk /path/to/disk +``` + +Note that while `--privileged` is used, this command will not perform any +destructive action on the host system. Among other things, `--privileged` +makes sure that all host devices are mounted into container. `/path/to/disk` is +the host's block device where `` will be installed on. + +The `--pid=host --security-opt label=type:unconfined_t` today +make it more convenient for bootc to perform some privileged +operations; in the future these requirements may be dropped. + +The `-v /var/lib/containers:/var/lib/containers` option is required in order +for the container to access its own underlying image, which is used by +the installation process. + +Jump to the section for [`install to-filesystem`](#more-advanced-installation) later +in this document for additional information about that method. + +### "day 2" updates, security and fetch configuration + +By default the `bootc install` path will find the pull specification used +for the `podman run` invocation and use it to set up "day 2" OS updates that `bootc update` +will use. + +For example, if you invoke `podman run --privileged ... quay.io/examplecorp/exampleos:latest bootc install ...` +then the installed operating system will fetch updates from `quay.io/examplecorp/exampleos:latest`. +This can be overridden via `--target_imgref`; this is handy in cases like performing +installation in a manufacturing environment from a mirrored registry. + +By default, the installation process will verify that the container (representing the target OS) +can fetch its own updates. + +Additionally note that to perform an upgrade with a target image reference set to an +authenticated registry, you must provide a pull secret. One path is to embed the pull secret into +the image in `/etc/ostree/auth.json`. + +### Configuring the default root filesystem type + +To use the `to-disk` installation flow, the container should include a root filesystem +type. If it does not, then each user will need to specify `install to-disk --filesystem`. + +To set a default filesystem type for `bootc install to-disk` as part of your OS/distribution base image, +create a file named `/usr/lib/bootc/install/00-.toml` with the contents of the form: + +```toml +[install.filesystem.root] +type = "xfs" +``` + +Configuration files found in this directory will be merged, with higher alphanumeric values +taking precedence. If for example you are building a derived container image from the above OS, +you could create a `50-myos.toml` that sets `type = "btrfs"` which will override the +prior setting. + +For other available options, see [bootc-install-config](man/bootc-install-config.5.md). + +## Installing an "unconfigured" image + +The bootc project aims to support generic/general-purpose operating +systems and distributions that will ship unconfigured images. An +unconfigured image does not have a default password or SSH key, etc. + +For more information, see [Image building and configuration guidance](building/guidance.md). + +## More advanced installation with `to-filesystem` + +The basic `bootc install to-disk` logic is really a pretty small (but opinionated) wrapper +for a set of lower level tools that can also be invoked independently. + +The `bootc install to-disk` command is effectively: + +- `mkfs.$fs /dev/disk` +- `mount /dev/disk /mnt` +- `bootc install to-filesystem --karg=root=UUID= --imgref $self /mnt` + +There may be a bit more involved here; for example configuring +`--block-setup tpm2-luks` will configure the root filesystem +with LUKS bound to the TPM2 chip, currently via [systemd-cryptenroll](https://www.freedesktop.org/software/systemd/man/systemd-cryptenroll.html#). + +Some OS/distributions may not want to enable it at all; it +can be configured off at build time via Cargo features. + +### Using `bootc install to-filesystem` + +The usual expected way for an external storage system to work +is to provide `root=` and `rootflags` kernel arguments +to describe to the initial RAM disk how to find and mount the +root partition. For more on this, see the below section +discussing mounting the root filesystem. + +Note that if a separate `/boot` is needed (e.g. for LUKS) you will also need to provide `--boot-mount-spec UUID=...`. + +The `bootc install to-filesystem` command allows an operating +system or distribution to ship a separate installer that creates more complex block +storage or filesystem setups, but reuses the "top half" of the logic. +For example, a goal is to change [Anaconda](https://github.com/rhinstaller/anaconda/) +to use this. + +#### Postprocessing after to-filesystem + +Some installation tools may want to inject additional data, such as adding +an `/etc/hostname` into the target root. At the current time, bootc does +not offer a direct API to do this. However, the backend for bootc is +ostree, and it is possible to enumerate the deployments via ostree APIs. + +We hope to provide a bootc-supported method to find the deployment in +the future. + +However, for tools that do perform any changes, there is a new +`bootc install finalize` command which is optional, but recommended +to run as the penultimate step before unmounting the target filesystem. + +This command will perform some basic sanity checks and may also +perform fixups on the target root. For example, a direction +currently for bootc is to stop using `/etc/fstab`. While `install finalize` +does not do this today, in the future it may automatically migrate +`etc/fstab` to `rootflags` kernel arguments. + +### Using `bootc install to-disk --via-loopback` + +Because every `bootc` system comes with an opinionated default installation +process, you can create a raw disk image that you can boot via virtualization. Run these commands as root: + +```bash +truncate -s 10G myimage.raw +podman run --rm --privileged --pid=host --security-opt label=type:unconfined_t -v /dev:/dev -v /var/lib/containers:/var/lib/containers -v .:/output bootc install to-disk --generic-image --via-loopback /output/myimage.raw +``` + +Notice that we use `--generic-image` for this use case. + +Set the environment variable `BOOTC_DIRECT_IO=on` to create the loopback device with direct-io enabled. + +### Using `bootc install to-existing-root` + +This is a variant of `install to-filesystem`, which maximizes convenience for using +an existing Linux system, converting it into the target container image. Note that +the `/boot` (and `/boot/efi`) partitions *will be reinitialized* - so this is a +somewhat destructive operation for the existing Linux installation. + +Also, because the filesystem is reused, it's required that the target system kernel +support the root storage setup already initialized. + +The core command should look like this (root/elevated permission required): + +```bash +podman run --rm --privileged -v /dev:/dev -v /var/lib/containers:/var/lib/containers -v /:/target \ + --pid=host --security-opt label=type:unconfined_t \ + \ + bootc install to-existing-root +``` + +It is assumed in this command that the target rootfs is pased via `-v /:/target` at this time. + +As noted above, the data in `/boot` will be wiped, but everything else in the existing +operating `/` is **NOT** automatically cleaned up. This can +be useful, because it allows the new image to automatically import data from the previous +host system! For example, container images, database, user home directory data, config +files in `/etc` are all available after the subsequent reboot in `/sysroot` (which +is the "physical root"). + +However, previous mount points or subvolumes will not be automatically +mounted in the new system, e.g. a btrfs subvolume for /home will not be automatically mounted to +/sysroot/home. These filesystems will persist and can be handled any way you want like manually +mounting them or defining the mount points as part of the bootc image. + +A special case of this trick is using the `--root-ssh-authorized-keys` flag to inherit +root's SSH keys (which may have been injected from e.g. cloud instance userdata +via a tool like `cloud-init`). To do this, just add +`--root-ssh-authorized-keys /target/root/.ssh/authorized_keys` +to the above. + + +### Using `system-reinstall-bootc` + +This is a separate binary included with bootc. It is an opinionated, interactive CLI that wraps `bootc install to-existing-root`. See [bootc install to-existing-root](#Using-bootc-install-to-existing-root) for details on the installation operation. + +`system-reinstall-bootc` can be run from an existing Linux system. It will pull the supplied image, prompt to setup SSH keys for accessing the system, and run `bootc install to-existing-root` with all the bind mounts and SSH keys configured. + +It will also add the `bootc-destructive-cleanup.service` systemd unit that will run on first boot to cleanup parts of the previous system. The cleanup actions can be configured per distribution by creating a script and packaging it similar to [this one for Fedora](https://github.com/bootc-dev/bootc/blob/main/contrib/scripts/fedora-bootc-destructive-cleanup). + +### Using `bootc install to-filesystem --source-imgref ` + +By default, `bootc install` has to be run inside a podman container. With this assumption, +it can escape the container, find the source container image (including its layers) in +the podman's container storage and use it to create the image. + +When `--source-imgref ` is given, `bootc` no longer assumes that it runs inside podman. +Instead, the given container image reference (see [containers-transports(5)](https://github.com/containers/image/blob/main/docs/containers-transports.5.md) +for accepted formats) is used to fetch the image. Note that `bootc install` still has to be +run inside a chroot created from the container image. However, this allows users to use +a different sandboxing tool (e.g. [bubblewrap](https://github.com/containers/bubblewrap)). + +This argument is mainly useful for 3rd-party tooling for building disk images from bootable +containers (e.g. based on [osbuild](https://github.com/osbuild/osbuild)). + + +## Finding and configuring the physical root filesystem + +On a bootc system, the "physical root" is different from +the "logical root" of the booted container. For more on +that, see [filesystem](filesystem.md). This section +is about how the physical root filesystem is discovered. + +Systems using systemd will often default to using +[systemd-fstab-generator](https://www.freedesktop.org/software/systemd/man/latest/systemd-fstab-generator.html) +and/or [systemd-gpt-auto-generator](https://www.freedesktop.org/software/systemd/man/latest/systemd-gpt-auto-generator.html#). +Support for the latter though for the root filesystem is conditional on EFI and a bootloader implementing the bootloader interface. + +Outside of the discoverable partition model, a common baseline default for installers is to set `root=UUID=` +(and optionally `rootflags=`) kernel arguments as machine specific state. +When using `install to-filesystem`, you should provide these as explicit +kernel arguments. + +Some installation tools may want to generate an `/etc/fstab`. An important +consideration is that when composefs is on by default (as it is expected +to be) it will no longer work to have an entry for `/` in `/etc/fstab` +(or a systemd `.mount` unit) that handles remounting the rootfs with +updated options after exiting the initrd. + +In general, prefer using the `rootflags` kernel argument for that +use case; it ensures that the filesystem is mounted with the +correct options to start, and avoid having an entry for `/` +in `/etc/fstab`. + +The physical root is mounted at `/sysroot`. It is an option +for legacy `/etc/fstab` references for `/` to use +`/sysroot` by default, but `rootflags` is preferred. + +## Configuring machine-local state + +Per the [filesystem](filesystem.md) section, `/etc` and `/var` are machine-local +state by default. If you want to inject additional content after the installation +process, at the current time this can be done by manually finding the +target "deployment root" which will be underneath `/ostree/deploy/ +RUN apt|dnf upgrade https://example.com/systemd-hotfix.package +``` + +## Copying an updated image into the bootc storage + +This command is straightforward; we just need to tell bootc +to fetch updates from `containers-storage`, which is the +local "application" container runtime (podman) storage: + +``` +$ bootc switch --transport containers-storage quay.io/fedora/fedora-bootc:40 +``` + +From there, the new image will be queued for the next boot +and a `reboot` will apply it. + +For more on valid transports, see [containers-transports](https://github.com/containers/image/blob/main/docs/containers-transports.5.md). diff --git a/docs/src/bootloaders.md b/docs/src/bootloaders.md new file mode 100644 index 000000000..664c9a5a1 --- /dev/null +++ b/docs/src/bootloaders.md @@ -0,0 +1,21 @@ +# Bootloaders in `bootc` + +`bootc` supports two ways to manage bootloaders. + +## bootupd + +[bootupd](https://github.com/coreos/bootupd/) is a project explicitly designed to abstract over and manage bootloader installation and configuration. +Today it primarily supports GRUB+shim. There are pending patches for it to support systemd-boot as well. + +When you run `bootc install`, it invokes `bootupctl backend install` to install the bootloader to the target disk or filesystem. The specific bootloader configuration is determined by the container image and the target system's hardware. + +Currently, `bootc` only runs `bootupd` during the installation process. It does **not** automatically run `bootupctl update` to update the bootloader after installation. This means that bootloader updates must be handled separately, typically by the user or an automated system update process. + +## systemd-boot + +If bootupd is not present in the input container image, then systemd-boot will be used +by default (except on s390x). + +## s390x + +bootc uses `zipl`. diff --git a/docs/src/building/bootc-runtime.md b/docs/src/building/bootc-runtime.md new file mode 100644 index 000000000..4e6e690f5 --- /dev/null +++ b/docs/src/building/bootc-runtime.md @@ -0,0 +1,91 @@ + +# Container runtime vs "bootc runtime" + +Fundamentally, `bootc` reuses the [OCI image format](https://github.com/opencontainers/image-spec) +as a way to transport serialized filesystem trees with included metadata such as a `version` +label, etc. + +A bootc container operates in two basic modes. First, when invoked by a container run time such as `podman` or `docker` (typically as part of a build process), the bootc container behaves exactly the same as any other container. For example, although there is a kernel embedded in the container image, it is not executed - the host kernel is used. There's no additional mount namespaces, etc. Ultimately, the container runtime is in full control here. + +The second, and most important mode of operation is when a bootc container is installed to a physical or virtual machine. Here, bootc is in control; the container runtime used to build is no longer relevant. However, it's *very* important to understand that bootc's role is quite limited: + +- On boot, there is code in the initramfs to do a "chroot" equivalent into the target filesystem root +- On upgrade, bootc will fetch new content, but this will not affect the running root + +Crucially, besides setting up some mounts, bootc itself does not act as any kind of "container runtime". It does not set up pid or other namespace, does not change cgroups, etc. That remains the role of other code (typically systemd). `bootc` is not a persistent daemon by default; it does not impose any runtime overhead. + +Another example of this: While one can add [Container configuration](https://github.com/opencontainers/image-spec/blob/main/config.md) metadata, `bootc` generally ignores that at runtime today. + +## Labels + +A key aspect of OCI is the ability to use standardized (or semi-standardized) +labels. The are stored and rendered by `bootc`; especially the +`org.opencontainers.image.version` label. + +## Example ignored runtime metadata, and recommendations + +### `ENTRYPOINT` and `CMD` (OCI: `Entrypoint`/`Cmd`) + +Ignored by bootc. + +It's recommended for bootc containers to set `CMD /sbin/init`; but this is not required. + +The booted host system will launch from the bootloader, to the kernel+initramfs and +real root however it is "physically" configured inside the image. Typically +today this is using [systemd](https://systemd.io/) in both the initramfs +and at runtime; but this is up to how you build the image. + +### `ENV` (OCI: `Env`) + +Ignored by bootc; to configure the global system environment you can +change the systemd configuration. (Though this is generally not a good idea; +instead it's usually better to change the environment of individual services) + +### `EXPOSE` (OCI: `exposedPorts`) + +Ignored by bootc; it is agnostic to how the system firewall and network +function at runtime. + +### `USER` (OCI: `User`) + +Ignored by bootc; typically you should configure individual services inside +the bootc container to run as unprivileged users instead. + +### `HEALTHCHECK` (OCI: *no equivalent*) + +This is currently a Docker-specific metadata, and did not make it into the +OCI standards. (Note [podman healthchecks](https://developers.redhat.com/blog/2019/04/18/monitoring-container-vitality-and-availability-with-podman#)) + +It is important to understand again is that there is no "outer container runtime" when a +bootc container is deployed on a host. The system must perform health checking on itself (or have an external +system do it). + +Relevant links: + +- [bootc rollback](../man/bootc-rollback.8.md) +- [CentOS Automotive SIG unattended updates](https://sigs.centos.org/automotive/building/unattended_updates/#watchdog-in-qemu) + (note that as of right now, greenboot does not yet integrate with bootc) +- + + +## Kernel + +When run as a container, the Linux kernel binary in +`/usr/lib/modules/$kver/vmlinuz` is ignored. It +is only used when a bootc container is deployed +to a physical or virtual machine. + +## Security properties + +When run as a container, the container runtime will by default apply +various Linux kernel features such as namespacing to isolate +the container processes from other system processes. + +None of these isolation properties apply when a bootc +system is deployed. + +## SELinux + +For more on the intersection of SELinux and current bootc (OSTree container) +images, see [bootc images - SELinux](../bootc-images.md#SELinux). + diff --git a/docs/src/building/guidance.md b/docs/src/building/guidance.md new file mode 100644 index 000000000..460988f1b --- /dev/null +++ b/docs/src/building/guidance.md @@ -0,0 +1,181 @@ +# Generic guidance for building images + +The bootc project intends to be operating system and distribution independent as possible, +similar to its related projects [podman](http://podman.io/) and [systemd](https://systemd.io/), +etc. + +The recommendations for creating bootc-compatible images will in general need to +be owned by the OS/distribution - in particular the ones who create the default +bootc base image(s). However, some guidance is very generic to most Linux +systems (and bootc only supports Linux). + +Let's however restate a base goal of this project: + +> The original Docker container model of using "layers" to model +> applications has been extremely successful. This project +> aims to apply the same technique for bootable host systems - using +> standard OCI/Docker containers as a transport and delivery format +> for base operating system updates. + +Every tool and technique for creating application base images +should apply to the host Linux OS as much as possible. + +## Understanding mutability + +When run as a container (particularly as part of a build), bootc-compatible +images have all parts of the filesystem (e.g. `/usr` in particular) as fully +mutable state, and writing there is encouraged (see below). + +When "deployed" to a physical or virtual machine, the container image +files are read-only by default; for more, see [filesystem](../filesystem.md). + +## Installing software + +For package management tools like `apt`, `dnf`, `zypper` etc. +(generically, `$pkgsystem`) it is very much expected that +the pattern of + +`RUN $pkgsystem install somepackage && $pkgsystem clean all` + +type flow Just Works here - the same way as it does +"application" container images. This pattern is really how +Docker got started. + +There's not much special to this that doesn't also apply +to application containers; but see below. + +### Nesting OCI containers in bootc containers + +The [OCI format](https://github.com/opencontainers/image-spec/blob/main/spec.md) uses +"whiteouts" represented in the tar stream as special `.wh` files, and typically +consumed by the Linux kernel `overlayfs` driver as special `0:0` character +devices. Without special work, whiteouts cannot be nested. + +Hence, an invocation like + +``` +RUN podman pull quay.io/exampleimage/someimage +``` + +will create problems, as the `podman` runtime will create whiteout files +inside the container image filesystem itself. + +Special care and code changes will need to be made to container +runtimes to support such nesting. Some more discussion in +[this tracker issue](https://github.com/bootc-dev/bootc/issues/128). + +## systemd units + +The model that is most popular with the Docker/OCI world +is "microservice" style containers with the application as +pid 1, isolating the applications from each other and +from the host system - as opposed to "system containers" +which run an init system like systemd, typically also +SSH and often multiple logical "application" components +as part of the same container. + +The bootc project generally expects systemd as pid 1, +and if you embed software in your derived image, the +default would then be that that software is initially +launched via a systemd unit. + +```dockerfile +RUN dnf -y install postgresql && dnf clean all +``` + +Would typically also carry a systemd unit, and that +service will be launched the same way as it would +on a package-based system. + +## Users and groups + +Note that the above `postgresql` today will allocate a user; +this leads to the topic of [users, groups and SSH keys](users-and-groups.md). + +## Configuration + +A key aspect of choosing a bootc-based operating system model +is that *code* and *configuration* can be strictly "lifecycle bound" +together in exactly the same way. + +(Today, that's by including the configuration into the base + container image; however a future enhancement for bootc + will also support dynamically-injected ConfigMaps, similar + to kubelet) + +You can add configuration files to the same places they're +expected by typical package systems on Debian/Fedora/Arch +etc. and others - in `/usr` (preferred where possible) +or `/etc`. systemd has long advocated and supported +a model where `/usr` (e.g. `/usr/lib/systemd/system`) +contains content owned by the operating system image. + +`/etc` is machine-local state. However, per [filesystem.md](../filesystem.md) +it's important to note that the underlying OSTree +system performs a 3-way merge of `/etc`, so changes you +make in the container image to e.g. `/etc/postgresql.conf` +will be applied on update, assuming it is not modified +locally. + +### Prefer using drop-in directories + +These "locally modified" files can be a source of state drift. The best +pattern to use is "drop-in" directories that are merged dynamically by +the relevant software. systemd supports this comprehensively; see +[drop-ins](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html) +for example in units. + +And instead of modifying `/etc/sudoers.conf`, it's best practice to add +a file into `/etc/sudoers.d` for example. + +Not all software supports this, however; and this is why there +is generic support for `/etc`. + +### Configuration in /usr vs /etc + +Some software supports generic configuration both `/usr` and `/etc` - systemd, +among others. Because bootc supports *derivation* (the way OCI +containers work) - it is supported and encouraged to put configuration +files in `/usr` (instead of `/etc`) where possible, because then +the state is consistently immutable. + +One pattern is to replace a configuration file like +`/etc/postgresql.conf` with a symlink to e.g. `/usr/postgres/etc/postgresql.conf` +for example, although this can run afoul of SELinux labeling. + +### Secrets + +There is a dedicated document for [secrets](secrets.md), +which is a special case of configuration. + +## Handling read-only vs writable locations + +The high level pattern for bootc systems is summarized again +this way: + +- Put read-only data and executables in `/usr` +- Put configuration files in `/usr` (if they're static), or `/etc` if they need to be machine-local +- Put "data" (log files, databases, etc.) underneath `/var` + +However, some software installs to `/opt/examplepkg` or another +location outside of `/usr`, and may include all three types of data +undernath its single toplevel directory. For example, it +may write log files to `/opt/examplepkg/logs`. A simple way to handle +this is to change the directories that need to be writable to symbolic links +to `/var`: + +```dockerfile +RUN apt|dnf install examplepkg && \ + mv /opt/examplepkg/logs /var/log/examplepkg && \ + ln -sr /var/log/examplepkg /opt/examplepkg/logs +``` + +The [Fedora/CentOS bootc puppet example](https://gitlab.com/fedora/bootc/examples/-/tree/main/opt-puppet) +is one instance of this. + +Another option is to configure the systemd unit launching the service to do these mounts +dynamically via e.g. + +``` +BindPaths=/var/log/exampleapp:/opt/exampleapp/logs +``` diff --git a/docs/src/building/kernel-arguments.md b/docs/src/building/kernel-arguments.md new file mode 100644 index 000000000..2d619fc49 --- /dev/null +++ b/docs/src/building/kernel-arguments.md @@ -0,0 +1,103 @@ +# Kernel arguments + +The default bootc model uses ["type 1" bootloader config](https://uapi-group.org/specifications/specs/boot_loader_specification/) +files stored in `/boot/loader/entries`, which define arguments +provided to the Linux kernel. + +The set of kernel +arguments can be machine-specific state, but can also +be managed via container updates. + +The bootloader entries are currently written by the OSTree backend. + +More on Linux kernel arguments: + +## /usr/lib/bootc/kargs.d + +Many bootc use cases will use generic "OS/distribution" kernels. +In order to support injecting kernel arguments, bootc supports +a small custom config file format in `/usr/lib/bootc/kargs.d` in +TOML format, that have the following form: + +``` +# /usr/lib/bootc/kargs.d/10-example.toml +kargs = ["mitigations=auto,nosmt"] +``` + +There is also support for making these kernel arguments +architecture specific via the `match-architectures` key: + +``` +# /usr/lib/bootc/kargs.d/00-console.toml +kargs = ["console=ttyS0,115200n8"] +match-architectures = ["x86_64"] +``` + +NOTE: The architecture matching here accepts values defined +by the [Rust standard library](https://doc.rust-lang.org/std/env/consts/constant.ARCH.html) +(using the architecture of the `bootc` binary itself). + +In some cases for Linux, this matches the value of `uname -m`, but +definitely not all. For example, on Fedora derivatives there is `ppc64le`, +but in Rust only `powerpc64`. A common discrepancy is that +Debian derivatives use `amd64`, whereas Rust (and Fedora derivatives) +use `x86_64`. + +### Changing kernel arguments post-install via kargs.d + +Changes to `kargs.d` files included in a container build +are honored post-install; the difference between the set of +kernel arguments is applied to the current bootloader +configuration. This will preserve any machine-local +kernel arguments. + +## Kernel arguments injected at installation time + +The `bootc install` flow supports a `--karg` to provide +install-time kernel arguments. These become machine-local +state. + +Higher level install tools (ideally at least using `bootc install to-filesystem` +can inject kernel arguments this way) too; for example, +the [Anaconda installer](https://github.com/rhinstaller/anaconda) +has a `bootloader` verb which ultimately uses an API +similar to this. + +Post-install, it is supported for any tool to edit +the `/boot/loader/entries` files, which are in a standardized +format. + +Typically, `/boot` is mounted read-only to limit +the set of tools which write to this filesystem. It is not +"physically" read-only by default. One approach to edit +them is to run a tool under a new mount namespace, e.g. + +```bash +unshare -m +mount -o remount,rw /boot +# tool to edit /boot/loader/entries +``` + +At the current time, `bootc` does not itself offer +an API to manipulate kernel arguments maintained per-machine. + +Other projects such as `rpm-ostree` do, via e.g. `rpm-ostree kargs`, +which is just a frontend for editing the bootloader configuration +files. Note an important detail is that `rpm-ostree kargs` always +creates a new deployment. + +`rpm-ostree kargs` and bootc will interoperate as they both +use the ostree backend today, and any kernel arguments changed +via that mechanism will persist across upgrades. + +It is currently undefined behavior to remove kernel arguments +locally that are included in the base image via +`/usr/lib/bootc/kargs.d`. + +## Injecting default arguments into custom kernels + +The Linux kernel supports building in arguments into the kernel +binary, at the time of this writing via the `config CMDLINE` +build option. If you are building a custom kernel, then +it often makes sense to use this instead of `/usr/lib/bootc/kargs.d` +for example. diff --git a/docs/src/building/management-services.md b/docs/src/building/management-services.md new file mode 100644 index 000000000..1740c8bc3 --- /dev/null +++ b/docs/src/building/management-services.md @@ -0,0 +1,51 @@ +# Management services + +When running a fleet of systems, it is common to use a central management service. Commonly, these services provide a client to be installed on each system which connects to the central service. Often, the management service requires the client to perform a one time registration. + +The following example shows how to install the client into a bootc image and run it at startup to register the system. This example assumes the management-client handles future connections to the server, e.g. via a cron job or a separate systemd service. This example could be modified to create a persistent systemd service if that is required. The Containerfile is not optimized in order to more clarly explain each step, e.g. it's generally better to invoke RUN a single time to avoid creating multiple layers in the image. + +```Dockerfile +FROM + +# Typically when using a management service, it will determine when to upgrade the system. +# So, disable bootc-fetch-apply-updates.timer if it is included in the base image. +RUN systemctl disable bootc-fetch-apply-updates.timer + +# Install the client from dnf, or some other method that applies for your client +RUN dnf install management-client -y && dnf clean all + +# Bake the credentials for the management service into the image +ARG activation_key= + +# The existence of .run_next_boot acts as a flag to determine if the +# registration is required to run when booting +RUN touch /etc/management-client/.run_next_boot + +COPY <<"EOT" /usr/lib/systemd/system/management-client.service +[Unit] +Description=Run management client at boot +After=network-online.target +ConditionPathExists=/etc/management-client/.run_client_next_boot + +[Service] +Type=oneshot +EnvironmentFile=/etc/management-client/.credentials +ExecStart=/usr/bin/management-client register --activation-key ${CLIENT_ACTIVATION_KEY} +ExecStartPre=/bin/rm -f /etc/management-client/.run_next_boot +ExecStop=/bin/rm -f /etc/management-client/.credentials + +[Install] +WantedBy=multi-user.target +EOT + +# Link the service to run at startup +RUN ln -s /usr/lib/systemd/system/management-client.service /usr/lib/systemd/system/multi-user.target.wants/management-client.service + +# Store the credentials in a file to be used by the systemd service +RUN echo -e "CLIENT_ACTIVATION_KEY=${activation_key}" > /etc/management-client/.credentials + +# Set the flag to enable the service to run one time +# The systemd service will remove this file after the registration completes the first time +RUN touch /etc/management-client/.run_next_boot +``` + diff --git a/docs/src/building/secrets.md b/docs/src/building/secrets.md new file mode 100644 index 000000000..7bf100e28 --- /dev/null +++ b/docs/src/building/secrets.md @@ -0,0 +1,88 @@ + +# Secrets (e.g. container pull secrets) + +To have `bootc` fetch updates from registry which requires authentication, +you must include a pull secret in one of `/etc/ostree/auth.json`, +`/run/ostree/auth.json` or `/usr/lib/ostree/auth.json`. + +The path to the authentication file differs from that used +by e.g. `podman` by default as some of the file paths used +there are not appropriate for system services (e.g. reading +the `/root` home directory). + +Regardless, injecting this data is a good example of a generic +"secret". The bootc project does not currently include one +single opinionated mechanism for secrets. + +## Synchronizing the bootc and podman credentials + +See the [containers-auth.json](https://github.com/containers/image/blob/main/docs/containers-auth.json.5.md) man page. In many cases, you will +want to keep both the bootc and podman/skopeo credentials +in sync. One pattern is to symlink the two via e.g. a systemd `tmpfiles.d` fragment. + +If you have a process invoking `podman login` (which by default writes to +an ephemeral `$XDG_RUNTIME_DIR/containers/auth.json`) you can then +`ln -s /run/user/0/containers/auth.json /run/ostree/auth.json`. + +## Performing an explicit login + +If you have automation (or manual processes) performing a login, +you can pass `--authfile` to set the bootc authfile explicitly; +for example + +```bash +echo | podman login --authfile /run/ostree/auth.json -u someuser --password-stdin +``` + +This pattern of using the ephemeral location in `/run` can work +well when the credentials are derived on system start from +an external system. For example, `aws ecr get-login-password --region region` +as suggested by [this document](https://docs.aws.amazon.com/AmazonECR/latest/userguide/Podman.html). + +You can also use the machine-local persistent location `/etc/ostree/auth.json` +via this method. + +## Using a credential helper + +In order to use a credential helper as configured in `registries.conf` +such as `credential-helpers = ["ecr-login"]`, you must currently +also write a "no-op" authentication file with the contents `{}` (i.e. an +empty JSON object, not an empty file) into the pull secret location. + +## Embedding in container build + +This was mentioned above; you can include secrets in +the container image if the registry server is suitably protected. + +In some cases, embedding only "bootstrap" secrets into the container +image is a viable pattern, especially alongside a mechanism for +having a machine authenticate to a cluster. In this pattern, +a provisioning tool (whether run as part of the host system +or a container image) uses the bootstrap secret to lay down +and keep updated other secrets (for example, SSH keys, +certificates). + +## Via cloud metadata + +Most production IaaS systems support a "metadata server" or equivalent +which can securely host secrets - particularly "bootstrap secrets". +Your container image can include tooling such as `cloud-init` +or `ignition` which fetches these secrets. + +## Embedded in disk images + +Another pattern is to embed bootstrap secrets only in disk images. +For example, when generating a cloud disk image (AMI, OpenStack glance image, etc.) +from an input container image, the disk image can contain secrets that +are effectively machine-local state. Rotating them would +require an additional management tool, or refreshing disk images. + +## Injected via baremetal installers + +It is common for installer tools to support injecting configuration +which can commonly cover secrets like this. + +## Injecting secrets via systemd credentials + +The systemd project has documentation for [credentials](https://systemd.io/CREDENTIALS/) +which applies in some deployment methodologies. diff --git a/docs/src/building/users-and-groups.md b/docs/src/building/users-and-groups.md new file mode 100644 index 000000000..9de0b43cf --- /dev/null +++ b/docs/src/building/users-and-groups.md @@ -0,0 +1,263 @@ + +# Users and groups + +This is one of the more complex topics. Generally speaking, bootc has nothing to +do directly with configuring users or groups; it is a generic OS +update/configuration mechanism. (There is currently just one small exception in +that `bootc install` has a special case `--root-ssh-authorized-keys` argument, +but it's very much optional). + +## Generic base images + +Commonly OS/distribution base images will be generic, i.e. +without any configuration. It is *very strongly recommended* +to avoid hardcoded passwords and ssh keys with publicly-available +private keys (as Vagrant does) in generic images. + +### Injecting SSH keys via systemd credentials + +The systemd project has documentation for [credentials](https://systemd.io/CREDENTIALS/) +which can be used in some environments to inject a root +password or SSH authorized_keys. For many cases, this +is a best practice. + +At the time of this writing this relies on SMBIOS which +is mainly configurable in local virtualization environments. +(qemu). + +### Injecting users and SSH keys via cloud-init, etc. + +Many IaaS and virtualization systems are oriented towards a "metadata server" +(see e.g. [AWS instance metadata](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html)) +that are commonly processed by software such as [cloud-init](https://cloud-init.io/) +or [Ignition](https://github.com/coreos/ignition) or equivalent. + +The base image you're using may include such software, or you +can install it in your own derived images. + +In this model, SSH configuration is managed outside of the bootable +image. See e.g. [GCP oslogin](https://cloud.google.com/compute/docs/oslogin/) +for an example of this where operating system identities are linked +to the underlying Google accounts. + +### Adding users and credentials via custom logic (container or unit) + +Of course, systems like `cloud-init` are not privileged; you +can inject any logic you want to manage credentials via +e.g. a systemd unit (which may launch a container image) +that manages things however you prefer. Commonly, +this would be a custom network-hosted source. For example, +[FreeIPA](https://www.freeipa.org/page/Main_Page). + +Another example in a Kubernetes-oriented infrastructure would +be a container image that fetches desired authentication +credentials from a [CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) +hosted in the API server. (To do things like this +it's suggested to reuse the kubelet credentials) + +### System users and groups (added via packages, etc) + +It is common for packages (deb/rpm/etc) to allocate system users +or groups as part of e.g `apt|dnf install ` such as Apache or MySQL, +and this is often done by directly invoking `useradd` or `groupadd` as part +of package pre/post installation scripts. + +With the`shadow-utils` implementation of `useradd` and the default glibc `files` this will +result in changes to the traditional `/etc/passwd` and `/etc/shadow` files +as part of the container build. + +#### System drift from local /etc/passwd modifications + +When the system is initially installed, the `/etc/passwd` in the container image will be +applied and contain desired users. + +By default (without `etc = transient`, see below), the `/etc` directory is machine-local +persistent state. If subsequently `/etc/passwd` is modified local to the machine +(as is common for e.g. setting a root password) then any new changes in the container +image (such as users from new packages) *will not appear* on subsequent updates by default (they will be +in `/usr/etc/passwd` instead - the default image version). + +The general best fix for this is to use `systemd-sysusers` instead of allocating +a user/group at build time at all. + +##### Using systemd-sysusers + +See [systemd-sysusers](https://www.freedesktop.org/software/systemd/man/latest/systemd-sysusers.html). +For example in your derived build: + +``` +COPY mycustom-user.conf /usr/lib/sysusers.d +``` + +A key aspect of how this works is that `sysusers` will make changes +to the traditional `/etc/passwd` file as necessary on boot instead +of at build time. If `/etc` is persistent, this can avoid uid/gid drift (but +in the general case it does mean that uid/gid allocation can +depend on how a specific machine was upgraded over time). + +Note that the default `sysusers` design is that users are allocated on the client +side (per machine). Avoid having non-root owned files managed by `sysusers` +inside your image, especially underneath `/usr`. With the exception of +`setuid` or `setgid` binaries (which should also be strongly avoided), there is +generally no valid reason for having non-root owned files in `/usr` or other +runtime-immutable directories. + +#### User and group home directories and `/var` + +For systems configured with persistent `/home` → `/var/home`, any changes to `/var` made +in the container image after initial installation *will not be applied on subsequent updates*. If for example you inject `/var/home/someuser/.ssh/authorized_keys` +into a container build, existing systems will *not* get the updated authorized keys file. + +#### Using DynamicUser=yes for systemd units + +For "system" users it's strongly recommended to use systemd [DynamicUser=yes](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#DynamicUser=) where +possible. + +This is significantly better than the pattern of allocating users/groups +at "package install time" (e.g. [Fedora package user/group guidelines](https://docs.fedoraproject.org/en-US/packaging-guidelines/UsersAndGroups/)) because +it avoids potential UID/GID drift (see below). + +#### Using systemd JSON user records + +See [JSON user records](https://systemd.io/USER_RECORD/). Unlike `sysusers`, +the canonical state for these live in `/usr` - if a subsequent +image drops a user record, then it will also vanish +from the system - unlike `sysusers.d`. + +#### nss-altfiles + +The [nss-altfiles](https://github.com/aperezdc/nss-altfiles) project +(long) predates systemd JSON user records. It aims to help split +"system" users into `/usr/lib/passwd` and `/usr/lib/group`. It's +very important to understand that this aligns with the way +the OSTree project handles the "3 way merge" for `/etc` as it +relates to `/etc/passwd`. Currently, if the `/etc/passwd` file is +modified in any way on the local system, then subsequent changes +to `/etc/passwd` in the container image *will not be applied*. + +Some base images may have `nss-altfiles` enabled by default; +this is currently the case for base images built by +[rpm-ostree](https://github.com/coreos/rpm-ostree). + +Commonly, base images will have some "system" users pre-allocated +and managed via this file again to avoid uid/gid drift. + +In a derived container build, you can also append users +to `/usr/lib/passwd` for example. (At the time of this +writing there is no command line to do so though). + +Typically it is more preferable to use `sysusers.d` +or `DynamicUser=yes`. + +### Machine-local state for users + +At this point, it is important to understand the [filesystem](../filesystem.md) +layout - the default is up to the base image. + +The default Linux concept of a user has data stored in both `/etc` (`/etc/passwd`, `/etc/shadow` and groups) +and `/home`. The choice for how these work is up to the base image, but +a common default for generic base images is to have both be machine-local persistent state. +In this model `/home` would be a symlink to `/var/home/someuser`. + +#### Injecting users and SSH keys via at system provisioning time + +For base images where `/etc` and `/var` are configured to persist by default, it +will then be generally supported to inject users via "installers" such +as [Anaconda](https://github.com/rhinstaller/anaconda/) (interactively or +via kickstart) or any others. + +Typically generic installers such as this are designed for "one time bootstrap" +and again then the configuration becomes mutable machine-local state +that can be changed "day 2" via some other mechanism. + +The simple case is a user with a password - typically the installer helps +set the initial password, but to change it there is a different in-system +tool (such as `passwd` or a GUI as part of [Cockpit](https://cockpit-project.org/), GNOME/KDE/etc). + +It is intended that these flows work equivalently in a bootc-compatible +system, to support users directly installing "generic" base images, without +requiring changes to the tools above. + +#### Transient home directories + +Many operating system deployments will want to minimize persistent, +mutable and executable state - and user home directories are that + +But it is also valid to default to having e.g. `/home` be a `tmpfs` +to ensure user data is cleaned up across reboots (and this pairs particularly +well with a transient `/etc` as well): + +In order to set up the user's home directory to e.g. inject SSH `authorized_keys` +or other files, a good approach is to use systemd `tmpfiles.d` snippets: + +``` +f~ /home/someuser/.ssh/authorized_keys 600 someuser someuser - +``` +which can be embedded in the image as `/usr/lib/tmpfiles.d/someuser-keys.conf`. + +Or a service embedded in the image can fetch keys from the network and write +them; this is the pattern used by cloud-init and [afterburn](https://github.com/coreos/afterburn). + +### UID/GID drift + +Any invocation of `useradd` or `groupadd` that does not allocate a *fixed* UID/GID may +be subject to "drift" in subsequent rebuilds by default. + +One possibility is to explicitly force these user/group allocations into a static +state, via `systemd-sysusers` (per above) or explicitly adding the users with +static IDs *before* a dpkg/RPM installation script operates on it: + +``` +RUN < + +## Using `bootc image copy-to-storage` + +This experimental command is intended to aid in [booting local builds](booting-local-builds.md). + +Invoking this command will default to copying the booted container image into the `containers-storage:` +area as used by e.g. `podman`, under the image tag `localhost/bootc` by default. It can +then be managed independently; used as a base image, pushed to a registry, etc. + +Run `bootc image copy-to-storage --help` for more options. + +Example workflow: + +``` +$ bootc image copy-to-storage +$ cat Containerfile +FROM localhost/bootc +... +$ podman build -t localhost/bootc-custom . +$ bootc switch --transport containers-storage localhost/bootc-custom +``` + diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md new file mode 100644 index 000000000..37d94d7cd --- /dev/null +++ b/docs/src/experimental-composefs.md @@ -0,0 +1,85 @@ +# composefs backend + +Experimental features are subject to change or removal. Please +do provide feedback on them. + +Tracking issue: + +## Overview + +The composefs backend is an experimental alternative storage backend that uses [composefs-rs](https://github.com/containers/composefs-rs) instead of ostree for storing and managing bootc system deployments. + +**Status**: Experimental. The composefs backend is under active development and not yet suitable for production use. The feature is always compiled in as of bootc v1.10.1. + +## Key Benefits + +- **Native container integration**: Direct use of container image formats without the ostree layer +- **UKI support**: First-class support for Unified Kernel Images (UKIs) and systemd-boot +- **Sealed images**: Enables building cryptographically sealed, securely-bootable images +- **Simpler architecture**: Reduces dependency on ostree as an implementation detail + +## Building Sealed Images + +### Using `just build-sealed` + +This is an entrypoint focused on *bootc development* itself - it builds bootc +from source. + +```bash +just build-sealed +``` + +We are working on documenting individual steps to build a sealed image outside of +this tooling. + +## How Sealed Images Work + +A sealed image includes: +- A Unified Kernel Image (UKI) that combines kernel, initramfs, and boot parameters +- The composefs fsverity digest embedded in the kernel command line +- Secure Boot signatures on both the UKI and systemd-boot loader + +The UKI is placed in `/boot/EFI/Linux/` and includes the composefs digest in its command line: +``` +composefs=${COMPOSEFS_FSVERITY} root=UUID=... +``` + +This enables the boot chain to verify the integrity of the root filesystem. + +## Installation + +When installing a composefs-backend system, use: + +```bash +bootc install to-disk /dev/sdX +``` + +**Note**: Sealed images will require fsverity support on the target filesystem by default. + +## Testing Composefs + +To run the composefs integration tests: + +```bash +just test-composefs +``` + +This builds a sealed image and runs the composefs test suite using `bcvk` (bootc VM tooling). + +## Current Limitations + +- **Experimental**: In particular, the on-disk formats are subject to change +- **UX refinement**: The user experience for building and managing sealed images is still being improved + +## Related Issues + +- [#1190](https://github.com/bootc-dev/bootc/issues/1190) - composefs-native backend (main tracker) +- [#1498](https://github.com/bootc-dev/bootc/issues/1498) - Sealed image build UX + implementation +- [#1703](https://github.com/bootc-dev/bootc/issues/1703) - OCI config mismatch issues +- [#20](https://github.com/bootc-dev/bootc/issues/20) - Unified storage (long-term goal) +- [#806](https://github.com/bootc-dev/bootc/issues/806) - UKI/systemd-boot tracker + +## Additional Resources + +- See [filesystem.md](filesystem.md) for information about composefs in the standard ostree backend +- See [bootloaders.md](bootloaders.md) for bootloader configuration details diff --git a/docs/src/experimental-fsck.md b/docs/src/experimental-fsck.md new file mode 100644 index 000000000..2e0efdea7 --- /dev/null +++ b/docs/src/experimental-fsck.md @@ -0,0 +1,9 @@ +# bootc internals fsck + +Experimental features are subject to change or removal. Please +do provide feedback on them. + +## Using `bootc internals fsck` + +This command expects a booted system, and performs consistency checks +in a read-only fashion. diff --git a/docs/src/experimental-install-reset.md b/docs/src/experimental-install-reset.md new file mode 100644 index 000000000..fc5c0a2ac --- /dev/null +++ b/docs/src/experimental-install-reset.md @@ -0,0 +1,118 @@ +# Factory reset with `bootc install reset` + +This is an experimental feature; use `--experimental` flag to acknowledge. + +## Overview + +The `bootc install reset` command allows you to perform a non-destructive factory reset of an existing bootc system. This creates a fresh installation state in a new stateroot while preserving the existing system's files on disk. After rebooting into the new deployment, you can still access the old system's data by examining files in `/sysroot/ostree/deploy//`. + +## How it works + +When you run `bootc install reset`: + +1. A new stateroot is created with an automatically generated name (format: `state--`, e.g., `s2025-0`) +2. A fresh deployment is created in the new stateroot using the currently booted image (or optionally a different image via `--target-imgref`) +3. Kernel arguments related to root filesystem configuration are automatically inherited from the current deployment +4. The `/boot` fstab entry is preserved from the current system if it exists +5. The new deployment becomes the default boot target + +After rebooting, you'll be running in a completely fresh system state: +- `/etc` contains only the configuration from the container image +- `/var` is empty (no user data or state from the previous system) +- The old stateroot's files remain on disk at `/sysroot/ostree/deploy//` and can be accessed for data recovery or inspection + +## Usage + +Basic usage (reset to the same image currently running): + +```bash +bootc install reset --experimental +``` + +Reset and switch to a different image: + +```bash +bootc install reset --experimental --target-imgref quay.io/example/myimage:latest +``` + +Reset with custom stateroot name: + +```bash +bootc install reset --experimental --stateroot production-2025 +``` + +Reset and immediately reboot: + +```bash +bootc install reset --experimental --apply +``` + +Add custom kernel arguments: + +```bash +bootc install reset --experimental --karg=console=ttyS0,115200n8 +``` + +Skip inheriting root filesystem kernel arguments: + +```bash +bootc install reset --experimental --no-root-kargs +``` + +## Kernel arguments + +By default, `bootc install reset` automatically inherits kernel arguments from the currently booted deployment that are related to root filesystem configuration. This includes: + +- `root=` - Root device specification +- `rootflags=` - Root filesystem mount options +- `rd.*` arguments - Initramfs arguments (e.g., for LVM, LUKS, network root) +- Kernel arguments defined in `/usr/lib/bootc/kargs.d/` and `/etc/bootc/kargs.d/` + +You can: +- Add additional kernel arguments with `--karg` (can be specified multiple times) +- Skip automatic root filesystem argument inheritance with `--no-root-kargs` + +## Use cases + +- **Development/testing**: Quickly return to a clean state while preserving the ability to boot back to your development environment +- **Troubleshooting**: Reset to a known-good state without losing access to the problematic deployment for debugging +- **System refresh**: Start fresh after accumulating configuration changes, while keeping the old state accessible +- **Image testing**: Test a new image version in a separate stateroot before committing to it + +## Cleaning up the old stateroot + +After performing a factory reset and rebooting into the new stateroot, the old stateroot remains on disk at `/sysroot/ostree/deploy//`. This allows you to access files from the previous system if needed. + +Once you no longer need the old stateroot, you can remove it to free up disk space: + +1. First, remove any remaining deployments from the old stateroot: + +```bash +# List all deployments to find the old stateroot's deployment index +ostree admin status + +# Remove the old deployment(s) by index +# The index is shown in the output (e.g., "1" for the second deployment) +ostree admin undeploy +``` + +2. After all deployments from the old stateroot are removed, you can delete the stateroot directory: + +```bash +# Replace "default" with your old stateroot name if different +mount -o remount,rw /sysroot +rm -rf /sysroot/ostree/deploy/default +``` + +**Note:** You cannot remove the stateroot directory while deployments still exist in it. OSTree protects deployment directories with filesystem-level mechanisms, so you must undeploy them first using `ostree admin undeploy`. + +## Limitations + +- This command requires `--experimental` flag as the feature is still under development +- Only works on systems already running bootc (not for initial installations) +- The old stateroot is not automatically removed and will consume disk space until manually deleted (see "Cleaning up the old stateroot" section above) + +## See also + +- `bootc switch` - Switch to a different container image +- `bootc status` - View current deployment status diff --git a/docs/src/experimental-progress-fd.md b/docs/src/experimental-progress-fd.md new file mode 100644 index 000000000..597b86a38 --- /dev/null +++ b/docs/src/experimental-progress-fd.md @@ -0,0 +1,32 @@ + +# Interactive progress with `--progress-fd` + +This is an experimental feature; tracking issue: + +While the `bootc status` tooling allows a client to discover the state +of the system, during interactive changes such as `bootc upgrade` +or `bootc switch` it is possible to monitor the status of downloads +or other operations at a fine-grained level with `--progress-fd`. + +The format of data output over `--progress-fd` is [JSON Lines](https://jsonlines.org) +which is a series of JSON objects separated by newlines (the intermediate +JSON content is guaranteed not to contain a literal newline). + +You can find the JSON schema describing this version here: +[progress-v0.schema.json](progress-v0.schema.json). + +Deploying a new image with either switch or upgrade consists +of three stages: `pulling`, `importing`, and `staging`. The `pulling` step +downloads the image from the registry, offering per-layer and progress in +each message. The `importing` step imports the image into storage and consists +of a single step. Finally, `staging` runs a variety of staging +tasks. Currently, they are staging the image to disk, pulling bound images, +and removing old images. + +Note that new stages or fields may be added at any time. + +Importing and staging are affected by disk speed and the total image size. Pulling +is affected by network speed and how many layers invalidate between pulls. +Therefore, a large image with a good caching strategy will have longer +importing and staging times, and a small bespoke container image will have +negligible importing and staging times. diff --git a/docs/src/filesystem-storage.md b/docs/src/filesystem-storage.md new file mode 100644 index 000000000..316cc293e --- /dev/null +++ b/docs/src/filesystem-storage.md @@ -0,0 +1,88 @@ +# Container storage + +The bootc project uses [ostree](https://github.com/ostreedev/ostree/) and specifically +the [ostree-rs-ext](https://github.com/ostreedev/ostree-rs-ext/) Rust library +which handles storage of container images on top of an ostree-based system for +the booted host, and additionally there is a +[containers/storage](https://github.com/containers/storage) instance for [logically bound images](logically-bound-images.md). + +## Architecture + +```mermaid +flowchart TD + bootc --- ostree-rs-ext --- ostree-rs --- ostree + ostree-rs-ext --- containers-image-proxy-rs --- skopeo --- containers/image + bootc --- podman --- image-storage["containers/{image,storage}"] +``` + +There were two high level goals that drove the design of the current system +architecture: + +- Support seamless in-place migrations from existing ostree systems +- Avoid requiring deep changes to the podman stack + +A simple way to explain the current architecture is that podman uses +two Go libraries: + +- +- + +Whereas ostree uses a custom container storage, not `containers/storage`. + +## Mapping container images to ostree + +[OCI images](https://github.com/opencontainers/image-spec) are effectively +just a standardized format of tarballs wrapped with JSON - specifically +"layers" of tarballs. + +The ostree-rs-ext project maps layers to OSTree commits. Each layer +is stored separately, under an ostree "ref" (like a git branch) +under the `ostree/container/` namespace: + +``` +$ ostree refs ostree/container +``` + +### Layers + +The `ostree/container/blob` namespace tracks storage of a container layer +identified by its blob ID (sha256 digest). + +### Images + +At the current time, ostree always boots into a "flattened" filesystem +tree. This is generated as both a hardlinked checkout as well as +a composefs image. + +The flattened tree is constructed and committed into the +`ostree/container/image` namespace. The commit metadata also includes +the OCI manifest and config objects. + +This is implemented in the [ostree-rs-ext/container module](https://docs.rs/ostree-ext/latest/ostree_ext/container/index.html). + +### SELinux labeling + +See the SELinux section of [Image layout](bootc-images.md). + +### Origin files + +ostree has the concept of an `origin` file which defines the source +of truth for upgrades. The container image reference for each deployment +is included in its origin. + +## Booting + +A core aspect of this entire design is that once a container image is +fetched into the ostree storage, from there on it just appears as +an "ostree commit", and so all code built on top can work with it. + +For example, the `ostree-prepare-root.service` which runs in +the initramfs is currently agnostic to whether the filesystem tree originated +from an OCI image or some other mechanism; it just targets a +prepared flattened filesystem tree. + +This is what is referenced by the `ostree=` kernel commandline. + +## Logically bound images + +In addition to the base image, bootc supports [logically bound images](logically-bound-images.md). diff --git a/docs/src/filesystem-sysroot.md b/docs/src/filesystem-sysroot.md new file mode 100644 index 000000000..e6d5c6d88 --- /dev/null +++ b/docs/src/filesystem-sysroot.md @@ -0,0 +1,78 @@ +# Filesystem: Physical /sysroot + +The bootc project uses [ostree](https://github.com/ostreedev/ostree/) as a backend, +and maps fetched container images to a [deployment](https://ostreedev.github.io/ostree/deployment/). + +## stateroot + +The underlying `ostree` CLI and API tooling expose a concept of `stateroot`, which +is not yet exposed via `bootc`. The `stateroot` used by `bootc install` +is just named `default`. + +The stateroot concept allows having fully separate parallel operating +system installations with fully separate `/etc` and `/var`, while +still sharing an underlying root filesystem. + +In the future, this functionality will be exposed and used by `bootc`. + +## /sysroot mount + +When booted, the physical root will be available at `/sysroot` as a +read-only mount point and the logical root `/` will be a bind mount +pointing to a deployment directory under `/sysroot/ostree`. This is a +key aspect of how `bootc upgrade` operates: it fetches the updated +container image and writes the base image files (using OSTree storage +to `/sysroot/ostree/repo`). + +Beyond that and debugging/introspection, there are few use cases for tooling to +operate on the physical root. + +### bootc-owned container storage + +For [logically bound images](logically-bound-images.md), +bootc maintains a dedicated [containers/storage](https://github.com/containers/storage) +instance using the `overlay` backend (the same type of thing that backs `/var/lib/containers`). + +This storage is accessible via a `/usr/lib/bootc/storage` symbolic link which points into +`/sysroot`. (Avoid directly referencing the `/sysroot` target) + +At the current time, this storage is *not* used for the base bootable image. +This [unified storage issue](https://github.com/bootc-dev/bootc/issues/20) tracks unification. + +## Expanding the root filesystem + +One notable use case that *does* need to operate on `/sysroot` +is expanding the root filesystem. + +Some higher level tools such as e.g. `cloud-init` may (reasonably) +expect the `/` mount point to be the physical root. Tools like +this will need to be adjusted to instead detect this and operate +on `/sysroot`. + +### Growing the block device + +Fundamentally bootc is agnostic to the underlying block device setup. +How to grow the root block device depends on the underlying +storage stack, from basic partitions to LVM. However, a +common tool is the [growpart](https://manpages.debian.org/testing/cloud-guest-utils/growpart.1.en.html) +utility from `cloud-init`. + +### Growing the filesystem + +The systemd project ships a [systemd-growfs](https://www.freedesktop.org/software/systemd/man/latest/systemd-growfs.html#) +tool and corresponding `systemd-growfs@` services. This is +a relatively thin abstraction over detecting the target +root filesystem type and running the underlying tool such as +`xfs_growfs`. + +At the current time, most Linux filesystems require +the target to be mounted writable in order to grow. Hence, +an invocation of `system-growfs /sysroot` or `xfs_growfs /sysroot` +will need to be further wrapped in a temporary mount namespace. + +Using a `MountFlags=slave` drop-in stanza for `systemd-growfs@sysroot.service` +is recommended, along with an `ExecStartPre=mount -o remount,rw /sysroot`. + +### Detecting bootc/ostree systems + +See the [package managers](package-managers.md) section on "Detecting image based systems". diff --git a/docs/src/filesystem.md b/docs/src/filesystem.md new file mode 100644 index 000000000..81d813a7c --- /dev/null +++ b/docs/src/filesystem.md @@ -0,0 +1,364 @@ +# Filesystem + +As noted in other chapters, the bootc project currently +depends on the [ostree project](https://github.com/ostreedev/ostree/) +for storing the base container image. Additionally there is a [containers/storage](https://github.com/containers/storage) instance for [logically bound images](logically-bound-images.md). + +However, bootc is intending to be a "fresh, new container-native interface", +and ostree is an implementation detail. + +First, it is strongly recommended that bootc consumers use the ostree +[composefs backend](https://ostreedev.github.io/ostree/composefs/); to do this, +ensure that you have a `/usr/lib/ostree/prepare-root.conf` that contains at least + +```ini +[composefs] +enabled = true +``` + +This will ensure that the entire `/` is a read-only filesystem which +is very important for achieving correct semantics. + +## Understanding container build/runtime vs deployment + +When run *as a container* (e.g. as part of a container build), the +filesystem is fully mutable in order to allow derivation to work. +For more on container builds, see [build guidance](building/guidance.md). + +The rest of this document describes the state of the system when +"deployed" to a physical or virtual machine, and managed by `bootc`. + +## Timestamps + +bootc uses ostree, which currently [squashes all timestamps to zero](https://ostreedev.github.io/ostree/repo/#content-objects). +This is now viewed as an implementation bug and will be changed in the future. +For more information, see [this tracker issue](https://github.com/bootc-dev/bootc/issues/20). + +## Understanding physical vs logical root with `/sysroot` + +When the system is fully booted, it is into the equivalent of a `chroot`. +The "physical" host root filesystem will be mounted at `/sysroot`. +For more on this, see [filesystem: sysroot](filesystem-sysroot.md). + +This `chroot` filesystem is called a "deployment root". All the remaining +filesystem paths below are part of a deployment root which is used as a +final target for the system boot. The target deployment is determined +via the `ostree=` kernel commandline argument. + +## `/usr` + +The overall recommendation is to keep all operating system content in `/usr`, +with directories such as `/bin` being symbolic links to `/usr/bin`, etc. +See [UsrMove](https://fedoraproject.org/wiki/Features/UsrMove) for example. + +However, with composefs enabled `/usr` is not different from `/`; +they are part of the same immutable image. So there is not a fundamental +need to do a full "UsrMove" with a bootc system. + +### `/usr/local` + +The OSTree upstream recommendation suggests making `/usr/local` a symbolic +link to `/var/usrlocal`. But because the emphasis of a bootc-oriented system is +on users deriving custom container images as the default entrypoint, +it is recommended here that base images configure `/usr/local` be a regular +directory (i.e. the default). + +Projects that want to produce "final" images that are themselves +not intended to be derived from in general can enable that symbolic link +in derived builds. + +## `/etc` + +The `/etc` directory contains mutable persistent state by default; however, +it is supported (and encouraged) to enable the [`etc.transient` config option](https://ostreedev.github.io/ostree/man/ostree-prepare-root.html), +see below as well. + +When in persistent mode, it inherits the OSTree semantics of [performing a 3-way merge](https://ostreedev.github.io/ostree/atomic-upgrades/#assembling-a-new-deployment-directory) +across upgrades. In a nutshell: + +- The *new default* `/etc` is used as a base +- The diff between current and previous `/etc` is applied to the new `/etc` +- Locally modified files in `/etc` different from the default `/usr/etc` (of the same deployment) will be retained + +You can view the state via `ostree admin config-diff`. Note that the "diff" +here includes metadata (uid, gid, extended attributes), so changing any of those +will also mean that updated files from the image are not applied. + +The implementation of this defaults to being executed by `ostree-finalize-staged.service` +at shutdown time, before the new bootloader entry is created. + +The rationale for this design is that in practice today, many components of a Linux system end up shipping +default configuration files in `/etc`. And even if the default package doesn't, often the software +only looks for config files there by default. + +Some other image-based update systems do not have distinct "versions" of `/etc` and +it may be populated only set up at install time, and untouched thereafter. But +that creates "hysteresis" where the state of the system's `/etc` is strongly +influenced by the initial image version. This can lead to problems +where e.g. a change to `/etc/sudoers` (to give one simple example) +would require external intervention to apply. + +For more on configuration file best practices, see [Building](building/guidance.md). + +To emphasize again, it's recommended to enable `etc.transient` if possible, though +when using that you may need to store some machine-specific state in e.g. the +kernel commandline if applicable. + +### `/usr/etc` + +The `/usr/etc` tree is generated client side and contains the default container image's +view of `/etc`. This should generally be considered an internal implementation detail +of bootc/ostree. Do *not* explicitly put files into this location, it can create +undefined behavior. There is a check for this in `bootc container lint`. + +## `/var` + +Content in `/var` persists by default; it is however supported to make it or subdirectories +mount points (whether network or `tmpfs`). There is exactly one `/var`. If it is +not a distinct partition, then it is automatically made a bind from +`/ostree/deploy/$stateroot/var` and shared across "deployments" (bootloader entries). + +You may include content in `/var` in your image - and reference base images may +have a few basic directories such as `/var/tmp` (in order to ease use in container +builds). + +However, it is very important to understand that content included in `/var` +in the container image acts like a Docker `VOLUME /var`. This means its +contents are unpacked *only from the initial image* - subsequent changes to `/var` +in a container image are not automatically applied. + +A common case is for applications to want some directory structure (e.g. `/var/lib/postgresql`) to be pre-created. +It's recommended to use [systemd tmpfiles.d](https://www.freedesktop.org/software/systemd/man/latest/tmpfiles.d.html) +for this. An even better approach where applicable is [StateDirectory=](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) +in units. + +As of bootc 1.1.6, the `bootc container lint` command will check for missing `tmpfiles.d` +entries and warn. + +Note this is very different from the handling of `/etc`. The rationale for this is +that `/etc` is relatively small configuration files, and the expected configuration +files are often bound to the operating system binaries in `/usr`. + +But `/var` has arbitrarily large data (system logs, databases, etc.). It would +also not be expected to be rolled back if the operating system state is rolled +back. A simple example is that an `apt|dnf downgrade postgresql` should not +affect the physical database in general in `/var/lib/postgres`. Similarly, +a bootc update or rollback should not affect this application data. + +Having `/var` separate also makes it work cleanly to "stage" new +operating system updates before applying them (they're downloaded +and ready, but only take effect on reboot). + +In general, this is the same rationale for Docker `VOLUME`: decouple the application +code from its data. + +## Other directories + +It is not supported to ship content in `/run` or `/proc` or other [API Filesystems](https://www.freedesktop.org/wiki/Software/systemd/APIFileSystems/) in container images. + +Besides those, for other toplevel directories such as `/usr` `/opt`, they will be lifecycled with the container image. + +### `/opt` + +In the default suggested model of using composefs (per above) the `/opt` directory will be read-only, alongside +other toplevels such as `/usr`. + +Some software (especially "3rd party" deb/rpm packages) expect to be able to write to +a subdirectory of `/opt` such as `/opt/examplepkg`. + +See [building images](building/guidance.md) for recommendations on how to build +container images and adjust the filesystem for cases like this. + +However, for some use cases, it may be easier to allow some level of mutability. +There are two options for this, each with separate trade-offs: transient roots +and state overlays. + +### Other toplevel directories + +Creating other toplevel directories and content (e.g. `/afs`, `/arbitrarymountpoint`) +or in general further nested data is supported - just create the directory +as part of your container image build process (e.g. `RUN mkdir /arbitrarymountpoint`). +These directories will be lifecycled with the container image state, +and appear immutable by default, the same as all other directories +such as `/usr` and `/opt`. + +Mounting separate filesystems there can be done by the usual mechanisms +of `/etc/fstab`, systemd `.mount` units, etc. + +#### SELinux for arbitrary toplevels + +Note that operating systems using SELinux may use a label such as +`default_t` for unknown toplevel directories, which may not be +accessible by some processes. In this situation you currently may +need to also ensure a label is defined for them in the file contexts. + +## Enabling transient root + +This feature enables a fully transient writable rootfs by default. +To do this, set the + +```toml +[root] +transient = true +``` + +option in `/usr/lib/ostree/prepare-root.conf`. In particular this will allow software to +write (transiently, i.e. until the next reboot) to all top-level directories, +including `/usr` and `/opt`, with symlinks to `/var` for content that should +persist. + +This can be combined with `etc.transient` as well (below). + +More on prepare-root: + +Note that regenerating the initramfs is required when changing this file. + +## Dynamic mountpoints with transient-ro + +The `transient-ro` option allows privileged users to create dynamic toplevel mountpoints +at runtime while keeping the filesystem read-only by default. This is particularly useful for +applications that need to bind mount host paths that may be platform-specific or dynamic. + +### Use cases + +This feature addresses scenarios where: + +- Applications need to bind mount host directories that match the host's absolute paths +- Platform-specific mountpoints are required (e.g., `/Users` on macOS) +- Dynamic mountpoints need to be created after deployment but before application startup +- The filesystem should remain read-only for regular processes + +### Configuration + +To enable this feature, add the following to `/usr/lib/ostree/prepare-root.conf`: + +```toml +[root] +transient-ro = true +``` + +### How it works + +When `transient-ro=true` is set: + +1. The overlayfs upper directory is mounted read-only by default +2. Privileged processes can remount it as writable only in a new mount namespace, and perform arbitrary changes there, such as creating new toplevel mountpoints +3. These mountpoints persist for the current boot but do not survive reboots or upgrades +4. Regular processes continue to see a read-only filesystem + +A privileged process can achieve this using standard Linux commands. For example: + +```bash +# unshare -m -- /bin/sh -c 'mount -o remount,rw / && mkdir /new-mountpoint' +``` + +### Example: Podman machine integration + +A common use case is with `podman machine` on macOS, where the VM needs to bind mount +host paths like `/Users/username` into the VM. With `transient-ro`, the system can: + +1. Create the `/Users` directory dynamically at runtime +2. Bind mount the host's `/Users` directory to the VM's `/Users` +3. Keep the rest of the filesystem read-only for security + +## Enabling transient etc + +The default (per above) is to have `/etc` persist. If however you do +not need to use it for any per-machine state, then enabling a transient +`/etc` is a great way to reduce the amount of possible state drift. Set +the + +```toml +[etc] +transient = true +``` + +option in `/usr/lib/ostree/prepare-root.conf`. + +This can be combined with `root.transient` as well (above). + +More on prepare-root: + +Note that regenerating the initramfs is required when changing this file. + +## Enabling state overlays + +This feature enables a writable overlay on top of `/opt` (or really, any +toplevel or subdirectory baked into the image that is normally read-only). + +The semantics here are somewhat nuanced: + +- Changes persist across reboots by default +- During updates, new files from the container image override any locally modified version + +The advantages are: +- It makes it very easy to make compatible applications that install into `/opt`. +- In contrast to transient root (above), a smaller surface of the filesystem is mutable. + +The disadvantages are: +- There is no equivalent to this feature in the Docker/Podman ecosystem. +- It allows for some temporary state drift until the next update. + +To enable this feature, instantiate the `ostree-state-overlay@.service` +unit template on the target path. For example, for `/opt`: + +``` +RUN systemctl enable ostree-state-overlay@opt.service +``` + +## More generally dealing with `/opt` + +Both transient root and state overlays above provide ways for packages +that install in `/opt` to operate. However, for maximum immutability the +best approach is simply to symlink just the parts of the `/opt` needed +into `/var`. See the section on `/opt` in [Image building and configuration +guidance](building/guidance.md) for a more concrete example. + +## Increased filesystem integrity with fsverity + +The bootc project uses [composefs](https://github.com/composefs/composefs) +by default for the root filesystem (using ostree's support for composefs). +However, the default configuration as recommended for base images +uses composefs in a mode that does not require signatures or fsverity. + +bootc supports with ostree's model of hard requiring fsverity +for underlying objects. Enabling this also causes bootc +to error out at install time if the target filesystem does +not enable fsverity. + +To enable this, inside your container build update +`/usr/lib/ostree/prepare-root.conf` with: + +``` +[composefs] +enabled = verity +``` + +At the current time, there is no default recommended +mechanism to check the integrity of the upper composefs. +For more information about this, see +[this tracking issue](https://github.com/bootc-dev/bootc/issues/1190). + +Note that the default `/etc` and `/var` mounts are unaffected by +this configuration. Because `/etc` in particular can easily +contain arbitrary executable code (`/etc/systemd/system` unit files), +many deployment scenarios that want to hard require fsverity will also +want a "transient etc" model. + +### Caveats + +#### Does not apply to logically bound images + +The [logically bound images](logically-bound-images.md) store is currently +implemented using a separate mechanism and configuring fsverity +for the bootc storage has no effect on it. + +#### Enabling fsverity across upgrades + +At the current time the integration is only for +installation; there is not yet support for automatically ensuring that +fsverity is enabled when upgrading from a state with +`composefs.enabled = yes` to `composefs.enabled = verity`. +Because older objects may not have fsverity enabled, +the new system will likely fail at runtime to access these older files +across the upgrade. diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json new file mode 100644 index 000000000..e0bdf2880 --- /dev/null +++ b/docs/src/host-v1.schema.json @@ -0,0 +1,448 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Host", + "description": "The core host definition", + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/ObjectMeta", + "default": {} + }, + "spec": { + "description": "The spec", + "$ref": "#/$defs/HostSpec", + "default": { + "bootOrder": "default", + "image": null + } + }, + "status": { + "description": "The status", + "$ref": "#/$defs/HostStatus", + "default": { + "booted": null, + "rollback": null, + "rollbackQueued": false, + "staged": null, + "type": null + } + } + }, + "required": [ + "apiVersion", + "kind" + ], + "$defs": { + "BootEntry": { + "description": "A bootable entry", + "type": "object", + "properties": { + "cachedUpdate": { + "description": "The last fetched cached update metadata", + "anyOf": [ + { + "$ref": "#/$defs/ImageStatus" + }, + { + "type": "null" + } + ] + }, + "composefs": { + "description": "If this boot entry is composefs based, the corresponding state", + "anyOf": [ + { + "$ref": "#/$defs/BootEntryComposefs" + }, + { + "type": "null" + } + ] + }, + "image": { + "description": "The image reference", + "anyOf": [ + { + "$ref": "#/$defs/ImageStatus" + }, + { + "type": "null" + } + ] + }, + "incompatible": { + "description": "Whether this boot entry is not compatible (has origin changes bootc does not understand)", + "type": "boolean" + }, + "ostree": { + "description": "If this boot entry is ostree based, the corresponding state", + "anyOf": [ + { + "$ref": "#/$defs/BootEntryOstree" + }, + { + "type": "null" + } + ] + }, + "pinned": { + "description": "Whether this entry will be subject to garbage collection", + "type": "boolean" + }, + "softRebootCapable": { + "description": "This is true if (relative to the booted system) this is a possible target for a soft reboot", + "type": "boolean", + "default": false + }, + "store": { + "description": "The container storage backend", + "anyOf": [ + { + "$ref": "#/$defs/Store" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "incompatible", + "pinned" + ] + }, + "BootEntryComposefs": { + "description": "A bootable entry", + "type": "object", + "properties": { + "bootDigest": { + "description": "The sha256sum of vmlinuz + initrd\nOnly `Some` for Type1 boot entries", + "type": [ + "string", + "null" + ] + }, + "bootType": { + "description": "Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry", + "$ref": "#/$defs/BootType" + }, + "bootloader": { + "description": "Whether we boot using systemd or grub", + "$ref": "#/$defs/Bootloader" + }, + "verity": { + "description": "The erofs verity", + "type": "string" + } + }, + "required": [ + "verity", + "bootType", + "bootloader" + ] + }, + "BootEntryOstree": { + "description": "A bootable entry", + "type": "object", + "properties": { + "checksum": { + "description": "The ostree commit checksum", + "type": "string" + }, + "deploySerial": { + "description": "The deployment serial", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "stateroot": { + "description": "The name of the storage for /etc and /var content", + "type": "string" + } + }, + "required": [ + "stateroot", + "checksum", + "deploySerial" + ] + }, + "BootOrder": { + "description": "Configuration for system boot ordering.", + "oneOf": [ + { + "description": "The staged or booted deployment will be booted next", + "type": "string", + "const": "default" + }, + { + "description": "The rollback deployment will be booted next", + "type": "string", + "const": "rollback" + } + ] + }, + "BootType": { + "type": "string", + "enum": [ + "Bls", + "Uki" + ] + }, + "Bootloader": { + "description": "Bootloader type to determine whether system was booted via Grub or Systemd", + "oneOf": [ + { + "description": "Use Grub as the booloader", + "type": "string", + "const": "Grub" + }, + { + "description": "Use SystemdBoot as the bootloader", + "type": "string", + "const": "Systemd" + } + ] + }, + "HostSpec": { + "description": "The host specification", + "type": "object", + "properties": { + "bootOrder": { + "description": "If set, and there is a rollback deployment, it will be set for the next boot.", + "$ref": "#/$defs/BootOrder", + "default": "default" + }, + "image": { + "description": "The host image", + "anyOf": [ + { + "$ref": "#/$defs/ImageReference" + }, + { + "type": "null" + } + ] + } + } + }, + "HostStatus": { + "description": "The status of the host system", + "type": "object", + "properties": { + "booted": { + "description": "The booted image; this will be unset if the host is not bootc compatible.", + "anyOf": [ + { + "$ref": "#/$defs/BootEntry" + }, + { + "type": "null" + } + ] + }, + "otherDeployments": { + "description": "Other deployments (i.e. pinned)", + "type": "array", + "items": { + "$ref": "#/$defs/BootEntry" + } + }, + "rollback": { + "description": "The previously booted image", + "anyOf": [ + { + "$ref": "#/$defs/BootEntry" + }, + { + "type": "null" + } + ] + }, + "rollbackQueued": { + "description": "Set to true if the rollback entry is queued for the next boot.", + "type": "boolean", + "default": false + }, + "staged": { + "description": "The staged image for the next boot", + "anyOf": [ + { + "$ref": "#/$defs/BootEntry" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "The detected type of system", + "anyOf": [ + { + "$ref": "#/$defs/HostType" + }, + { + "type": "null" + } + ] + } + } + }, + "HostType": { + "description": "The detected type of running system. Note that this is not exhaustive\nand new variants may be added in the future.", + "oneOf": [ + { + "description": "The current system is deployed in a bootc compatible way.", + "type": "string", + "const": "bootcHost" + } + ] + }, + "ImageReference": { + "description": "A container image reference with attached transport and signature verification", + "type": "object", + "properties": { + "image": { + "description": "The container image reference", + "type": "string" + }, + "signature": { + "description": "Signature verification type", + "anyOf": [ + { + "$ref": "#/$defs/ImageSignature" + }, + { + "type": "null" + } + ] + }, + "transport": { + "description": "The container image transport", + "type": "string" + } + }, + "required": [ + "image", + "transport" + ] + }, + "ImageSignature": { + "description": "An image signature", + "oneOf": [ + { + "description": "Fetches will use the named ostree remote for signature verification of the ostree commit.", + "type": "object", + "properties": { + "ostreeRemote": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "ostreeRemote" + ] + }, + { + "description": "Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy.", + "type": "string", + "const": "containerPolicy" + }, + { + "description": "No signature verification will be performed", + "type": "string", + "const": "insecure" + } + ] + }, + "ImageStatus": { + "description": "The status of the booted image", + "type": "object", + "properties": { + "architecture": { + "description": "The hardware architecture of this image", + "type": "string" + }, + "image": { + "description": "The currently booted image", + "$ref": "#/$defs/ImageReference" + }, + "imageDigest": { + "description": "The digest of the fetched image (e.g. sha256:a0...);", + "type": "string" + }, + "timestamp": { + "description": "The build timestamp, if any", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "version": { + "description": "The version string, if any", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "image", + "imageDigest", + "architecture" + ] + }, + "ObjectMeta": { + "type": "object", + "properties": { + "annotations": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + } + } + }, + "Store": { + "description": "The container storage backend", + "oneOf": [ + { + "description": "Use the ostree-container storage backend.", + "type": "string", + "const": "ostreeContainer" + } + ] + } + } +} \ No newline at end of file diff --git a/docs/src/installation.md b/docs/src/installation.md new file mode 100644 index 000000000..922858105 --- /dev/null +++ b/docs/src/installation.md @@ -0,0 +1,22 @@ +# Base images + +Many users will be more interested in base (container) images. + +## Fedora/CentOS + +Currently, the [Fedora/CentOS bootc project](https://docs.fedoraproject.org/en-US/bootc/) +is the most closely aligned upstream project. + +For pre-built base images; any Fedora derivative already using `ostree` can be seamlessly converted into using bootc; +for example, [Fedora CoreOS](https://quay.io/repository/fedora/fedora-coreos) can be used as a +base image; you will want to also `rpm-ostree install bootc` in your image builds currently. +There are some overlaps between `bootc` and `ignition` and `zincati` however; see +[this pull request](https://github.com/coreos/fedora-coreos-docs/pull/540) for more information. + +For other derivatives such as the ["Atomic desktops"](https://gitlab.com/fedora/ostree), see +discussion of [relationships](relationships.md) which particularly covers interactions with rpm-ostree. + +## Other + +However, bootc itself is not tied to Fedora derivatives; +[this issue](https://github.com/coreos/bootupd/issues/468) tracks the main blocker for other distributions. diff --git a/docs/src/intro.md b/docs/src/intro.md new file mode 100644 index 000000000..bdbd2df53 --- /dev/null +++ b/docs/src/intro.md @@ -0,0 +1,25 @@ +# bootc + +Transactional, in-place operating system updates using OCI/Docker container images. +bootc is the key component in a broader mission of [bootable containers](https://containers.github.io/bootable/). + +The original Docker container model of using "layers" to model +applications has been extremely successful. This project +aims to apply the same technique for bootable host systems - using +standard OCI/Docker containers as a transport and delivery format +for base operating system updates. + +The container image includes a Linux kernel (in e.g. `/usr/lib/modules`), +which is used to boot. At runtime on a target system, the base userspace is +*not* itself running in a container by default. For example, assuming +systemd is in use, systemd acts as pid1 as usual - there's no "outer" process. + +# Status + +The CLI and API for bootc are now considered stable. Every existing system +can be upgraded in place seamlessly across any future changes. + +However, the core underlying code uses the [ostree](https://github.com/ostreedev/ostree) +project which has been powering stable operating system updates for +many years. The stability here generally refers to the surface +APIs, not the underlying logic. diff --git a/docs/src/logically-bound-images.md b/docs/src/logically-bound-images.md new file mode 100644 index 000000000..6be695aef --- /dev/null +++ b/docs/src/logically-bound-images.md @@ -0,0 +1,115 @@ +# Logically Bound Images + +## About logically bound images + +This feature enables an association of container "app" images to a base bootc system image. Use cases for this include: + +- Logging (e.g. journald->remote log forwarder container) +- Monitoring (e.g. [Prometheus node_exporter](https://github.com/prometheus/node_exporter)) +- Configuration management agents +- Security agents + +These types of things are commonly not updated outside of the host, and there's a secondary important property: We *always* want them present and available on the host, possibly from very early on in the boot. In contrast with default usage of tools like `podman` or `docker`, images may be pulled dynamically *after* the boot starts; requiring functioning networking, etc. For example if the remote registry is unavailable temporarily, the host system may run for a longer period of time without log forwarding or monitoring, which can be very undesirable. + +Another simple way to say this is that logically bound images allow you to reference container images with the same confidence you can with `ExecStart=` in a systemd unit. + +The term "logically bound" was created to contrast with [physically bound](https://github.com/bootc-dev/bootc/issues/644) images. There are some trade-offs between the two approaches. Some benefits of logically bound images are: + +- The bootc system image can be updated without re-downloading the app image bits. +- The app images can be updated without modifying the bootc system image, this would be especially useful for development work + +## Using logically bound images + +Each image is defined in a [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) `.image` or `.container` file. An image is selected to be bound by creating a symlink in the `/usr/lib/bootc/bound-images.d` directory pointing to a `.image` or `.container` file. + +With these defined, during a `bootc upgrade` or `bootc switch` the bound images defined in the new bootc image will be automatically pulled into the bootc image storage, and are available to container runtimes such as podman by explicitly configuring them to point to the bootc storage as an "additional image store", via e.g.: + +`podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage run ...` + +An example Containerfile + +```Dockerfile +FROM quay.io/myorg/myimage:latest + +COPY ./my-app.image /usr/share/containers/systemd/my-app.image +COPY ./another-app.container /usr/share/containers/systemd/another-app.container + +RUN ln -s /usr/share/containers/systemd/my-app.image /usr/lib/bootc/bound-images.d/my-app.image && \ + ln -s /usr/share/containers/systemd/another-app.container /usr/lib/bootc/bound-images.d/another-app.container +``` + +In the `.container` definition, you should use: + +``` +GlobalArgs=--storage-opt=additionalimagestore=/usr/lib/bootc/storage +``` + +NOTE: Do *not* attempt to globally enable `/usr/lib/bootc/storage` in `/etc/containers/storage.conf`; only +use the bootc storage for logically bound images, not also floating images. For more, see below. + +## Pull secret + +Images are fetched using the global bootc pull secret by default (`/etc/ostree/auth.json`). It is not yet supported to configure `PullSecret` in these image definitions. + +## Garbage collection + +The bootc image store is owned by bootc; images will be garbage collected when they are no longer referenced +by a file in `/usr/lib/bootc/bound-images.d`. + +## Installation + +Logically bound images must be present in the default container store (`/var/lib/containers`) when invoking +[bootc install](bootc-install.md); the images will be copied into the target system and present +directly at boot, alongside the bootc base image. + +## Limitations + +The *only* field parsed and honored by bootc currently is the `Image` field of a `.image` or `.container` file. + +Other pull-relevant flags such as `PullSecret=` for example are not supported (see above). +Another example unsupported flag is `Arch` (the default host architecture is always used). + +There is no mechanism to inject arbitrary arguments to the `podman pull` (or equivalent) +invocation used by bootc. However, many properties used for container registry interaction +can be configured via [containers-registries.conf](https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md) +and apply to all commands operating on that image. + +It is not currently supported in general to launch "rootless" containers from system-owned +image stores in general, whether from `/var/lib/containers` or the `/usr/lib/bootc/storage`. +There is no integration between bootc and "rootless" storage today, and none is planned. +Instead, it's recommended to ensure that your "system" or "rootful" containers drop +privileges. More in e.g. . + +### Distro/OS installer support + +At the current time, logically bound images are [not supported by Anaconda](https://github.com/rhinstaller/anaconda/discussions/5197). + +## Comparison with default podman systemd units + +In the comparison below, the term "floating" will be used for non-logically bound images. These images are often fetched by e.g. [podman-systemd](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) and may be upgraded, added or removed independently of the host upgrade lifecycle. + +### Lifecycle + +- **Floating image:** The images are downloaded by the machine the first time it starts (requiring networking typically). Tools such as `podman auto-update` can be used to upgrade them independently of the host. +- **Logically bound image:** The images are referenced by the bootable container and are ensured to be available when the (bootc based) server starts. The image is always upgraded via `bootc upgrade` and appears read-only to other processes (e.g. `podman`). + +### Upgrades, rollbacks and garbage collection + +- **Floating image:** Managed by the user (`podman auto-update`, `podman image prune`). This can be triggered at anytime independent of the host upgrades or rollbacks, and host upgrades/rollbacks do not affect the set of images. +- **Logically bound image:** Managed exclusively by `bootc` during upgrades. The logically bound images corresponding to rollback deployments will also be retained. `bootc` performs garbage collection of unused images. + +### "rootless" container image + +- **Floating image:** Supported. +- **Logically bound image:** Not supported (`bootc` cannot be invoked as non-root). Instead, it's recommended to just drop most privileges for launched logically bound containers. + +## Avoid using /usr/lib/bootc/storage for floating images + +Because images and in particular *layers* of images can be removed over time as +the OS upgrades, if you attempt to globally enable `/usr/lib/bootc/storage` +in the global `/etc/containers/storage.conf` that would also apply to "floating" +container images (i.e. the default `podman run` and other runtimes), it can +cause a bug where floating images can later fail if layers that were reused +in the LBI storage are removed. In the future, this restriction may be lifted, +but at the current time you can only configure this additional storage +for logically bound images. diff --git a/docs/src/man/bootc-composefs-finalize-staged.8.md b/docs/src/man/bootc-composefs-finalize-staged.8.md new file mode 100644 index 000000000..569b88367 --- /dev/null +++ b/docs/src/man/bootc-composefs-finalize-staged.8.md @@ -0,0 +1,26 @@ +# NAME + +bootc-composefs-finalize-staged - TODO: Add description + +# SYNOPSIS + +bootc composefs-finalize-staged + +# DESCRIPTION + +TODO: Add description + + + + +# EXAMPLES + +TODO: Add practical examples showing how to use this command. + +# SEE ALSO + +**bootc**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-config-diff.8.md b/docs/src/man/bootc-config-diff.8.md new file mode 100644 index 000000000..f1fc8191f --- /dev/null +++ b/docs/src/man/bootc-config-diff.8.md @@ -0,0 +1,26 @@ +# NAME + +bootc-config-diff - Diff current /etc configuration versus default + +# SYNOPSIS + +bootc config-diff + +# DESCRIPTION + +Diff current /etc configuration versus default + + + + +# EXAMPLES + +TODO: Add practical examples showing how to use this command. + +# SEE ALSO + +**bootc**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-config.5.md b/docs/src/man/bootc-config.5.md new file mode 100644 index 000000000..07bdfbdf8 --- /dev/null +++ b/docs/src/man/bootc-config.5.md @@ -0,0 +1,57 @@ +# NAME + +bootc-config - Configuration file format for bootc + +# SYNOPSIS + +**/etc/bootc/config.toml** + +# DESCRIPTION + +The bootc configuration file uses TOML format to specify various +settings for bootc operation. + +# FILE FORMAT + +The configuration file is in TOML format with the following sections: + +## [core] + +Core configuration options. + +**auto_updates** = *boolean* + Enable or disable automatic updates. Default: false + +**update_interval** = *string* + Update check interval (e.g., "daily", "weekly"). Default: "weekly" + +## [storage] + +Storage-related configuration. + +**root** = *path* + Root storage path. Default: "/sysroot/ostree" + +# EXAMPLES + +A basic configuration file: + + [core] + auto_updates = true + update_interval = "daily" + + [storage] + root = "/var/lib/bootc" + +# FILES + +**/etc/bootc/config.toml** + System-wide configuration file + +# SEE ALSO + +**bootc**(8), **toml**(5) + +# VERSION + + \ No newline at end of file diff --git a/docs/src/man/bootc-container-lint.8.md b/docs/src/man/bootc-container-lint.8.md new file mode 100644 index 000000000..d72ab58d3 --- /dev/null +++ b/docs/src/man/bootc-container-lint.8.md @@ -0,0 +1,48 @@ +# NAME + +bootc-container-lint - Perform relatively inexpensive static analysis +checks as part of a container build + +# SYNOPSIS + +**bootc container lint** \[*OPTIONS...*\] + +# DESCRIPTION + +Perform relatively inexpensive static analysis checks as part of a +container build. + +This is intended to be invoked via e.g. `RUN bootc container lint` as +part of a build process; it will error if any problems are detected. + +# OPTIONS + + +**--rootfs**=*ROOTFS* + + Operate on the provided rootfs + + Default: / + +**--fatal-warnings** + + Make warnings fatal + +**--list** + + Instead of executing the lints, just print all available lints. At the current time, this will output in YAML format because it's reasonably human friendly. However, there is no commitment to maintaining this exact format; do not parse it via code or scripts + +**--skip**=*SKIP* + + Skip checking the targeted lints, by name. Use `--list` to discover the set of available lints + +**--no-truncate** + + Don't truncate the output. By default, only a limited number of entries are shown for each lint, followed by a count of remaining entries + + + +# VERSION + + + diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md new file mode 100644 index 000000000..313f7d59e --- /dev/null +++ b/docs/src/man/bootc-container.8.md @@ -0,0 +1,29 @@ +# NAME + +bootc-container - Operations which can be executed as part of a +container build + +# SYNOPSIS + +**bootc container** \[*OPTIONS...*\] <*SUBCOMMAND*> + +# DESCRIPTION + +Operations which can be executed as part of a container build + + + + +# SUBCOMMANDS + + +| Command | Description | +|---------|-------------| +| **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build | + + + +# VERSION + + + diff --git a/docs/src/man/bootc-edit.8.md b/docs/src/man/bootc-edit.8.md new file mode 100644 index 000000000..48bd00d46 --- /dev/null +++ b/docs/src/man/bootc-edit.8.md @@ -0,0 +1,38 @@ +# NAME + +bootc-edit - Apply full changes to the host specification + +# SYNOPSIS + +**bootc edit** \[*OPTIONS...*\] + +# DESCRIPTION + +Apply full changes to the host specification. + +This command operates very similarly to `kubectl apply`; if invoked +interactively, then the current host specification will be presented in +the system default `\$EDITOR` for interactive changes. + +It is also possible to directly provide new contents via `bootc edit +\--filename`. + +Only changes to the `spec` section are honored. + +# OPTIONS + + +**-f**, **--filename**=*FILENAME* + + Use filename to edit system specification + +**--quiet** + + Don't display progress + + + +# VERSION + + + diff --git a/docs/src/man/bootc-fetch-apply-updates.service.5.md b/docs/src/man/bootc-fetch-apply-updates.service.5.md new file mode 100644 index 000000000..de5a1bb41 --- /dev/null +++ b/docs/src/man/bootc-fetch-apply-updates.service.5.md @@ -0,0 +1,35 @@ +# NAME + +bootc-fetch-apply-updates.service + +# DESCRIPTION + +This service causes `bootc` to perform the following steps: + +- Check the source registry for an updated container image +- If one is found, download it +- Reboot + +This service also comes with a companion `bootc-fetch-apply-updates.timer` +systemd unit. The current default systemd timer shipped in the upstream +project is enabled for daily updates. + +However, it is fully expected that different operating systems +and distributions choose different defaults. + +# CUSTOMIZING UPDATES + +Note that all three of these steps can be decoupled; they +are: + +- `bootc upgrade --check` +- `bootc upgrade` +- `bootc upgrade --apply` + +# SEE ALSO + +**bootc(1)** + +# VERSION + + \ No newline at end of file diff --git a/docs/src/man/bootc-install-config.5.md b/docs/src/man/bootc-install-config.5.md new file mode 100644 index 000000000..b4343784f --- /dev/null +++ b/docs/src/man/bootc-install-config.5.md @@ -0,0 +1,55 @@ +# NAME + +bootc-install-config.toml + +# DESCRIPTION + +The `bootc install` process supports some basic customization. This configuration file +is in TOML format, and will be discovered by the installation process in via "drop-in" +files in `/usr/lib/bootc/install` that are processed in alphanumerical order. + +The individual files are merged into a single final installation config, so it is +supported for e.g. a container base image to provide a default root filesystem type, +that can be overridden in a derived container image. + +# install + +This is the only defined toplevel table. + +The `install` section supports two subfields: + +- `block`: An array of supported `to-disk` backends enabled by this base container image; + if not specified, this will just be `direct`. The only other supported value is `tpm2-luks`. + The first value specified will be the default. To enable both, use `block = ["direct", "tpm2-luks"]`. +- `filesystem`: See below. +- `kargs`: An array of strings; this will be appended to the set of kernel arguments. +- `match_architectures`: An array of strings; this filters the install config. + +# filesystem + +There is one valid field: + +- `root`: An instance of "filesystem-root"; see below + +# filesystem-root + +There is one valid field: + +`type`: This can be any basic Linux filesystem with a `mkfs.$fstype`. For example, `ext4`, `xfs`, etc. + +# Examples + +```toml +[install.filesystem.root] +type = "xfs" +[install] +kargs = ["nosmt", "console=tty0"] +``` + +# SEE ALSO + +**bootc(1)** + +# VERSION + + diff --git a/docs/src/man/bootc-install-ensure-completion.8.md b/docs/src/man/bootc-install-ensure-completion.8.md new file mode 100644 index 000000000..2b66c4930 --- /dev/null +++ b/docs/src/man/bootc-install-ensure-completion.8.md @@ -0,0 +1,28 @@ +# NAME + +bootc-install-ensure-completion - Intended for use in environments that +are performing an ostree-based installation, not bootc + +# SYNOPSIS + +**bootc install ensure-completion** \[*OPTIONS...*\] + +# DESCRIPTION + +Intended for use in environments that are performing an ostree-based +installation, not bootc. + +In this scenario the installation may be missing bootc specific features +such as kernel arguments, logically bound images and more. This command +can be used to attempt to reconcile. At the current time, the only +tested environment is Anaconda using `ostreecontainer` and it is +recommended to avoid usage outside of that environment. Instead, ensure +your code is using `bootc install to-filesystem` from the start. + + + + +# VERSION + + + diff --git a/docs/src/man/bootc-install-finalize.8.md b/docs/src/man/bootc-install-finalize.8.md new file mode 100644 index 000000000..c15b4ec66 --- /dev/null +++ b/docs/src/man/bootc-install-finalize.8.md @@ -0,0 +1,35 @@ +# NAME + +bootc-install-finalize - Execute this as the penultimate step of an +installation using `install to-filesystem` + +# SYNOPSIS + +**bootc install finalize** \[*OPTIONS...*\] <*ROOT_PATH*> + +# DESCRIPTION + +Execute this as the penultimate step of an installation using `install +to-filesystem` + +# OPTIONS + + +**ROOT_PATH** + + Path to the mounted root filesystem + + This argument is required. + + + +# ARGUMENTS + +\<*ROOT_PATH*\> + +: Path to the mounted root filesystem + +# VERSION + + + diff --git a/docs/src/man/bootc-install-print-configuration.8.md b/docs/src/man/bootc-install-print-configuration.8.md new file mode 100644 index 000000000..bf50664a5 --- /dev/null +++ b/docs/src/man/bootc-install-print-configuration.8.md @@ -0,0 +1,28 @@ +# NAME + +bootc-install-print-configuration - Output JSON to stdout that contains +the merged installation configuration as it may be relevant to calling +processes using `install to-filesystem` that in particular want to +discover the desired root filesystem type from the container image + +# SYNOPSIS + +**bootc install print-configuration** \[*OPTIONS...*\] + +# DESCRIPTION + +Output JSON to stdout that contains the merged installation +configuration as it may be relevant to calling processes using `install +to-filesystem` that in particular want to discover the desired root +filesystem type from the container image. + +At the current time, the only output key is `root-fs-type` which is a +string-valued filesystem name suitable for passing to `mkfs.\$type`. + + + + +# VERSION + + + diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md new file mode 100644 index 000000000..b470b7200 --- /dev/null +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -0,0 +1,180 @@ +# NAME + +bootc-install-to-disk - Install to the target block device + +# SYNOPSIS + +**bootc install to-disk** \[*OPTIONS...*\] <*DEVICE*> + +# DESCRIPTION + +Install to the target block device. + +This command must be invoked inside of the container, which will be +installed. The container must be run in `--privileged` mode, and +hence will be able to see all block devices on the system. + +The default storage layout uses the root filesystem type configured in +the container image, alongside any required system partitions such as +the EFI system partition. Use `install to-filesystem` for anything +more complex such as RAID, LVM, LUKS etc. + +## Partitioning details + +The default as of bootc 1.11 uses the [Discoverable Partitions Specification](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/) +for the generated root filesystem, as well as any required system partitions +such as the EFI system partition. + +Note that by default when used with "type 1" bootloader setups (i.e. non-UKI) +a kernel argument `root=UUID=` is injected by default. + +When used with the composefs backend and UKIs, it's recommended that +a bootloader implementing the DPS specification is used and that the root +partition is auto-discovered. + +# OPTIONS + + +**DEVICE** + + Target block device for installation. The entire device will be wiped + + This argument is required. + +**--wipe** + + Automatically wipe all existing data on device + +**--block-setup**=*BLOCK_SETUP* + + Target root block device setup + + Possible values: + - direct + - tpm2-luks + +**--filesystem**=*FILESYSTEM* + + Target root filesystem type + + Possible values: + - xfs + - ext4 + - btrfs + +**--root-size**=*ROOT_SIZE* + + Size of the root partition (default specifier: M). Allowed specifiers: M (mebibytes), G (gibibytes), T (tebibytes) + +**--source-imgref**=*SOURCE_IMGREF* + + Install the system from an explicitly given source + +**--target-transport**=*TARGET_TRANSPORT* + + The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry` + + Default: registry + +**--target-imgref**=*TARGET_IMGREF* + + Specify the image to fetch for subsequent updates + +**--enforce-container-sigpolicy** + + This is the inverse of the previous `--target-no-signature-verification` (which is now a no-op). Enabling this option enforces that `/etc/containers/policy.json` includes a default policy which requires signatures + +**--run-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--skip-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--disable-selinux** + + Disable SELinux in the target (installed) system + +**--karg**=*KARG* + + Add a kernel argument. This option can be provided multiple times + +**--root-ssh-authorized-keys**=*ROOT_SSH_AUTHORIZED_KEYS* + + The path to an `authorized_keys` that will be injected into the `root` account + +**--generic-image** + + Perform configuration changes suitable for a "generic" disk image. At the moment: + +**--bound-images**=*BOUND_IMAGES* + + How should logically bound images be retrieved + + Possible values: + - stored + - skip + - pull + + Default: stored + +**--stateroot**=*STATEROOT* + + The stateroot name to use. Defaults to `default` + +**--via-loopback** + + Instead of targeting a block device, write to a file via loopback + +**--composefs-backend** + + If true, composefs backend is used, else ostree backend is used + + Default: false + +**--insecure** + + Make fs-verity validation optional in case the filesystem doesn't support it + + Default: false + +**--bootloader**=*BOOTLOADER* + + The bootloader to use + + Possible values: + - grub + - systemd + +**--uki-addon**=*UKI_ADDON* + + Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed + + + +# EXAMPLES + +Install to a disk, wiping all existing data: + + bootc install to-disk --wipe /dev/sda + +Install with a specific root filesystem type: + + bootc install to-disk --filesystem xfs /dev/nvme0n1 + +Install with TPM2 LUKS encryption: + + bootc install to-disk --block-setup tpm2-luks /dev/sda + +Install with custom kernel arguments: + + bootc install to-disk --karg=nosmt --karg=console=ttyS0 /dev/sda + +# SEE ALSO + +**bootc**(8), **bootc-install**(8), **bootc-install-to-filesystem**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md new file mode 100644 index 000000000..f8b52dbed --- /dev/null +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -0,0 +1,130 @@ +# NAME + +bootc-install-to-existing-root - Install to the host root filesystem + +# SYNOPSIS + +**bootc install to-existing-root** \[*OPTIONS...*\] \[*ROOT_PATH*\] + +# DESCRIPTION + +Install to the host root filesystem. + +This is a variant of `install to-filesystem` that is designed to +install \"alongside\" the running host root filesystem. Currently, the +host root filesystem\'s `/boot` partition will be wiped, but the +content of the existing root will otherwise be retained, and will need +to be cleaned up if desired when rebooted into the new root. + +# OPTIONS + + +**ROOT_PATH** + + Path to the mounted root; this is now not necessary to provide. Historically it was necessary to ensure the host rootfs was mounted at here via e.g. `-v /:/target` + +**--replace**=*REPLACE* + + Configure how existing data is treated + + Possible values: + - wipe + - alongside + + Default: alongside + +**--source-imgref**=*SOURCE_IMGREF* + + Install the system from an explicitly given source + +**--target-transport**=*TARGET_TRANSPORT* + + The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry` + + Default: registry + +**--target-imgref**=*TARGET_IMGREF* + + Specify the image to fetch for subsequent updates + +**--enforce-container-sigpolicy** + + This is the inverse of the previous `--target-no-signature-verification` (which is now a no-op). Enabling this option enforces that `/etc/containers/policy.json` includes a default policy which requires signatures + +**--run-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--skip-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--disable-selinux** + + Disable SELinux in the target (installed) system + +**--karg**=*KARG* + + Add a kernel argument. This option can be provided multiple times + +**--root-ssh-authorized-keys**=*ROOT_SSH_AUTHORIZED_KEYS* + + The path to an `authorized_keys` that will be injected into the `root` account + +**--generic-image** + + Perform configuration changes suitable for a "generic" disk image. At the moment: + +**--bound-images**=*BOUND_IMAGES* + + How should logically bound images be retrieved + + Possible values: + - stored + - skip + - pull + + Default: stored + +**--stateroot**=*STATEROOT* + + The stateroot name to use. Defaults to `default` + +**--acknowledge-destructive** + + Accept that this is a destructive action and skip a warning timer + +**--cleanup** + + Add the bootc-destructive-cleanup systemd service to delete files from the previous install on first boot + +**--composefs-backend** + + If true, composefs backend is used, else ostree backend is used + + Default: false + +**--insecure** + + Make fs-verity validation optional in case the filesystem doesn't support it + + Default: false + +**--bootloader**=*BOOTLOADER* + + The bootloader to use + + Possible values: + - grub + - systemd + +**--uki-addon**=*UKI_ADDON* + + Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed + + + +# VERSION + + + diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md new file mode 100644 index 000000000..de8d0af9e --- /dev/null +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -0,0 +1,138 @@ +# NAME + +bootc-install-to-filesystem - Install to an externally created +filesystem structure + +# SYNOPSIS + +**bootc install to-filesystem** \[*OPTIONS...*\] <*ROOT_PATH*> + +# DESCRIPTION + +Install to an externally created filesystem structure. + +In this variant of installation, the root filesystem alongside any +necessary platform partitions (such as the EFI system partition) are +prepared and mounted by an external tool or script. The root filesystem +is currently expected to be empty by default. + +# OPTIONS + + +**ROOT_PATH** + + Path to the mounted root filesystem + + This argument is required. + +**--root-mount-spec**=*ROOT_MOUNT_SPEC* + + Source device specification for the root filesystem. For example, `UUID=2e9f4241-229b-4202-8429-62d2302382e1`. If not provided, the UUID of the target filesystem will be used. This option is provided as some use cases might prefer to mount by a label instead via e.g. `LABEL=rootfs` + +**--boot-mount-spec**=*BOOT_MOUNT_SPEC* + + Mount specification for the /boot filesystem + +**--replace**=*REPLACE* + + Initialize the system in-place; at the moment, only one mode for this is implemented. In the future, it may also be supported to set up an explicit "dual boot" system + + Possible values: + - wipe + - alongside + +**--acknowledge-destructive** + + If the target is the running system's root filesystem, this will skip any warnings + +**--skip-finalize** + + The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar operations, and finally mounting it readonly. This option skips those operations. It is then the responsibility of the invoking code to perform those operations + +**--source-imgref**=*SOURCE_IMGREF* + + Install the system from an explicitly given source + +**--target-transport**=*TARGET_TRANSPORT* + + The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry` + + Default: registry + +**--target-imgref**=*TARGET_IMGREF* + + Specify the image to fetch for subsequent updates + +**--enforce-container-sigpolicy** + + This is the inverse of the previous `--target-no-signature-verification` (which is now a no-op). Enabling this option enforces that `/etc/containers/policy.json` includes a default policy which requires signatures + +**--run-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--skip-fetch-check** + + Verify the image can be fetched from the bootc image. Updates may fail when the installation host is authenticated with the registry but the pull secret is not in the bootc image + +**--disable-selinux** + + Disable SELinux in the target (installed) system + +**--karg**=*KARG* + + Add a kernel argument. This option can be provided multiple times + +**--root-ssh-authorized-keys**=*ROOT_SSH_AUTHORIZED_KEYS* + + The path to an `authorized_keys` that will be injected into the `root` account + +**--generic-image** + + Perform configuration changes suitable for a "generic" disk image. At the moment: + +**--bound-images**=*BOUND_IMAGES* + + How should logically bound images be retrieved + + Possible values: + - stored + - skip + - pull + + Default: stored + +**--stateroot**=*STATEROOT* + + The stateroot name to use. Defaults to `default` + +**--composefs-backend** + + If true, composefs backend is used, else ostree backend is used + + Default: false + +**--insecure** + + Make fs-verity validation optional in case the filesystem doesn't support it + + Default: false + +**--bootloader**=*BOOTLOADER* + + The bootloader to use + + Possible values: + - grub + - systemd + +**--uki-addon**=*UKI_ADDON* + + Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed + + + +# VERSION + + + diff --git a/docs/src/man/bootc-install.8.md b/docs/src/man/bootc-install.8.md new file mode 100644 index 000000000..cc4a91a4c --- /dev/null +++ b/docs/src/man/bootc-install.8.md @@ -0,0 +1,51 @@ +# NAME + +bootc-install - Install the running container to a target + +# SYNOPSIS + +**bootc install** \[*OPTIONS...*\] <*SUBCOMMAND*> + +# DESCRIPTION + +Install the running container to a target. + +## Understanding installations + +OCI containers are effectively layers of tarballs with JSON for +metadata; they cannot be booted directly. The `bootc install` flow is +a highly opinionated method to take the contents of the container image +and install it to a target block device (or an existing filesystem) in +such a way that it can be booted. + +For example, a Linux partition table and filesystem is used, and the +bootloader and kernel embedded in the container image are also prepared. + +A bootc installed container currently uses OSTree as a backend, and this +sets it up such that a subsequent `bootc upgrade` can perform in-place +updates. + +An installation is not simply a copy of the container filesystem, but +includes other setup and metadata. + + + + +# SUBCOMMANDS + + +| Command | Description | +|---------|-------------| +| **bootc install to-disk** | Install to the target block device | +| **bootc install to-filesystem** | Install to an externally created filesystem structure | +| **bootc install to-existing-root** | Install to the host root filesystem | +| **bootc install finalize** | Execute this as the penultimate step of an installation using `install to-filesystem` | +| **bootc install ensure-completion** | Intended for use in environments that are performing an ostree-based installation, not bootc | +| **bootc install print-configuration** | Output JSON to stdout that contains the merged installation configuration as it may be relevant to calling processes using `install to-filesystem` that in particular want to discover the desired root filesystem type from the container image | + + + +# VERSION + + + diff --git a/docs/src/man/bootc-rollback.8.md b/docs/src/man/bootc-rollback.8.md new file mode 100644 index 000000000..e5f22e173 --- /dev/null +++ b/docs/src/man/bootc-rollback.8.md @@ -0,0 +1,71 @@ +# NAME + +bootc-rollback - Change the bootloader entry ordering + +# SYNOPSIS + +**bootc rollback** \[*OPTIONS...*\] + +# DESCRIPTION + +Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot, +and the current will become rollback. If there is a `staged` entry (an unapplied, queued upgrade) +then it will be discarded. + +Note that absent any additional control logic, if there is an active agent doing automated upgrades +(such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the +change here may be reverted. It's recommended to only use this in concert with an agent that +is in active control. + +A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in +order to detect a rollback invocation. + +## Note on Rollbacks and the `/etc` Directory + +When you perform a rollback (e.g., with `bootc rollback`), any +changes made to files in the `/etc` directory won't carry over +to the rolled-back deployment. The `/etc` files will revert +to their state from that previous deployment instead. + +This is because `bootc rollback` just reorders the existing +deployments. It doesn't create new deployments. The `/etc` +merges happen when new deployments are created. + +# OPTIONS + + +**--apply** + + Restart or reboot into the rollback image + +**--soft-reboot**=*SOFT_REBOOT* + + Configure soft reboot behavior + + Possible values: + - required + - auto + + + +# EXAMPLES + +Rollback to the previous deployment: + + bootc rollback + +Rollback and immediately apply the changes: + + bootc rollback --apply + +Rollback with soft reboot if possible: + + bootc rollback --apply --soft-reboot=auto + +# SEE ALSO + +**bootc**(8), **bootc-upgrade**(8), **bootc-switch**(8), **bootc-status**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-root-setup.service.5.md b/docs/src/man/bootc-root-setup.service.5.md new file mode 100644 index 000000000..7cee4308a --- /dev/null +++ b/docs/src/man/bootc-root-setup.service.5.md @@ -0,0 +1,73 @@ +# NAME + +bootc-root-setup.service + +# DESCRIPTION + +This service runs in the initramfs to set up the root filesystem when composefs is enabled. +It is only activated when the `composefs` kernel command line parameter is present. + +The service performs the following operations: + +- Mounts the composefs image specified in the kernel command line +- Sets up `/etc` and `/var` directories from the deployment state +- Optionally configures transient overlays based on the configuration file +- Prepares the root filesystem for switch-root + +This service runs after `sysroot.mount` and `ostree-prepare-root.service`, and before +`initrd-root-fs.target`. + +# CONFIGURATION FILE + +The service reads an optional configuration file at `/usr/lib/composefs/setup-root-conf.toml`. +If this file does not exist, default settings are used. + +**WARNING**: The configuration file format and composefs integration are experimental +and subject to change. + +## Configuration Options + +The configuration file uses TOML format with the following sections: + +### `[root]` + +- `transient` (boolean): If true, mounts the root filesystem as a transient overlay. + This makes all changes to `/` ephemeral and lost on reboot. Default: false. + +### `[etc]` + +- `mount` (string): Mount type for `/etc`. Options: "none", "bind", "overlay", "transient". + Default: "bind". +- `transient` (boolean): Shorthand for `mount = "transient"`. Default: false. + +### `[var]` + +- `mount` (string): Mount type for `/var`. Options: "none", "bind", "overlay", "transient". + Default: "bind". +- `transient` (boolean): Shorthand for `mount = "transient"`. Default: false. + +## Example Configuration + +```toml +[root] +transient = false + +[etc] +mount = "bind" + +[var] +mount = "overlay" +``` + +# EXPERIMENTAL STATUS + +The composefs integration, including this service and its configuration file format, +is experimental and subject to change. + +# SEE ALSO + +**bootc(8)** + +# VERSION + + diff --git a/docs/src/man/bootc-status-updated.path.5.md b/docs/src/man/bootc-status-updated.path.5.md new file mode 100644 index 000000000..8404b46f1 --- /dev/null +++ b/docs/src/man/bootc-status-updated.path.5.md @@ -0,0 +1,21 @@ +# NAME + +bootc-status-updated.path + +# DESCRIPTION + +This unit watches the `bootc` root directory (/ostree/bootc) for +modification, and triggers the companion `bootc-status-updated.target` +systemd unit. + +The `bootc` program updates the mtime on its root directory when the +contents of `bootc status` changes as a result of an +update/upgrade/edit/switch/rollback operation. + +# SEE ALSO + +**bootc**(1), **bootc-status-updated.target**(5) + +# VERSION + + diff --git a/docs/src/man/bootc-status-updated.target.5.md b/docs/src/man/bootc-status-updated.target.5.md new file mode 100644 index 000000000..b9bc71833 --- /dev/null +++ b/docs/src/man/bootc-status-updated.target.5.md @@ -0,0 +1,25 @@ +# NAME + +bootc-status-updated.target + +# DESCRIPTION + +This unit is triggered by the companion `bootc-status-updated.path` +systemd unit. This target is intended to enable users to add custom +services to trigger as a result of `bootc status` changing. + +Add the following to your unit configuration to active it when `bootc +status` changes: + +``` +[Install] +WantedBy=bootc-status-updated.target +``` + +# SEE ALSO + +**bootc**(1), **bootc-status-updated.path**(5) + +# VERSION + + diff --git a/docs/src/man/bootc-status.8.md b/docs/src/man/bootc-status.8.md new file mode 100644 index 000000000..71be313b7 --- /dev/null +++ b/docs/src/man/bootc-status.8.md @@ -0,0 +1,86 @@ +# NAME + +bootc-status - Display status + +# SYNOPSIS + +**bootc status** \[*OPTIONS...*\] + +# DESCRIPTION + +Display status. + +If standard output is a terminal, this will output a description of the bootc system state. +If standard output is not a terminal, output a YAML-formatted object using a schema +intended to match a Kubernetes resource that describes the state of the booted system. + +## Parsing output via programs + +Either the default YAML format or `--format=json` can be used. Do not attempt to +explicitly parse the output of `--format=humanreadable` as it will very likely +change over time. + +## Programmatically detecting whether the system is deployed via bootc + +Invoke e.g. `bootc status --json`, and check if `status.booted` is not `null`. + +### Detecting rpm-ostree vs bootc + +There is no "bootc runtime". When used with the default ostree backend, bootc +and tools like rpm-ostree end up sharing the same code and doing effectively the same thing. +Hence, there isn't a mechanism to detect if a system "is bootc" or "is rpm-ostree". + +However, if the `incompatible` flag is set on a deployment, then there are layered packages and +`rpm-ostree` must be used for mutation. + +# OPTIONS + + +**--format**=*FORMAT* + + The output format + + Possible values: + - humanreadable + - yaml + - json + +**--format-version**=*FORMAT_VERSION* + + The desired format version. There is currently one supported version, which is exposed as both `0` and `1`. Pass this option to explicitly request it; it is possible that another future version 2 or newer will be supported in the future + +**--booted** + + Only display status for the booted deployment + +**-v**, **--verbose** + + Include additional fields in human readable format + + + +# EXAMPLES + +Show current system status: + + bootc status + +Show status in JSON format: + + bootc status --format=json + +Show detailed status with verbose output: + + bootc status --verbose + +Show only booted deployment status: + + bootc status --booted + +# SEE ALSO + +**bootc**(8), **bootc-upgrade**(8), **bootc-switch**(8), **bootc-rollback**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md new file mode 100644 index 000000000..116f98553 --- /dev/null +++ b/docs/src/man/bootc-switch.8.md @@ -0,0 +1,98 @@ +# NAME + +bootc-switch - Target a new container image reference to boot + +# SYNOPSIS + +**bootc switch** \[*OPTIONS...*\] <*TARGET*> + +# DESCRIPTION + +Target a new container image reference to boot. + +This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference +instead. + +## Usage + +A common pattern is to have a management agent control operating system updates via container image tags; +for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines +are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`. + +It is also supported to provide explicit digests, via e.g. `bootc switch quay.io/exampleos/someuser@sha256:9cca0703342e24806a9f64e08c053dca7f2cd90f10529af8ea872afb0a0c77d4`. When you do this, `bootc upgrade` will always be a no-op. In this model, upgrades are then always triggered by further `switch` operations. + +## Applying Changes + +The `--apply` option will automatically take action (rebooting) if the system has changed after switching to the new image. Currently, this option always reboots the system. In the future, this command may detect cases where no kernel changes are queued and perform a userspace-only restart instead. + +## Soft Reboot + +The `--soft-reboot` option configures soft reboot behavior when used with `--apply`: + +- `required`: The operation will fail if soft reboot is not available on the target system +- `auto`: Uses soft reboot if available on the target system, otherwise falls back to a regular reboot + +Soft reboot allows faster system restart by avoiding full hardware reboot when possible. + +# OPTIONS + + +**TARGET** + + Target image to use for the next boot + + This argument is required. + +**--quiet** + + Don't display progress + +**--apply** + + Restart or reboot into the new target image + +**--soft-reboot**=*SOFT_REBOOT* + + Configure soft reboot behavior + + Possible values: + - required + - auto + +**--transport**=*TRANSPORT* + + The transport; e.g. registry, oci, oci-archive, docker-daemon, containers-storage. Defaults to `registry` + + Default: registry + +**--enforce-container-sigpolicy** + + This is the inverse of the previous `--target-no-signature-verification` (which is now a no-op) + +**--retain** + + Retain reference to currently booted image + + + +# EXAMPLES + +Switch to a different image version: + + bootc switch quay.io/exampleos/myapp:v1.1 + +Switch and immediately apply the changes: + + bootc switch --apply quay.io/exampleos/myapp:v1.1 + +Switch with soft reboot if possible: + + bootc switch --apply --soft-reboot=auto quay.io/exampleos/myapp:v1.1 + +# SEE ALSO + +**bootc**(8), **bootc-upgrade**(8), **bootc-status**(8), **bootc-rollback**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md new file mode 100644 index 000000000..451cdf82a --- /dev/null +++ b/docs/src/man/bootc-upgrade.8.md @@ -0,0 +1,86 @@ +# NAME + +bootc-upgrade - Download and queue an updated container image to apply + +# SYNOPSIS + +**bootc upgrade** \[*OPTIONS...*\] + +# DESCRIPTION + +Download and queue an updated container image to apply. + +This does not affect the running system; updates operate in an "A/B" style by default. + +A queued update is visible as `staged` in `bootc status`. + +## Checking for Updates + +The `--check` option allows you to verify if updates are available without downloading the full image layers. This only downloads the updated manifest and image configuration (typically kilobyte-sized metadata), making it much faster than a full upgrade. + +## Applying Updates + +Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`. +There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting) +if the system has changed. + +The `--apply` option currently always reboots the system. In the future, this command may detect cases where no kernel changes are queued and perform a userspace-only restart instead. + +However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply` +do *not* automatically apply the update in addition. + +## Soft Reboot + +The `--soft-reboot` option configures soft reboot behavior when used with `--apply`: + +- `required`: The operation will fail if soft reboot is not available on the target system +- `auto`: Uses soft reboot if available on the target system, otherwise falls back to a regular reboot + +Soft reboot allows faster system restart by avoiding full hardware reboot when possible. + +# OPTIONS + + +**--quiet** + + Don't display progress + +**--check** + + Check if an update is available without applying it + +**--apply** + + Restart or reboot into the new target image + +**--soft-reboot**=*SOFT_REBOOT* + + Configure soft reboot behavior + + Possible values: + - required + - auto + + + +# EXAMPLES + +Check for available updates: + + bootc upgrade --check + +Upgrade and immediately apply the changes: + + bootc upgrade --apply + +Upgrade with soft reboot if possible: + + bootc upgrade --apply --soft-reboot=auto + +# SEE ALSO + +**bootc**(8), **bootc-switch**(8), **bootc-status**(8), **bootc-rollback**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-usr-overlay.8.md b/docs/src/man/bootc-usr-overlay.8.md new file mode 100644 index 000000000..afb032ad1 --- /dev/null +++ b/docs/src/man/bootc-usr-overlay.8.md @@ -0,0 +1,40 @@ +# NAME + +bootc-usr-overlay - Adds a transient writable overlayfs on `/usr` that +will be discarded on reboot + +# SYNOPSIS + +**bootc usr-overlay** \[*OPTIONS...*\] + +# DESCRIPTION + +Adds a transient writable overlayfs on `/usr` that will be discarded +on reboot. + +## USE CASES + +A common pattern is wanting to use tracing/debugging tools, such as +`strace` that may not be in the base image. A system package manager +such as `apt` or `dnf` can apply changes into this transient overlay +that will be discarded on reboot. + +## /ETC AND /VAR + +However, this command has no effect on `/etc` and `/var` - changes +written there will persist. It is common for package installations to +modify these directories. + +## UNMOUNTING + +Almost always, a system process will hold a reference to the open mount +point. You can however invoke `umount -l /usr` to perform a "lazy +unmount". + + + + +# VERSION + + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md new file mode 100644 index 000000000..dd9ee7d18 --- /dev/null +++ b/docs/src/man/bootc.8.md @@ -0,0 +1,43 @@ +# NAME + +bootc - Deploy and transactionally in-place with bootable container +images + +# SYNOPSIS + +**bootc** \[*OPTIONS...*\] <*SUBCOMMAND*> + +# DESCRIPTION + +Deploy and transactionally in-place with bootable container images. + +The `bootc` project currently uses ostree-containers as a backend to +support a model of bootable container images. Once installed, whether +directly via `bootc install` (executed as part of a container) or via +another mechanism such as an OS installer tool, further updates can be +pulled and `bootc upgrade`. + + + + +# SUBCOMMANDS + + +| Command | Description | +|---------|-------------| +| **bootc upgrade** | Download and queue an updated container image to apply | +| **bootc switch** | Target a new container image reference to boot | +| **bootc rollback** | Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot, and the current will become rollback. If there is a `staged` entry (an unapplied, queued upgrade) then it will be discarded | +| **bootc edit** | Apply full changes to the host specification | +| **bootc status** | Display status | +| **bootc usr-overlay** | Add a transient writable overlayfs on `/usr` | +| **bootc install** | Install the running container to a target | +| **bootc container** | Operations which can be executed as part of a container build | +| **bootc composefs-finalize-staged** | | + + + +# VERSION + + + diff --git a/docs/src/man/system-reinstall-bootc.8.md b/docs/src/man/system-reinstall-bootc.8.md new file mode 100644 index 000000000..434e5c6d5 --- /dev/null +++ b/docs/src/man/system-reinstall-bootc.8.md @@ -0,0 +1,50 @@ +# NAME + +system-reinstall-bootc - Reinstall the current system with a bootc image + +# SYNOPSIS + +**system-reinstall-bootc** <*BOOTC_IMAGE*> + +# DESCRIPTION + +**system-reinstall-bootc** is a utility that allows you to reinstall your current system using a bootc container image. This tool provides an interactive way to replace your existing system with a new bootc-based system while preserving SSH access and making the previous root filesystem (including user data) available in `/sysroot`. + +The utility will: +- Pull the specified bootc container image +- Collect SSH keys for root access after reinstall +- Execute a bootc install to replace the current system +- Reboot into the new system + +After reboot, the previous root filesystem will be available in `/sysroot`, and some automatic cleanup of the previous root will be performed. Note that existing mounts will not be automatically mounted by the bootc system unless they are defined in the bootc image. + +This is primarily intended as a way to "take over" cloud virtual machine images, effectively using them as an installer environment. + +# ARGUMENTS + +**BOOTC_IMAGE** + + The bootc container image to install (e.g., quay.io/fedora/fedora-bootc:41) + + This argument is required. + +# EXAMPLES + +Reinstall with a custom bootc image: +``` +system-reinstall-bootc registry.example.com/my-bootc:latest +``` + +# ENVIRONMENT + +**BOOTC_REINSTALL_CONFIG** + + This variable is deprecated. + +# SEE ALSO + +**bootc**(8), **bootc-install**(8) + +# VERSION + + diff --git a/docs/src/package-managers.md b/docs/src/package-managers.md new file mode 100644 index 000000000..03bc979d6 --- /dev/null +++ b/docs/src/package-managers.md @@ -0,0 +1,111 @@ +# Package manager integration + +A toplevel goal of bootc is to encourage a default model +where Linux systems are built and delivered as (container) images. +In this model, the default usage of package managers such as `apt` and `dnf` +will be at container build time. + +However, one may end up shipping the package manager tooling onto +the end system. In some cases this may be desirable even, to allow +workflows with transient overlays using e.g. `bootc usroverlay`. + +## Detecting image-based systems + +bootc is not the only image based system; there are many. A common +emphasis is on having the operating system content in `/usr`, +and for that filesystem to be mounted read-only at runtime. + +A first recommendation here is that package managers should +detect if `/usr` is read-only, and provide a useful error +message referring users to documentation guidance. + +An example of a non-bootc case is "Live CD" environments, +where the *physical media* is readonly. Some Live operating system environments end +up mounting a transient writable overlay (whether via e.g. devicemapper or overlayfs) +that make the system appear writable, but it's arguably clearer not to do so by +default. Detecting `/usr` as read-only here and providing the same information +would make sense. + +To specifically detect if bootc is in use, you can parse its JSON status +(if the binary is present) to tell if a system is tracking an image. +The following command succeeds if an image is *not* being tracked: +`test $(bootc status --format=json | jq .spec.image) = null`. + +### The `/run/ostree-booted` file + +This is created by ostree, and hence created by bootc (with the ostree) +backend. You can use it to detect ostree. However, *most* cases +should instead detect via one of the recommendations above. + +### Running a read-only system via podman/docker + +The historical default for docker (inherited into podman) is that +the `/` is a writable (but transient) overlayfs. However, e.g. `podman` +supports a `--read-only` flag, and [Kubernetes pods](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/) offer a +`securityContext.readOnlyRootFilesystem` flag. + +Running containers in production in this way is a good idea, +for exactly the same reasons that bootc defaults to mounting +the system read-only. + +Ensure that your package manager offers a useful error message +in this mode. Today for example: + +``` +$ podman run --read-only --rm -ti debian apt update +Reading package lists... Done +E: List directory /var/lib/apt/lists/partial is missing. - Acquire (30: Read-only file system) +$ podman run --read-only --rm -ti quay.io/fedora/fedora:40 dnf -y install strace +Config error: [Errno 30] Read-only file system: '/var/log/dnf.log': '/var/log/dnf.log' +``` + +However note that both of these fail on `/var` being read-only; in a default bootc +model, it won't be. A more accurate check is thus closer to: + +``` +$ podman run --read-only --rm -ti --tmpfs /var quay.io/fedora/fedora:40 dnf -y install strace +... +Error: Transaction test error: + installing package strace-6.9-1.fc40.x86_64 needs 2MB more space on the / filesystem +``` + +``` +$ podman run --read-only --rm --tmpfs /var -ti debian /bin/sh -c 'apt update && apt -y install strace' +... +dpkg: error processing archive /var/cache/apt/archives/libunwind8_1.6.2-3_amd64.deb (--unpack): + unable to clean up mess surrounding './usr/lib/x86_64-linux-gnu/libunwind-coredump.so.0.0.0' before installing another version: Read-only file system +``` + +These errors message are misleading and confusing for the user. A more useful error may look like e.g.: + +``` +$ podman run --read-only --rm --tmpfs /var -ti debian /bin/sh -c 'apt update && apt -y install strace' +error: read-only /usr detected, refusing to operate. See `man apt-image-based` for more information. +``` + + +## Transient overlays + +Today there is a simple `bootc usroverlay` command that adds a transient writable overlayfs for `/usr`. +This makes many package manager operations work; conceptually it is similar +to the writable overlay that many "Live CDs" use. However, one cannot change the kernel +this way for example. + +An optional integration that package managers can do is to detect this transient overlay +situation and inform the user that the changes will be ephemeral. + +## Persistent changes + +A bootc system by default *does* have a writable, persistent data store that holds +multiple container image versions (more in [filesystem](filesystem.md)). + +Systems such as [rpm-ostree](https://github.com/coreos/rpm-ostree/) implement +a "hybrid" mechanism where packages can be persistently layered and re-applied; +the system effectively does a "local build", unioning the intermediate filesystems. + +One aspect of how rpm-ostree implements this is by caching individual unpacked RPMs as ostree commits +in the ostree repo. + +This section will be expanded later; you may also be able to find more information in [booting local builds](booting-local-builds.md). + + diff --git a/docs/src/packaging-and-integration.md b/docs/src/packaging-and-integration.md new file mode 100644 index 000000000..0ab936c54 --- /dev/null +++ b/docs/src/packaging-and-integration.md @@ -0,0 +1,132 @@ +# Packaging and Integration + +This document describes how to build and package bootc for distribution in operating systems. + +### Build Requirements + +- Rust toolchain (see `rust-toolchain.toml` for the version) +- `coreutils` and `make` + +### Basic Build Commands + +The primary build targets are: + +```bash +make all +``` + +This builds: +- Binary artifacts (`cargo build --release`) +- Man pages (via `cargo xtask manpages`) + +The built binaries are placed in `target/release/`: +- `bootc` - The main bootc CLI +- `system-reinstall-bootc` - System reinstallation tool +- `bootc-initramfs-setup` - Initramfs setup utility + +### Installation + +The `install` target supports the standard `DESTDIR` variable for staged installations, which is essential for packaging: + +```bash +make install DESTDIR=/path/to/staging/root +``` + + +The install target handles: +- Binary installation to `$(prefix)/bin` +- Man pages to `$(prefix)/share/man/man{5,8}` +- systemd units to `$(prefix)/lib/systemd/system` +- Documentation and examples to `$(prefix)/share/doc/bootc` +- Dracut module to `/usr/lib/dracut/modules.d/51bootc` +- Base image configuration files + +### Optional Installation Targets + +#### install-ostree-hooks + +For distributions that need bootc to provide compatibility with `ostree container` commands: + +```bash +make install-ostree-hooks DESTDIR=/tmp/stage +``` + +This creates symbolic links in `$(prefix)/libexec/libostree/ext/` for: +- `ostree-container` +- `ostree-ima-sign` +- `ostree-provisional-repair` + +## Source Packaging + +### Vendored Dependencies + +bootc is written in Rust and has numerous dependencies. For distribution packaging, we recommend using a vendored tarball of Rust crates to ensure reproducible builds and avoid network access during the build process. + +#### Generating the Vendor Tarball + +Use the `cargo xtask package` command to generate both source and vendor tarballs: + +```bash +cargo xtask package +``` + +This creates two files in the `target/` directory: +- `bootc-.tar.zstd` - Source tarball with git archive contents +- `bootc--vendor.tar.zstd` - Vendored Rust dependencies + +The source tarball includes a `.cargo/vendor-config.toml` file that configures cargo to use the vendored dependencies. + +#### Using Vendored Dependencies in Builds + +When building with vendored dependencies: + +1. Extract both tarballs into your build directory +2. Extract the vendor tarball to create a `vendor/` directory +3. Ensure `.cargo/vendor-config.toml` is in place (included in source tarball) +4. Build normally with `make all` + +The cargo build will automatically use the vendored crates instead of fetching from crates.io. + +### Version Management + +The version is derived from git tags. The `cargo xtask package` command automatically determines the version: +- If the current commit has a tag: uses the tag (e.g., `v1.0.0` becomes `1.0.0`) +- Otherwise: generates a timestamp-based version with commit hash (e.g., `202501181430.g1234567890`) + +This ensures that development snapshots have monotonically increasing version numbers. + +## Cargo Features + +The build respects the `CARGO_FEATURES` environment variable. By default, the Makefile auto-detects whether to enable the `rhsm` (Red Hat Subscription Manager) feature based on the build environment's `/usr/lib/os-release`. + +To explicitly control features: + +```bash +make all CARGO_FEATURES="rhsm" +``` + +## Integration Testing + +For distributions that want to include integration tests, use: + +```bash +make install-all DESTDIR=/tmp/stage +``` + +This installs: +- Everything from `make install` +- Everything from `make install-ostree-hooks` +- The integration test binary as `bootc-integration-tests` + +## Base image content + +Alongside building the binary here, you may also want to prepare +a base image. For that, see [bootc-images](bootc-images.md). + +## Additional Resources + +- See `Makefile` for all available targets and variables +- See `crates/xtask/src/xtask.rs` for cargo xtask implementation details +- See `contrib/packaging/bootc.spec` for an example RPM spec file that + uses all of the above. + diff --git a/docs/src/progress-v0.schema.json b/docs/src/progress-v0.schema.json new file mode 100644 index 000000000..956f3ef16 --- /dev/null +++ b/docs/src/progress-v0.schema.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Event", + "description": "An event emitted as JSON.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Start" + }, + "version": { + "description": "The semantic version of the progress protocol.", + "type": "string" + } + }, + "required": [ + "type", + "version" + ] + }, + { + "description": "An incremental update to a container image layer download", + "type": "object", + "properties": { + "bytes": { + "description": "The number of bytes already fetched.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytesCached": { + "description": "The number of bytes fetched by a previous run.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytesTotal": { + "description": "Total number of bytes. If zero, then this should be considered \"unspecified\".", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "description": { + "description": "A human readable description of the task if i18n is not available.", + "type": "string" + }, + "id": { + "description": "A human and machine readable unique identifier for the task\n(e.g., the image name). For tasks that only happen once,\nit can be set to the same value as task.", + "type": "string" + }, + "steps": { + "description": "The initial position of progress.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "stepsCached": { + "description": "The number of steps fetched by a previous run.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "stepsTotal": { + "description": "The total number of steps (e.g. container image layers, RPMs)", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "subtasks": { + "description": "The currently running subtasks.", + "type": "array", + "items": { + "$ref": "#/$defs/SubTaskBytes" + } + }, + "task": { + "description": "A machine readable type (e.g., pulling) for the task (used for i18n\nand UI customization).", + "type": "string" + }, + "type": { + "type": "string", + "const": "ProgressBytes" + } + }, + "required": [ + "type", + "task", + "description", + "id", + "bytesCached", + "bytes", + "bytesTotal", + "stepsCached", + "steps", + "stepsTotal", + "subtasks" + ] + }, + { + "description": "An incremental update with discrete steps", + "type": "object", + "properties": { + "description": { + "description": "A human readable description of the task if i18n is not available.", + "type": "string" + }, + "id": { + "description": "A human and machine readable unique identifier for the task\n(e.g., the image name). For tasks that only happen once,\nit can be set to the same value as task.", + "type": "string" + }, + "steps": { + "description": "The initial position of progress.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "stepsCached": { + "description": "The number of steps fetched by a previous run.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "stepsTotal": { + "description": "The total number of steps (e.g. container image layers, RPMs)", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "subtasks": { + "description": "The currently running subtasks.", + "type": "array", + "items": { + "$ref": "#/$defs/SubTaskStep" + } + }, + "task": { + "description": "A machine readable type (e.g., pulling) for the task (used for i18n\nand UI customization).", + "type": "string" + }, + "type": { + "type": "string", + "const": "ProgressSteps" + } + }, + "required": [ + "type", + "task", + "description", + "id", + "stepsCached", + "steps", + "stepsTotal", + "subtasks" + ] + } + ], + "$defs": { + "SubTaskBytes": { + "description": "An incremental update to e.g. a container image layer download.\nThe first time a given \"subtask\" name is seen, a new progress bar should be created.\nIf bytes == bytes_total, then the subtask is considered complete.", + "type": "object", + "properties": { + "bytes": { + "description": "Updated byte level progress", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytesCached": { + "description": "The number of bytes fetched by a previous run (e.g., zstd_chunked).", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytesTotal": { + "description": "Total number of bytes", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "description": { + "description": "A human readable description of the task if i18n is not available.\n(e.g., \"OSTree Chunk:\", \"Derived Layer:\")", + "type": "string" + }, + "id": { + "description": "A human and machine readable identifier for the task\n(e.g., ostree chunk/layer hash).", + "type": "string" + }, + "subtask": { + "description": "A machine readable type for the task (used for i18n).\n(e.g., \"ostree_chunk\", \"ostree_derived\")", + "type": "string" + } + }, + "required": [ + "subtask", + "description", + "id", + "bytesCached", + "bytes", + "bytesTotal" + ] + }, + "SubTaskStep": { + "description": "Marks the beginning and end of a dictrete step", + "type": "object", + "properties": { + "completed": { + "description": "Starts as false when beginning to execute and turns true when completed.", + "type": "boolean" + }, + "description": { + "description": "A human readable description of the task if i18n is not available.\n(e.g., \"OSTree Chunk:\", \"Derived Layer:\")", + "type": "string" + }, + "id": { + "description": "A human and machine readable identifier for the task\n(e.g., ostree chunk/layer hash).", + "type": "string" + }, + "subtask": { + "description": "A machine readable type for the task (used for i18n).\n(e.g., \"ostree_chunk\", \"ostree_derived\")", + "type": "string" + } + }, + "required": [ + "subtask", + "description", + "id", + "completed" + ] + } + } +} \ No newline at end of file diff --git a/docs/src/registries-and-offline.md b/docs/src/registries-and-offline.md new file mode 100644 index 000000000..880fcf8e2 --- /dev/null +++ b/docs/src/registries-and-offline.md @@ -0,0 +1,65 @@ +# Accessing registries and disconnected updates + +The `bootc` project uses the [containers/image](https://github.com/containers/image) +library to fetch container images (the same used by `podman`) which means it honors almost all +the same configuration options in `/etc/containers`. + +## Insecure registries + +Container clients such as `podman pull` and `docker pull` have a `--tls-verify=false` +flag which says to disable TLS verification when accessing the registry. `bootc` +has no such option. Instead, you can globally configure the option +to disable TLS verification when accessing a specific registry via the +`/etc/containers/registries.conf.d` configuration mechanism, for example: + +``` +# /etc/containers/registries.conf.d/local-registry.conf +[[registry]] +location="localhost:5000" +insecure=true +``` + +For more, see [containers-registries.conf](https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md). + +## Private registries + +It's common to use a private repository when deploying a fleet of bootc instances. + +In addition to registry configuration, private registries require authentication. This is configured by placing an `auth.json` file at `/etc/ostree/auth.json`. + +For more, see [auth.json](https://man.archlinux.org/man/containers-auth.json.5) + +## Disconnected and offline updates + +It is common (a best practice even) to maintain systems which default +to being disconnected from the public Internet. + +### Pulling updates from a local mirror + +Everything in the section [remapping and mirroring images](https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md#remapping-and-mirroring-registries) +applies to bootc as well. + +### Performing offline updates via USB + +In a usage scenario where the operating system update is in a fully +disconnected environment and you want to perform updates via e.g. inserting +a USB drive, one can do this by copying the desired OS container image to +e.g. an `oci` directory: + +```bash +skopeo copy docker://quay.io/exampleos/myos:latest oci:/path/to/filesystem/myos.oci +``` + +Then once the USB device containing the `myos.oci` OCI directory is mounted +on the target, use + +```bash +bootc switch --transport oci /var/mnt/usb/myos.oci +``` + +The above command is only necessary once, and thereafter will be idempotent. +Then, use `bootc upgrade --apply` to fetch and apply the update from the USB device. + +This process can all be automated by creating systemd +units that look for a USB device with a specific label, mount (optionally with LUKS +for example), and then trigger the bootc upgrade. diff --git a/docs/src/related.md b/docs/src/related.md new file mode 100644 index 000000000..99cb2a192 --- /dev/null +++ b/docs/src/related.md @@ -0,0 +1 @@ +# Related projects diff --git a/docs/src/relationship-oci-artifacts.md b/docs/src/relationship-oci-artifacts.md new file mode 100644 index 000000000..5c1ec7f5b --- /dev/null +++ b/docs/src/relationship-oci-artifacts.md @@ -0,0 +1,5 @@ +# How does the use of OCI artifacts intersect with this effort? + +The "bootc compatible" images are OCI container images; they do not rely on the [OCI artifact specification](https://github.com/opencontainers/image-spec/blob/main/artifacts-guidance.md) or [OCI referrers API](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#enabling-the-referrers-api). + +It is foreseeable that users will need to produce "traditional" disk images (i.e. raw disk images, qcow2 disk images, Amazon AMIs, etc.) from the "bootc compatible" container images using additional tools. Therefore, it is reasonable that some users may want to encapsulate those disk images as an OCI artifact for storage and distribution. However, it is not a goal to use `bootc` to produce these "traditional" disk images nor to facilitate the encapsulation of those disk images as OCI artifacts. diff --git a/docs/src/relationship-particles.md b/docs/src/relationship-particles.md new file mode 100644 index 000000000..d19a94ec0 --- /dev/null +++ b/docs/src/relationship-particles.md @@ -0,0 +1,388 @@ +# Relationship with systemd "particles" + +There is an excellent [vision blog entry](https://0pointer.net/blog/fitting-everything-together.html) +that puts together a coherent picture for how a systemd (and [uapi-group.org](https://uapi-group.org/)) +oriented Linux based operating system can be put together, and the rationale for doing so. + +The "bootc vision" aligns with parts of this, but differs in emphasis and also some important technical details - and some of the emphasis and details have high level ramifications. Simply stated: related but different. + +## System emphasis + +The "particle" proposal mentions that the desktop case is most +interesting; the bootc belief is that servers are equally +important and interesting. In practice, this is not a real point +of differentiation, because the systemd project has done an excellent +job in catering to all use cases (desktop, embedded, server) etc. + +An important aspect related to this is that the bootc project exists and must +interact with many ecosystems, from "systemd-oriented Linux" to Android and +Kubernetes. Hence, we would not explicitly compare with just ChromeOS, but also +with e.g. [Kairos](https://kairos.io/) and many others. + +## Design goals + +Many of the toplevel design goals do overall align. It is clear that e.g. +[Discoverable Disk Images](https://uapi-group.org/specifications/specs/discoverable_disk_image/) +and [OCI images](https://github.com/opencontainers/image-spec) align on managing +systems in an image-oriented fashion. + +### A difference on goal 11 + +Goal 11 states: + +> Things should not require explicit installation. i.e. every image should be a live image. For installation it should be sufficient to dd an OS image onto disk. + +The `bootc install` approach is explicitly intending to support things such +as e.g. static IP addresses provisioned via kernel arguments at install time; +it is not a goal for installations to be equivalent to `dd`. The bootc creator has experience with systems that install this way, and it creates practical problems in nontrivial scenarios such as ["Advanced Format"](https://en.wikipedia.org/wiki/Advanced_Format) disk drives, etc. + +### New Goal: An explicit alignment with cloud-native + +The bootc project has an explicit goal to to take formats, cues and inspiration +from the container and cloud-native ecosystem. More on this in several sections below. + +### New Goal: Continued explicit support for "unlocked" systems + +A strong emphasis of the particle approach is "sealed" systems that chain from Secure Boot. +bootc aims to support the same. And in practice, nothing in "particles" strictly +requires Secure Boot etc. + +However, bootc has a stronger emphasis on continuing to support "unlocked" +systems into the foreseeable future in which key (even root level) operating system +changes can be that are outside of an explicit signed state and feel +*equally* first class, not just "developer system extensions". + +Or stated more simply, it will be explicitly supported to create bootc-based +operating systems that boot as e.g. a cloud instance or as desktop machine that defaults to an unlocked state and provides good ergonomics in this scenario for managing user owned +state across operating system upgrades too. + +## Hermetic `/usr` + +One of the biggest differences starts with this. The idea of having +the entire operating system self-contained in `/usr` is a good one. However, +there is an immense amount of prior history and details that make this +hard to support in many generalized cases. + +[This tracking issue](https://github.com/uapi-group/specifications/issues/76) is a good starting point - it's mostly about `/etc` (see below). + +### bootc design: Carve out sub mounts + +Instead, the bootc model allows arbitrary directory roots starting from `/` +to be included in the base operating system image. + +This first notable difference is rooted in bootc taking a stronger cue from the [opencontainers](https://github.com/opencontainers) ecosystem (including docker/podman/Kubernetes). +There are no restrictions on application container filesystem layout (everything +is ephemeral by default, and persistence must be explicit); bootc aims to be closer +to this. + +There is still alignment: bootc design does *strongly encourage* operating +system state to live underneath `/usr` - it should be the default place for all +operating system executable binaries and default configuration. It should be +read-only by default. + +### `/etc` + +Today, the bootc project uses [ostree](https://github.com/ostreedev/ostree/) as a backend, +and a key semantic ostree provides for `/etc` is a "3 way merge". + +This has several important differences. First, it means that `/etc` does get updated +by default for unchanged configuration files. + +The default proposal for "particle" OSes to deal with "legacy" config files in +`/etc` is to copy them on first OS install (e.g. `/usr/share/factory`). + +This creates serious problems for all the software (for example, OpenSSH) that put config +files there; - having the default configuration updated (e.g. for a security issue) for a package manager but not an image based update is not viable. + +However a key point of alignment between the two is that we still aim to +have `/etc` exist and be useful! Writing files there, whether from `vi` +or config management tooling must continue to work. Both bootc and systemd "particle" +systems should still Feel Like Unix - in contrast to e.g. Android. + +At the current time, this is implemented in ostree; as bootc moves +towards stronger integration with podman, it is likely that this logic +will simply be moved into bootc instead on top of podman. +Alternatively perhaps, podman itself may grow some support for +specifying this merge semantic for containers. + +### Other persistent state: `/var` + +Supporting arbitrary toplevel files in `/` on operating system +updates conflicts with a desire to have e.g. `/home` be persistent by default. + +Hence, bootc emphasizes having e.g. `/home` → `/var/home` as +a default symlink in base images. + +Aside from `/home` and `/etc`, it is common on most Linux systems to +have most persistent state under `/var`, so this is not a major point +of difference otherwise. + +### Other toplevel files/directories + +Even the operating systems have completed "UsrMerge" still have +legacy compatibility symlinks required in `/`, e.g. `/bin` → `/usr/bin`. +We still need to support shipping these for many cases, and they +are an important part of operating system state. Having them +not be explicitly managed by OS updates is hence suboptimal. + +Related to this, bootc will continue to support operating systems that have +not completed UsrMerge. + +## Discoverable Disk images and booting + +The bootc project will not use +[Discoverable Disk Images](https://uapi-group.org/specifications/specs/discoverable_disk_image/). +Instead, we orient as strongly around +[opencontainers/image-spec](https://github.com/opencontainers/image-spec) i.e. +OCI/Docker images. + +This is the biggest technical difference that strongly influences +many other aspects of operating system design and experience. + +It is an explicit goal of the bootc project that it should feel as +natural as possible for someone familiar with "application containers" +from podman/Docker/Kubernetes to take their tools and knowledge +and apply that to the base operating system too. + +### Technical heart: composefs + +There is a very strong security rationale behind much of the design proposal +of "particles" and DDIs. It is absolutely true today, quoting the blog: + +> That said, I think \[OCI has\] relatively weak properties, in particular when it comes to security, since immutability/measurements and similar are not provided. This means, unlike for system extensions and portable services a complete trust chain with attestation and per-app cryptographically protected data is much harder to implement sanely. + +The [composefs project](https://github.com/containers/composefs/) aims to close +this gap, and the bootc project will use it, and has an explicit goal +to align with e.g. [podman](https://github.com/containers/podman) in using it too. + +Effectively, everywhere one might use a DDI, bootc will usually support a container +image. (However for some things like system configuration files, bootc may +aim to instead support e.g. plain ConfigMap files which are signed for example). + +## System booting + +### The bootloader + +The strong emphasis of the UAPI-group is on +[UEFI](https://en.wikipedia.org/wiki/UEFI). However, the world is a bit broader +than that; the bootc project also will explicitly continue to support: + +- [GNU Grub](https://www.gnu.org/software/grub/) for multiple reasons; among them that unfortunately x86 BIOS systems will not disappear entirely in the next 10 years even. +- Android Boot - because some hardware manufacturers ship it, and we want to support operating systems that must work on this hardware. +- [zipl](https://www.ibm.com/docs/en/linux-on-z?topic=bs-zipl-initial-program-loader) because it's how things work on s390x, and there is significant alignment in terms of emphasizing a "unified kernel" style flow. + +#### Boot loader configs + +bootc aims to align with the idea of generic bootloader-independent config files where possible; today it uses ostree. For more on this, see [ostree and bootloaders](https://github.com/ostreedev/ostree/blob/main/docs/bootloaders.md). + +### The kernel and initramfs + +There is agreement that in order to achieve integrity, there must be a strong link +between the kernel and the first userspace code that executes in the initial RAM +disk. + +Building on the bootloader statement above: bootc will support [UKI](https://uapi-group.org/specifications/specs/unified_kernel_image/), but not require it. + +### The root filesystem + +In the bootc model, the root filesystem defaults to a single physical Linux filesystem (e.g. `xfs`, `ext4`, `btrfs` etc.). It is of course supported to mount other partitions and filesystems; doing so is encouraged even for `/var`. , where one ends up with some space constraints around the OS `/usr` partition due to dm-verity. + +This is a rather large difference already from particles; the root filesystem contains the operating system too; it is not a separate partition. One thing this helps significantly with is dealing with the "space management" problems that dm-verity introduces (need for +a partition to have unused empty space to grow, and also a fixed-size ultimate capacity limit). + +#### Locating the root + +bootc does not mandate or emphasize any particular way to locate the root filesystem; +parts of the [discoverable partitions specification](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/) specifically the "root partition" may be +used. Or, the root filesystem can be found the traditional way, via a local `root=` +kernel argument. + +Another point of contrast from the particle emphasis is that while we encourage encrypting the root filesystem, it is not required. Particularly some use cases in cloud environments perform encryption at the hypervisor level and do not want additional +overhead of doing so per virtual machine. + +#### Locating the base container image + +Until this point, we have been operating under external constraints; no one is creating +a bootloader that directly understands how to start a container image, for example. +We've gotten as far as running a Linux userspace in the initial RAM disk, and the +physical root filesystem is mounted. + +Here, we circle back to [composefs](https://github.com/containers/composefs). One can +think of composefs as effectively a way to manage something like dm-verity, but using +files. + +What bootc builds on top of that is to target a specific container image rootfs +that is part of the "physical" root. Today, this is implemented again using ostree, via the `ostree=` kernel commandline argument. In the future, it is likely to be a `bootc.image`. +However, integration with other bootloaders (such as Android Boot) require us to interact +with externally-specified fixed kernel arguments. + +Ultimately, the initramfs will contain logic to find the desired root container, which +again is just a set of files stored in the "physical" root filesystem. + +#### Chaining integrity from the initramfs + +One can think of composefs as effectively a way to manage something like dm-verity, but +supporting multiple ones stored inside a standard Linux filesystem. + +For "sealed" systems, the bootc project suggests a default model where there is an "ephemeral key" that binds the UKI (or equivalent) and the real root. For a bit more on this, see [ostree and composefs](https://ostreedev.github.io/ostree/composefs/#injecting-composefs-digests). Effectively, at image build time an "ephemeral" key is generated which signs the composefs digest of the container image. The public half +of this key is injected into the UKI, which is itself signed e.g. for Secure Boot. + +At boot time, the initramfs will use its embedded public key to verify the composefs +digest of the target root - and from there, overlayfs in the Linux kernel combined +with fs-verity will continually verify the integrity of all operating system root +files we use. + +At the current time, there is not one single standardized approach for signing composefs +images. Ultimately, a composefs image has a digest, and signing and verification +of that digest can be done via any signing tool. For more on this, see +[this issue](https://github.com/containers/composefs/issues/151). + +bootc itself will not mandate one mechanism currently. However, it is very likely +that we will ship an optionally-enabled opinionated mechanism that uses basic ed25519 +signatures for example. + +This is effectively equivalent to the particle approach of embedding a verity root hash into the kernel commandline - it means that the booted Linux kernel will *only* be capable +of mounting that one specific root filesystem. Note that this model is effectively +the same as e.g. Fedora uses to sign kernel modules. + +However, an "ephemeral key" is not the only valid way to do things; for some operating +system creators it may be very desirable to continue to be able to make root OS image +changes without changing the UKI (and hence re-signing it). Instead, another valid +approach is to simply maintain a persistent public/private keypair. This allows +disconnecting the build of userspace and kernel, but also means that there is less +strict verification between kernel and userspace (e.g. downgrade attacks become possible). + +#### Chaining integrity to configuration and application containers + +composefs is explicitly designed to be useful as a backend for "application" containers (e.g. podman). There is again not one single mechanism for signing and verification; in some use cases, it may be enough to boot the operating system enough to implement "network as source of truth" - for example, the public keys for verification of application containers +might be fetched from a remote server. Then before any application containers are run, +we dynamically fetch the relevant keys from a server which was trusted. + +The bootc project will align with podman in general, and make it easy to implement +a mechanism that chains keys stored alongside the operating system into composefs-signed +application containers. + +Configuration (effectively starting from `/etc` and the kernel commandline) in a "sealed" system is a complex topic. Many operating system builds will want to disable the default "etc merge" and make `/etc` always lifecycle bound with the OS: commonly writable but ephemeral. + +This topic is covered more in the next section. + +## Modularity + +A goal of "particles" is to add integrity into "general purpose" Linux OSes and distributions - supporting a world where there are a lot of users that simply directly install an OS from an upstream OS such as Debian or Fedora. This has a lot of implications; among them that e.g. the Secure Boot signatures etc. are made by the OS creator, not the user. + +A big emphasis for the bootc project in contrast a design where it is normal and expected for many users to *derive* (via standard container build technology) from the base image produced by the OS upstream. + +This is just a difference in emphasis: "particles" can clearly be built fully customized by the end customer, and bootc fully supports booting "stock" images. + +But still: the bootc project will again much more strongly push any scenario that desires truly strong integrity towards making and managing custom derived builds. + +### Extensions and security + +In "unlocked" scenarios, the bootc project will continue to support a "traditional Unix" feeling where persistent changes to `/etc` can be written and maintained. Similarly, it will continue to be supported to have machine-local kernel arguments. +There is significant value in migrating "package based" systems to "image based" systems, even if they are still "unsigned" or "unlocked". + +The particle model calls for tools like [confext](https://uapi-group.org/specifications/specs/extension_image/#confext-configuration-extension) that use DDIs. The "backend" of this (managing merged dynamic filesystem trees with overlayfs) and its relationship with systemd units is still relevant, but the bootc approach will again not expose DDIs to the user. Instead, our approach will take cues from the cloud-native world and use e.g. [Kubernetes ConfigMap](https://github.com/bootc-dev/bootc/issues/22) and support signatures on these. + +## More Modularity: Secondary OS installs + +This uses OCI containers, which will work the same as the host. + +## Developer Mode + +This topic heavily diverges between the "unlocked" and "sealed" cases. In the unlocked case, the bootc project aims to still continue to make it feel very "first class" to perform arbitrary machine-local mutations. Instead of managing overlay DDIs, `bootc` will make it trivial and obvious to use local container builds using any standard container build tooling. + +### Package managers + +In order to ease the transition for users coming from package systems, the bootc project suggests that package managers like `apt` and `dnf` etc. learn how to become a frontend for "local" container builds too. In other words, `apt|dnf install foo` would become shorthand for a container build like: + +``` +FROM +RUN apt|dnf install foo +``` + +### Transitioning from unlocked, mutable local state to server-built images + +Building on the above, a key point of `bootc` is to make it easy and obvious how to go from an "unlocked" system with potential unmanaged state towards a system built and managed using standard OCI container image build systems and tooling. For example, there should be a command like `apt|dnf print-containerfile`. (The problem is more complex than this of course, as we would likely want to capture some changes from `/etc` - but also some of those changes may include secrets, which are their own sub-topic) + +## Democratizing Code Signing + +Strong alignment here. + +## Running the OS itself in a container + +This is equally obvious to do when the host and the linked container runtime (e.g. podman) again use the same tools. + +## Parameterizing Kernels + +In "unlocked" scenarios (per above) we will continue to use bootloader configuration that is unsigned. + +We will not (in contrast to particles) try to strongly support a "partially sealed, general purpose" model. More on this below. + +Most cases for "sealed" systems will want to entirely lock the kernel commandline, not even using a bootloader at all and hence there is no mechanism to configure it locally at all. However, as discussed in various venues around UKI, "sealed" systems can become complex to deploy where there is a need for machine (or machine-type) specific kernel arguments: + +- Deploying the RT kernel often wants to use [isolcpus=](https://access.redhat.com/solutions/480473). +- Setting static IP addresses on the kernel commandline to enable [network bound disk encryption](https://access.redhat.com/articles/6987053) for the rootfs + +The bootc project default approach for this is to lean into the container-native world, using derivation to create a machine-independent "base image", then create derived, machine (or machine-class) specific images that are in turn signed. + +## Updating Images + +A big differentiation here is that bootc will reuse container technology for fetching updates. The operating system and application containers will be signed with e.g. [sigstore](https://www.sigstore.dev/) or similar for network fetching. The signature will cover the composefs digest, which enables continuous verification. + +Managing storage of container images using composefs is more complex than `systemd-sysupdate` writing to a partition, but significantly more flexible. For more on this, see [upstream composefs](https://github.com/containers/composefs). + +### Kernel in images + +The bootc and particle approaches are aligned on storing the kernel binary in `/usr/lib/modules/$kver`. On the bootc side, a key bit here is that bootc will extract the kernel and initramfs (or just UKI) and put it in the appropriate place - this is implemented as a transactional operation. There are significant details that can vary for how this works (because unlike particles, bootc aims to support non-EFI setups as well), but the high level idea is similar. + +## Boot Counting + Assessment + +This topic relates to the previous one; because of multiple bootloaders, there is not one single approach. The systemd [automatic boot assessment](https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT) is good where it can be used, but we also will support e.g. Android bootloaders. + +## Picking the Newest Version + +Because the storage of images is not just files or partitions, bootc will not expose to the user/administrator a semantic of `strvercmp` or package-manager oriented versioning semantics. Instead, the implementation of "latest" will be implemented in a more Kubernetes-oriented fashion of having "local" API objects with spec and status. This makes it easy and obvious for higher level management (e.g. cluster) +tooling to orchestrate updates in a Kubernetes-style fashion. + +## Home Directory Management + +The bootc project will not do anything with this. We will support [systemd-homed](https://www.freedesktop.org/software/systemd/man/systemd-homed.service.html) where users want it, but in many dedicated servers and managed devices the idea of persistent user "home directories" are more of an anti-pattern. + +## Partition Setup + +The biggest difference again here is that bootc is oriented closer to a single root partition by default that includes the OS, system/app containers and persistent local state all as one unit. + +## Trust chain + +In contrast to particles, the bootc project does not aim to by default emphasize a model of using sysexts from the initramfs because its primary use case occurs when using a "partially sealed" system. And per above (re kernels) it is insufficient for other cases. + +Without this in the mix then, the trust chain is simple to describe: the +kernel+initramfs are verified by the bootloader, the initramfs contains the key +and logic necessary to verify the composefs digest of the root, and the root +starts to verify everything else. + +## File System Choice + +As mentioned above, any Linux filesystem is valid for the root. For "sealed" systems using composefs will cover integrity and there is not a distinct need for dm-integrity. + +## OS Installation vs. OS Instantiation + +The bootc project is just less partition-oriented and more towards multiple-composefs-in-root oriented. However the high level goal is shared of making it easy to "re-provision" and keeping the install-time flow as close as possible. + +## Building Images According to this Model + +This is a key point of bootc: we aim for operating systems and distributions to ship their own bootc-compatible base images that can be used as a default derivation source. These images are just OCI images that will follow simple rules (as mentioned above, the kernel is found in `/usr/lib/modules/$kver/vmlinuz`) for example for the extra state to boot. + +However in order to enable "sealed" systems (using signed composefs digests), the container build system will need support for this. But, it is a goal to standardize the composefs metadata needed alongside the OCI, and to support this in the broader container ecosystem of tools (e.g. docker, podman) as well as bootc. + +## Final words + +This document is obviously very heavily inspired by [the original blog](https://0pointer.net/blog/fitting-everything-together.html). + +A point of divergence is that a goal of the bootc project *is* to strongly +influence the existing operating systems and distributions and help them migrate +their customers into an image-based world - and to make practical compromises in +order to aid that goal. + +But, the bootc project strongly agrees with the idea of finding common ground (the "50% shared" case). At a practical level, this project will take a hard dependency on systemd *and* on the container ecosystem, extending bridges where they exist, working on shared standards and approaches between the two. + diff --git a/docs/src/relationships.md b/docs/src/relationships.md new file mode 100644 index 000000000..aaa277461 --- /dev/null +++ b/docs/src/relationships.md @@ -0,0 +1,115 @@ +# Relationship with other projects + +bootc is the key component in a broader mission of [bootable containers](https://containers.github.io/bootable/). +Here's its relationship to other moving parts. + +## Relationship with podman + +It gets a bit confusing to talk about shipping bootable operating systems in container images. +Again, to be clear: we are reusing container images as: + +- A build mechanism (including running *as* a standard OCI container image) +- A transport mechanism + +But, actually when a bootc container is booted, podman (or docker, etc.) is not involved. +The storage used for the operating system content is distinct from `/var/lib/containers`. +`podman image prune --all` will not delete your operating system. + +That said, a toplevel goal of bootc is alignment with the https://github.com/containers ecosystem, +which includes podman. But more specifically at a technical level, today bootc uses +[skopeo](https://github.com/containers/skopeo/) and hence indirectly [containers/image](https://github.com/containers/image) +as a way to fetch container images. + +This means that bootc automatically also honors many of the knobs available in `/etc/containers` - specifically +things like [containers-registries.conf](https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md). + +In other words, if you configure `podman` to pull images from your local mirror registry, then `bootc` will automatically honor that as well. + +The simple way to say it is: A goal of `bootc` is to be the bootable-container analogue for `podman`, which runs application containers. Everywhere one might run `podman`, one could also consider using `bootc`. + +## Relationship with Image Builder (osbuild) + +There is a new [bootc-image-builder](https://github.com/osbuild/bootc-image-builder) project that is dedicated to the intersection of these two! + +## Relationship with Kubernetes + +Just as `podman` does not depend on a Kubernetes API server, `bootc` will also not depend on one. + +However, there are also plans for `bootc` to also understand Kubernetes API types. See [configmap/secret support](https://github.com/bootc-dev/bootc/issues/22) for example. + +Perhaps in the future we may actually support some kind of `Pod` analogue for representing the host state. Or we may define a [CRD](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/) which can be used inside and outside of Kubernetes. + +## Relationship with ostree + +OSTree provides many things: +1. a git-like repo for OS data from which you can check out an entire rootfs +2. a bootloader integration layer +3. a transport layer for pulling content over HTTP + +With bootc, the OSTree transport layer is not used. Instead, content is pulled +as OCI containers using `skopeo` as mentioned above. However, this content +is then imported into the local OSTree repo to perform a deployment checkout. +The role of OSTree may further shrink in the future, especially as tighter +integration with podman and composefs occurs, but it will remain an important +part of the bootc stack (in particular the bootloader integration layer and +management of deployment roots). + +## Relationship with rpm-ostree + +As mentioned above, bootc uses OSTree as a backing model, and so does +rpm-ostree. Hence, when using a container source, `rpm-ostree upgrade` and +`bootc upgrade` are effectively equivalent; you can use either command. + +### Differences from rpm-ostree + +- The ostree project never tried to have an opinionated "install" mechanism, + but bootc does with `bootc install to-filesystem` +- Bootc has additional features such as `/usr/lib/bootc/kargs.d` and + [logically bound images](logically-bound-images.md). + +### Client side changes + +Currently all functionality for client-side changes +such as `rpm-ostree install` or `rpm-ostree initramfs --enable` +continue to work, because of the shared base. + +However, as soon as you mutate the system in this way, `bootc upgrade` +will error out as it will not understand how to upgrade +the system. The bootc project currently takes a relatively +hard stance that system state should come from a container image. + +The way kernel argument work also uses ostree on the backend +in both cases, so using e.g. `rpm-ostree kargs` will also work +on a system updating via bootc. + +Overall, rpm-ostree is used in several important projects +and will continue to be maintained for many years to come. + +However, for use cases which want a "pure" image based model, +using `bootc` will be more appealing. bootc also does not +e.g. drag in dependencies on `libdnf` and the RPM stack. + +bootc also has the benefit of starting as a pure Rust project; +and while it [doesn't have an IPC mechanism today](https://github.com/bootc-dev/bootc/issues/4), the surface +of such an API will be significantly smaller. + +Further, bootc does aim to [include some of the functionality of zincati](https://github.com/bootc-dev/bootc/issues/5). + +But all this said: *It will be supported to use both bootc and rpm-ostree together*; they are not exclusive. +For example, `bootc status` at least will still function even if packages are layered. + +### Future bootc <-> podman binding + +All the above said, it is likely that at some point bootc will switch to [hard binding with podman](https://github.com/bootc-dev/bootc/pull/215). +This will reduce the role of ostree, and hence break compatibility with rpm-ostree. +When such work lands, we will still support at least a "one way" transition from an +ostree backend. But once this happens there are no plans to teach rpm-ostree +to use podman too. + +## Relationship with Fedora CoreOS (and Silverblue, etc.) + +Per above, it is a toplevel goal to support a seamless, transactional update from existing OSTree based systems, which includes these Fedora derivatives. + +For Fedora CoreOS specifically, see [this tracker issue](https://github.com/coreos/fedora-coreos-tracker/issues/1446). + +See also [OstreeNativeContainerStable](https://fedoraproject.org/wiki/Changes/OstreeNativeContainerStable). diff --git a/docs/src/upgrades.md b/docs/src/upgrades.md new file mode 100644 index 000000000..efe10eea1 --- /dev/null +++ b/docs/src/upgrades.md @@ -0,0 +1,51 @@ +# Managing upgrades + +Right now, bootc is a quite simple tool that is designed to do just +a few things well. One of those is transactionally fetching new operating system +updates from a registry and booting into them, while supporting rollback. + +## The `bootc upgrade` verb + +This will query the registry and queue an updated container image for the next boot. + +This is backed today by ostree, implementing an A/B style upgrade system. +Changes to the base image are staged, and the running system is not +changed by default. + +Use `bootc upgrade --apply` to auto-apply if there are queued changes. + +There is also an opinionated `bootc-fetch-apply-updates.timer` and corresponding +service available in upstream for operating systems and distributions +to enable. + +Man page: [bootc-upgrade](man/bootc-upgrade.8.md). + +## Changing the container image source + +Another useful pattern to implement can be to use a management agent +to invoke `bootc switch` (or declaratively via `bootc edit`) +to implement e.g. blue/green deployments, +where some hosts are rolled onto a new image independently of others. + +```shell +bootc switch quay.io/examplecorp/os-prod-blue:latest +``` + +`bootc switch` has the same effect as `bootc upgrade`; there is no +semantic difference between the two other than changing the +container image being tracked. + +This will preserve existing state in `/etc` and `/var` - for example, +host SSH keys and home directories. + +Man page: [bootc-switch](man/bootc-switch.8.md). + +## Rollback + +There is a `bootc rollback` verb, and associated declarative interface +accessible to tools via `bootc edit`. This will swap the bootloader +ordering to the previous boot entry. + +Man page: [bootc-rollback](man/bootc-rollback.8.md). + + diff --git a/hack/Containerfile b/hack/Containerfile new file mode 100644 index 000000000..ea24df36f --- /dev/null +++ b/hack/Containerfile @@ -0,0 +1,36 @@ +# Build a container image that has extra testing stuff in it, such +# as nushell, some preset logically bound images, etc. This expects +# to create an image derived FROM localhost/bootc which was created +# by the Dockerfile at top. + +FROM scratch as context +# We only need this stuff in the initial context +COPY . / + +# An intermediate layer which caches the extended RPMS +FROM localhost/bootc as extended +# And this layer has additional stuff for testing, such as nushell etc. +RUN --mount=type=bind,from=context,target=/run/context < /home/test/.ssh/authorized_keys && \ + chmod 0600 /home/test/.ssh/authorized_keys && \ + chown -R test: /home/test diff --git a/hack/lldb/dap-example-vim.lua b/hack/lldb/dap-example-vim.lua new file mode 100644 index 000000000..6bd95e833 --- /dev/null +++ b/hack/lldb/dap-example-vim.lua @@ -0,0 +1,122 @@ +-- This is an example of how to configure the DAP connection in an editor (neovim in this case) +-- It should be relatively straightforward to adapt to a different editor + +local dap = require("dap") +local job = require("plenary.job") + +-- This is a coroutine that runs the cargo build command and reports progress +local program = function() + return coroutine.create(function(dap_run_co) + local progress = require("fidget.progress") + + local cargo_build_fidget = progress.handle.create({ + title = "cargo build", + lsp_client = { name = "Debugger" }, + percentage = 0, + }) + + local cargo_build_job = job:new({ + command = "cargo", + args = { "build", "--color=never", "--profile=dev" }, + cwd = vim.fn.getcwd(), + enable_handlers = true, + on_stderr = vim.schedule_wrap(function(_, output) + cargo_build_fidget:report({ + message = output, + percentage = cargo_build_fidget.percentage + 0.3, + }) + end), + on_exit = function(_, return_val) + vim.schedule(function() + if return_val ~= 0 then + cargo_build_fidget:report({ + message = "Error during cargo build", + percentage = 100, + }) + else + cargo_build_fidget:finish() + coroutine.resume(dap_run_co, vim.fn.getcwd() .. "/target/debug/bootc") + end + end) + end, + }) + + cargo_build_job:start() + end) +end + +dap.adapters = { + lldb = { + executable = { + args = { + "--liblldb", + "~/.local/share/nvim/mason/packages/codelldb/extension/lldb/lib/liblldb.so", + "--port", + "${port}", + }, + command = "~/.local/share/nvim/mason/packages/codelldb/extension/adapter/codelldb", + }, + host = "127.0.0.1", + port = "${port}", + type = "server", + }, +} + +-- rust config that runs cargo build before opening dap ui and starting Debugger +-- shows cargo build status as fidget progress +-- the newly built bootc binary is copied to the VM and run in the lldb-server +dap.configurations.rust = { + { + args = { "status" }, + cwd = "/", + name = "[remote] status", + program = program, + request = "launch", + console = "integratedTerminal", + stopOnEntry = false, + type = "lldb", + initCommands = { + "platform select remote-linux", + "platform connect connect://bootc-lldb:1234", -- connect to the lldb-server running in the VM + "file target/debug/bootc", + }, + }, + { + args = { "upgrade" }, + cwd = "/", + name = "[remote] upgrade", + program = program, + request = "launch", + console = "integratedTerminal", + stopOnEntry = false, + type = "lldb", + initCommands = { + "platform select remote-linux", + "platform connect connect://bootc-lldb:1234", + "file target/debug/bootc", + }, + }, + + -- The install command can connect to a container instead of a VM. + -- The following command is an example of how to run a container and start a lldb-server: + -- sudo podman run --pid=host --network=host --privileged --security-opt label=type:unconfined_t -v /var/lib/containers:/var/lib/containers -v /dev:/dev -v .:/output localhost/bootc-lldb lldb-server platform --listen "*:1234" --server + { + args = { "install", "to-disk", "--generic-image", "--via-loopback", "--skip-fetch-check", "~/.cache/bootc-dev/disks/test.raw" }, + cwd = "/", + env = { + ["RUST_LOG"] = "debug", + ["BOOTC_DIRECT_IO"] = "on", + }, + name = "[remote] install to-disk", + program = program, + request = "launch", + console = "integratedTerminal", + stopOnEntry = false, + type = "lldb", + initCommands = { + "platform select remote-linux", + "platform connect connect://127.0.0.1:1234", -- connect to the lldb-server running in the container + "file target/debug/bootc", + }, + }, +} diff --git a/hack/lldb/deploy.sh b/hack/lldb/deploy.sh new file mode 100755 index 000000000..1cffeb0bf --- /dev/null +++ b/hack/lldb/deploy.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# connect to the VM using https://libvirt.org/nss.html + +set -e + +# build the container image +sudo podman build --build-arg "sshpubkey=$(cat ~/.ssh/id_rsa.pub)" -f Containerfile -t localhost/bootc-lldb . + +# build the disk image +mkdir -p ~/.cache/bootc-dev/disks +rm -f ~/.cache/bootc-dev/disks/lldb.raw +truncate -s 10G ~/.cache/bootc-dev/disks/lldb.raw +sudo podman run --pid=host --network=host --privileged --security-opt label=type:unconfined_t -v ~/.cache/bootc-dev/disks:/output localhost/bootc-lldb bootc install to-disk --via-loopback --generic-image /output/lldb.raw + +# create a new VM in libvirt +set +e +virsh -c qemu:///system destroy bootc-lldb +virsh -c qemu:///system undefine --nvram bootc-lldb +set -e +sudo virt-install --name bootc-lldb --cpu host --vcpus 8 --memory 8192 --import --disk ~/.cache/bootc-dev/disks/lldb.raw --os-variant rhel9-unknown diff --git a/hack/lldb/etc/sudoers.d/wheel-nopasswd b/hack/lldb/etc/sudoers.d/wheel-nopasswd new file mode 100644 index 000000000..bc5308c61 --- /dev/null +++ b/hack/lldb/etc/sudoers.d/wheel-nopasswd @@ -0,0 +1,2 @@ +# Enable passwordless sudo for the wheel group +%wheel ALL=(ALL) NOPASSWD: ALL diff --git a/hack/lldb/etc/sysctl.conf b/hack/lldb/etc/sysctl.conf new file mode 100644 index 000000000..5baef18b5 --- /dev/null +++ b/hack/lldb/etc/sysctl.conf @@ -0,0 +1,2 @@ +net.ipv6.conf.all.disable_ipv6 = 1 +net.ipv6.conf.default.disable_ipv6 = 1 diff --git a/hack/lldb/etc/systemd/system/lldb-server.service b/hack/lldb/etc/systemd/system/lldb-server.service new file mode 100644 index 000000000..fe10a90d8 --- /dev/null +++ b/hack/lldb/etc/systemd/system/lldb-server.service @@ -0,0 +1,13 @@ +[Unit] +Description=LLDB Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root +ExecStart=lldb-server platform --listen "*:1234" --server --min-gdbserver-port 31200 --max-gdbserver-port 31202 +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/hack/os-image-map.json b/hack/os-image-map.json new file mode 100644 index 000000000..c4ffef30c --- /dev/null +++ b/hack/os-image-map.json @@ -0,0 +1,9 @@ +{ + "rhel-9.8": "images.paas.redhat.com/bootc/rhel-bootc:latest-9.8", + "rhel-10.2": "images.paas.redhat.com/bootc/rhel-bootc:latest-10.2", + "centos-9": "quay.io/centos-bootc/centos-bootc:stream9", + "centos-10": "quay.io/centos-bootc/centos-bootc:stream10", + "fedora-42": "quay.io/fedora/fedora-bootc:42", + "fedora-43": "quay.io/fedora/fedora-bootc:43", + "fedora-44": "quay.io/fedora/fedora-bootc:rawhide" +} diff --git a/hack/packages.txt b/hack/packages.txt new file mode 100644 index 000000000..f158e2754 --- /dev/null +++ b/hack/packages.txt @@ -0,0 +1,5 @@ +# Needed by tmt +rsync +cloud-init +/usr/bin/flock +/usr/bin/awk diff --git a/hack/packit-reboot.yml b/hack/packit-reboot.yml new file mode 100644 index 000000000..431b83c79 --- /dev/null +++ b/hack/packit-reboot.yml @@ -0,0 +1,16 @@ +--- + +# tmt-reboot and reboot do not work in this case +# reboot in ansible is the only way to reboot in tmt prepare +- name: Reboot after system-reinstall-bootc + hosts: all + tasks: + - name: Reboot system to image mode + reboot: + reboot_timeout: 1200 + connect_timeout: 30 + pre_reboot_delay: 15 + + - name: Wait for connection to become reachable/usable + wait_for_connection: + delay: 30 diff --git a/hack/provision-derived.sh b/hack/provision-derived.sh new file mode 100755 index 000000000..3611dc977 --- /dev/null +++ b/hack/provision-derived.sh @@ -0,0 +1,117 @@ +#!/bin/bash +set -xeu +# I'm a big fan of nushell for interactive use, and I want to support +# using it in our test suite because it's better than bash. First, +# enable EPEL to get it. + +cloudinit=0 +case ${1:-} in + cloudinit) cloudinit=1 ;; + "") ;; + *) echo "Unhandled flag: ${1:-}" 1>&2; exit 1 ;; +esac + +# Ensure this is pre-created +mkdir -p -m 0700 /var/roothome +mkdir -p ~/.config/nushell +echo '$env.config = { show_banner: false, }' > ~/.config/nushell/config.nu +touch ~/.config/nushell/env.nu + +. /usr/lib/os-release +case "${ID}-${VERSION_ID}" in + "centos-9") + dnf config-manager --set-enabled crb + dnf -y install epel-release epel-next-release + dnf -y install nu + ;; + "rhel-9."*) + dnf -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm + dnf -y install nu + ;; + "centos-10"|"rhel-10."*) + # nu is not available in CS10 + td=$(mktemp -d) + cd $td + curl -kL "https://github.com/nushell/nushell/releases/download/0.103.0/nu-0.103.0-$(uname -m)-unknown-linux-gnu.tar.gz" --output nu.tar.gz + mkdir -p nu && tar zvxf nu.tar.gz --strip-components=1 -C nu + mv nu/nu /usr/bin/nu + rm -rf nu nu.tar.gz + cd - + rm -rf "${td}" + ;; + "fedora-"*) + dnf -y install nu + ;; +esac + +# Extra packages we install +grep -Ev -e '^#' packages.txt | xargs dnf -y install + +# Cloud bits +cat <> /usr/lib/bootc/kargs.d/20-console.toml +kargs = ["console=ttyS0,115200n8"] +KARGEOF +if test $cloudinit = 1; then + dnf -y install cloud-init + ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants + # Allow root SSH login for testing with bcvk/tmt +mkdir -p /etc/cloud/cloud.cfg.d +cat > /etc/cloud/cloud.cfg.d/80-enable-root.cfg <<'CLOUDEOF' +# Enable root login for testing +disable_root: false +CLOUDEOF +fi + +dnf clean all +# Stock extra cleaning of logs and caches in general (mostly dnf) +rm /var/log/* /var/cache /var/lib/{dnf,rpm-state,rhsm} -rf +# And clean root's homedir +rm /var/roothome/.config -rf +cat >/usr/lib/tmpfiles.d/bootc-cloud-init.conf <<'EOF' +d /var/lib/cloud 0755 root root - - +EOF + +# Fast track tmpfiles.d content from the base image, xref +# https://gitlab.com/fedora/bootc/base-images/-/merge_requests/92 +if test '!' -f /usr/lib/tmpfiles.d/bootc-base-rpmstate.conf; then + cat >/usr/lib/tmpfiles.d/bootc-base-rpmstate.conf <<'EOF' +# Workaround for https://bugzilla.redhat.com/show_bug.cgi?id=771713 +d /var/lib/rpm-state 0755 - - - +EOF +fi +if ! grep -q -r var/roothome/buildinfo /usr/lib/tmpfiles.d; then + cat > /usr/lib/tmpfiles.d/bootc-contentsets.conf <<'EOF' +# Workaround for https://github.com/konflux-ci/build-tasks-dockerfiles/pull/243 +d /var/roothome/buildinfo 0755 - - - +d /var/roothome/buildinfo/content_manifests 0755 - - - +# Note we don't actually try to recreate the content; this just makes the linter ignore it +f /var/roothome/buildinfo/content_manifests/content-sets.json 0644 - - - +EOF +fi + +# And add missing sysusers.d entries +if ! grep -q -r sudo /usr/lib/sysusers.d; then + cat >/usr/lib/sysusers.d/bootc-sudo-workaround.conf <<'EOF' +g sudo 16 +EOF +fi + +# dhcpcd +if rpm -q dhcpcd &>/dev/null; then +if ! grep -q -r dhcpcd /usr/lib/sysusers.d; then + cat >/usr/lib/sysusers.d/bootc-dhcpcd-workaround.conf <<'EOF' +u dhcpcd - 'Minimalistic DHCP client' /var/lib/dhcpcd +EOF +fi +cat >/usr/lib/tmpfiles.d/bootc-dhcpd.conf <<'EOF' +d /var/lib/dhcpcd 0755 root dhcpcd - - +EOF + rm -rf /var/lib/dhcpcd +fi +# dhclient +if test -d /var/lib/dhclient; then + cat >/usr/lib/tmpfiles.d/bootc-dhclient.conf <<'EOF' +d /var/lib/dhclient 0755 root root - - +EOF + rm -rf /var/lib/dhclient +fi diff --git a/hack/provision-packit.sh b/hack/provision-packit.sh new file mode 100755 index 000000000..9cf2eacc7 --- /dev/null +++ b/hack/provision-packit.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -exuo pipefail + +# Check environment +printenv + +# temp folder to save building files and folders +BOOTC_TEMPDIR=$(mktemp -d) +trap 'rm -rf -- "$BOOTC_TEMPDIR"' EXIT + +# Copy files and folders in hack to TEMPDIR +cp -a . "$BOOTC_TEMPDIR" + +# Keep testing farm run folder +cp -r /var/ARTIFACTS "$BOOTC_TEMPDIR" + +# Copy bootc repo +cp -r /var/share/test-artifacts "$BOOTC_TEMPDIR" + +ARCH=$(uname -m) +# Get OS info +source /etc/os-release + +# Some rhts-*, rstrnt-* and tmt-* commands are in /usr/local/bin +if [[ -d /var/lib/tmt/scripts ]]; then + cp -r /var/lib/tmt/scripts "$BOOTC_TEMPDIR" + ls -al "${BOOTC_TEMPDIR}/scripts" +else + cp -r /usr/local/bin "$BOOTC_TEMPDIR" + ls -al "${BOOTC_TEMPDIR}/bin" +fi + +# Get base image URL +TEST_OS="${ID}-${VERSION_ID}" +BASE=$(jq -r --arg v "$TEST_OS" '.[$v]' < os-image-map.json) + +if [[ "$ID" == "rhel" ]]; then + # OSCI gating only + CURRENT_COMPOSE_ID=$(skopeo inspect --no-tags --retry-times=5 --tls-verify=false "docker://${BASE}" | jq -r '.Labels."redhat.compose-id"') + + if [[ -n ${CURRENT_COMPOSE_ID} ]]; then + if [[ ${CURRENT_COMPOSE_ID} == *-updates-* ]]; then + BATCH_COMPOSE="updates/" + else + BATCH_COMPOSE="" + fi + else + BATCH_COMPOSE="updates/" + CURRENT_COMPOSE_ID=latest-RHEL-$VERSION_ID + fi + + # use latest compose if specific compose is not accessible + RC=$(curl -skIw '%{http_code}' -o /dev/null "http://${NIGHTLY_COMPOSE_SITE}/rhel-${VERSION_ID%%.*}/nightly/${BATCH_COMPOSE}RHEL-${VERSION_ID%%.*}/${CURRENT_COMPOSE_ID}/STATUS") + if [[ $RC != "200" ]]; then + CURRENT_COMPOSE_ID=latest-RHEL-${VERSION_ID%%} + fi + + # generate rhel repo + tee "${BOOTC_TEMPDIR}/rhel.repo" >/dev/null < to continue" { + send "\r" + exp_continue + } + "Operation complete, rebooting in 10 seconds. Press Ctrl-C to cancel reboot, or press enter to continue immediately" { + send "\x03" + } +} + +# Wait for the program to complete +expect eof diff --git a/hack/test-kargs/10-test.toml b/hack/test-kargs/10-test.toml new file mode 100644 index 000000000..a03b69779 --- /dev/null +++ b/hack/test-kargs/10-test.toml @@ -0,0 +1 @@ +kargs = ["kargsd-test=1", "kargsd-othertest=2"] diff --git a/hack/test-kargs/20-test2.toml b/hack/test-kargs/20-test2.toml new file mode 100644 index 000000000..ccf4f13ce --- /dev/null +++ b/hack/test-kargs/20-test2.toml @@ -0,0 +1 @@ +kargs = ["testing-kargsd=3"] diff --git a/labels.toml b/labels.toml deleted file mode 100644 index 82a23ebae..000000000 --- a/labels.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Standard labels for all bootc-dev repositories - -labels = [ - { name = "needs-rebase", color = "fbca04", description = "Used by the rebase helper" }, - { name = "triaged", color = "1d76db", description = "This issue appears to be valid" }, -] diff --git a/renovate-config.js b/renovate-config.js deleted file mode 100644 index 2bbf12c0f..000000000 --- a/renovate-config.js +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - // Find all repositories the GitHub App token has permissions to - autodiscover: true, - - // Don't create the onboarding PRs - // - // All repositories in the organisation will inherit the shared configuration - // (./renovate-shared-config.json) by default unless they opt-out. - onboarding: false, - - // Centralise all Renovate configuration into this repository - // - // This allows for easier management of Renovate settings across multiple - // repositories and organisations. Each individual repository can still - // contain their own configuration. - // - // Note: this uses an explicit repo name rather than {{parentOrg}}/infra - // so that repos in other orgs (e.g. composefs) also inherit from here. - inheritConfig: true, - inheritConfigRepoName: 'bootc-dev/infra', - inheritConfigFileName: "renovate-shared-config.json", - inheritConfigStrict: true, - - // Prefix all branches created by Renovate with "bootc-renovate/" - branchPrefix: 'bootc-renovate/', - - // Configure Renovate to use GitHub-specific API calls - platform: 'github', - - // Enable dependency updates on forked repositories in the organisation - forkProcessing: 'enabled', -}; diff --git a/renovate-shared-config.json b/renovate-shared-config.json deleted file mode 100644 index 4af220768..000000000 --- a/renovate-shared-config.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - // Base configuration - "config:recommended", - // Add "Signed-off-by" footer to commit messages - ":gitSignOff", - // Catch-all for grouping dependencies not caught by other groups - "group:all", - ":preserveSemverRanges" - ], - // Clone git submodules when analyzing repositories - // - // Some repositories use git submodules for vendored dependencies that are - // referenced in Cargo.toml files. Without initializing submodules, Renovate - // will fail to analyze these dependencies. - "cloneSubmodules": true, - // Custom datasource for tracking Rust nightly toolchain releases via the - // official release manifest. This is the same data source the upcoming - // native rust-version datasource (renovatebot/renovate#39529) will use; - // once that merges this can be replaced with zero-config native support. - "customDatasources": { - "rust-nightly": { - "defaultRegistryUrlTemplate": "https://static.rust-lang.org/manifests.txt", - "format": "plain", - "transformTemplates": [ - "{ \"releases\": $filter(releases, function($r) { $contains($r.version, \"channel-rust-nightly.toml\") }).$merge([{ \"version\": \"nightly-\" & $match(version, /(\\d{4}-\\d{2}-\\d{2})/).groups[0] }]) }" - ] - } - }, - // Custom managers for detecting dependencies in non-standard files - // - // - Containerfile/Dockerfile: Match "# renovate:" comments with ARG statements - // - .txt files: Match "# renovate:" comments followed by package@version on next line - "customManagers": [ - { - "customType": "regex", - "managerFilePatterns": ["/(^|/)Containerfile(\\.[^/]*)?$/", "/(^|/)Dockerfile(\\.[^/]*)?$/"], - "matchStrings": [ - "# renovate: datasource=(?[a-z.-]+) depName=(?[^\\s]+)\\s+ARG \\w+version=(?.+)", - "# renovate: datasource=(?[a-z.-]+) depName=(?[^\\s]+) versioning=(?[^\\s]+)\\s+ARG \\w+=(?.+)" - ] - }, - { - "customType": "regex", - "managerFilePatterns": ["**/*.txt"], - "matchStrings": [ - "# renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)\\n.*@(?\\S+)" - ] - }, - // Shell scripts in GHA workflows/actions: Match "# renovate:" followed by - // VERSION= or "export FOO_VERSION=" patterns - { - "customType": "regex", - "managerFilePatterns": ["**/*.yml", "**/*.yaml"], - "matchStrings": [ - "# renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)\\n\\s*(?:export )?\\w*VERSION=(?v?\\S+)" - ] - }, - // Container image digest pinning in YAML workflows - // Matches patterns like: - // # renovate: datasource=docker depName=quay.io/fedora/fedora-bootc - // source: quay.io/fedora/fedora-bootc:43@sha256:abc123... - // Also supports optional versioning= and quoted values. - { - "customType": "regex", - "managerFilePatterns": ["**/*.yml", "**/*.yaml"], - "matchStrings": [ - "# renovate: datasource=(?docker) depName=(?[^\\s]+)(?: versioning=(?[^\\s]+))?\\n\\s*[\\w-]+:\\s*[\"']?\\S+:(?[^@\\s\"']+)@(?sha256:[a-f0-9]+)[\"']?" - ] - }, - // Container image digest pinning in JSON files (e.g. base-images.json) - // Matches patterns like: - // "depName": "quay.io/fedora/fedora-bootc", - // "source": "quay.io/fedora/fedora-bootc:43@sha256:abc123..." - { - "customType": "regex", - "managerFilePatterns": ["**/base-images.json"], - "matchStrings": [ - "\"depName\":\\s*\"(?[^\"]+)\",\\s*\"source\":\\s*\"\\S+:(?[^@\"]+)@(?sha256:[a-f0-9]+)\"" - ], - "datasourceTemplate": "docker" - }, - // Git refs (commit SHA) tracking in Justfiles and YAML workflows - // Justfile example: - // # renovate: datasource=git-refs depName=https://github.com/org/repo branch=main - // export VAR := env("VAR", "0000000000000000000000000000000000000000") - // YAML example: - // # renovate: datasource=git-refs depName=https://github.com/org/repo branch=main - // VAR: '0000000000000000000000000000000000000000' - { - "customType": "regex", - "managerFilePatterns": ["**/Justfile", "**/*.just", "**/*.yml", "**/*.yaml"], - "matchStrings": [ - "# renovate: datasource=(?git-refs) depName=(?[^\\s]+) branch=(?[^\\s]+)\\n[^\\n]*\"(?[a-f0-9]{40})\"", - "# renovate: datasource=(?git-refs) depName=(?[^\\s]+) branch=(?[^\\s]+)\\n[^\\n]*'(?[a-f0-9]{40})'" - ] - } - ], - "packageRules": [ - // Limit dependency updates to once per week to reduce PR churn - // - // Rapidly-updating dependencies (e.g., opencode-ai) can create excessive PRs. - // By scheduling updates to Sundays only, we get one PR per week with the - // latest version, skipping all intermediate releases. - // - // Exception: bcvk updates immediately (excluded via negated regex). - { - "description": ["Limit dependency updates to weekly (Sundays UTC) to reduce PR churn. bcvk is excluded and updates immediately."], - "matchPackageNames": ["!bootc-dev/bcvk"], - "schedule": ["on sunday"], - "timezone": "UTC" - }, - { - // These files in these repos are synced from the bootc-dev/infra repository, which - // sends PRs to update them. Ignoring them here to avoid conflicting Renovate updates. - "matchRepositories": [ - "bootc-dev/bootc", - "bootc-dev/bcvk", - "bootc-dev/ci-sandbox", - "bootc-dev/containers-image-proxy-rs", - "bootc-dev/bootc-dev.github.io", - "composefs/composefs", - "composefs/composefs-rs" - ], - "ignorePaths": [ - ".github/workflows/rebase.yml", - ".github/workflows/openssf-scorecard.yml", - ".github/actions/bootc-ubuntu-setup/action.yml", - ".github/actions/setup-rust/action.yml" - ] - }, - // Group GitHub Actions dependencies - { - "description": ["GitHub Actions dependencies"], - "matchManagers": [ - "github-actions" - ], - "groupName": "GitHub Actions", - "enabled": true - }, - // Group Rust dependencies - { - "description": ["Rust dependencies"], - "matchManagers": [ - "cargo" - ], - "groupName": "Rust", - "enabled": true - }, - // Group Docker dependencies - { - "description": ["Docker dependencies"], - "matchManagers": [ - "dockerfile", - "custom.regex" - ], - "groupName": "Docker", - "enabled": true - }, - // Group staged bootc base image digest updates separately - // - // These are the upstream source images for chunkah-staged builds. - // Digest updates trigger a rebuild of the staged images, so they - // get their own PR. Must come after the Docker group rule so it - // takes precedence (Renovate applies all matching rules in order, - // later rules win). - { - "description": ["Staged bootc base image digest updates"], - "matchManagers": ["custom.regex"], - "matchDepNames": [ - "quay.io/fedora/fedora-bootc", - "quay.io/centos-bootc/centos-bootc" - ], - "groupName": "staged-images" - }, - // bcvk gets its own group so it isn't blocked by the weekly schedule - // applied to other Docker group members. Without this, the Docker group - // PR can only be created on Sundays (when all deps are in-schedule), - // even though bcvk itself is excluded from the weekly schedule above. - { - "description": ["bcvk updates independently of the Docker group"], - "matchPackageNames": ["bootc-dev/bcvk"], - "groupName": "bcvk" - }, - // Group npm dependencies - { - "description": ["npm dependencies"], - "matchDatasources": ["npm"], - "groupName": "npm", - "enabled": true - }, - // Disable Containerfile digest pinning - { - "description": ["Containerfile digest pinning"], - "matchManagers": [ - "dockerfile" - ], - "pinDigests": false - }, - // Disable Fedora OCI updates - // - // This is due to there not being an easy way to tell Renovate which - // Fedora version is "stable" and which has not been released yet. - { - "description": ["Disable Fedora OCI updates"], - "matchManagers": [ - "dockerfile", - "github-actions" - ], - "matchDepNames": [ - "quay.io/fedora/fedora", - "quay.io/fedora/fedora-bootc" - ], - "enabled": false - }, - // Ignore bootc cargo dependencies to fix failing Renovate task - // See: https://github.com/bootc-dev/infra/actions/runs/19914695687 - { - "matchManagers": ["cargo"], - "matchPackageNames": [ - "composefs", - "cfsctl", - "composefs-boot", - "composefs-oci" - ], - "enabled": false - }, - // Rust nightly toolchain: use rust-release-channel versioning for nightly-YYYY-MM-DD format - { - "matchDatasources": ["custom.rust-nightly"], - "versioning": "rust-release-channel" - } - ] -} diff --git a/renovate.json b/renovate.json index 3e4b91a3a..7190a60b6 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,3 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "local>bootc-dev/infra:renovate-shared-config.json" - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json" } diff --git a/scripts/sync-common/.gitignore b/scripts/sync-common/.gitignore deleted file mode 100644 index b83d22266..000000000 --- a/scripts/sync-common/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/scripts/sync-common/Cargo.lock b/scripts/sync-common/Cargo.lock deleted file mode 100644 index a43426828..000000000 --- a/scripts/sync-common/Cargo.lock +++ /dev/null @@ -1,153 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "sync-common" -version = "0.1.0" -dependencies = [ - "anyhow", - "tempfile", - "xshell", -] - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "xshell" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" -dependencies = [ - "xshell-macros", -] - -[[package]] -name = "xshell-macros" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" diff --git a/scripts/sync-common/Cargo.toml b/scripts/sync-common/Cargo.toml deleted file mode 100644 index 99b1c04e0..000000000 --- a/scripts/sync-common/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "sync-common" -version = "0.1.0" -edition = "2021" - -[dependencies] -anyhow = "1.0" -xshell = "0.2" - -[dev-dependencies] -tempfile = "3.0" diff --git a/scripts/sync-common/src/main.rs b/scripts/sync-common/src/main.rs deleted file mode 100644 index 0bf494500..000000000 --- a/scripts/sync-common/src/main.rs +++ /dev/null @@ -1,448 +0,0 @@ -use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; -use xshell::{cmd, Shell}; - -const COMMIT_MARKER: &str = ".bootc-dev-infra-commit.txt"; - -/// Git operations for querying repository history -struct GitOps; - -impl GitOps { - /// Get list of files deleted between two commits with given prefix - fn get_deleted_files( - sh: &Shell, - repo_path: &Path, - old_commit: &str, - new_commit: &str, - prefix: &str, - ) -> Result> { - let _dir = sh.push_dir(repo_path); - let output = cmd!(sh, "git diff --name-only --diff-filter=D {old_commit} {new_commit} -- {prefix}") - .read() - .context("Failed to run git diff")?; - - let files = output - .lines() - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - Ok(files) - } - - /// Check if there are any changes between commits for given prefix - fn has_changes( - sh: &Shell, - repo_path: &Path, - old_commit: &str, - new_commit: &str, - prefix: &str, - ) -> Result { - let _dir = sh.push_dir(repo_path); - // git diff --quiet returns exit code 1 if there are differences - let result = cmd!(sh, "git diff --quiet {old_commit} {new_commit} -- {prefix}").run(); - - match result { - Ok(_) => Ok(false), // No changes - Err(_) => Ok(true), // Has changes (exit code 1) - } - } -} - -/// File operations for syncing -struct FileOps; - -impl FileOps { - /// Read the last synced commit from target repository - fn read_commit_marker(target_path: &Path) -> Result> { - let marker_path = target_path.join(COMMIT_MARKER); - if !marker_path.exists() { - return Ok(None); - } - - let content = std::fs::read_to_string(&marker_path) - .context("Failed to read commit marker")?; - Ok(Some(content.trim().to_string())) - } - - /// Write the current commit to target repository marker file - fn write_commit_marker(target_path: &Path, commit: &str) -> Result<()> { - let marker_path = target_path.join(COMMIT_MARKER); - std::fs::write(&marker_path, format!("{}\n", commit)) - .context("Failed to write commit marker")?; - Ok(()) - } - - /// Remove a file if it exists - fn remove_file(file_path: &Path) -> Result<()> { - if file_path.exists() && file_path.is_file() { - std::fs::remove_file(file_path) - .with_context(|| format!("Failed to remove file: {}", file_path.display()))?; - println!(" Removed: {}", file_path.display()); - } - Ok(()) - } - - /// Sync directory using rsync - fn sync_directory(sh: &Shell, source: &Path, target: &Path) -> Result<()> { - let source_str = format!("{}/", source.display()); - let target_str = target.display().to_string(); - - cmd!(sh, "rsync -av {source_str} {target_str}") - .run() - .context("Failed to sync directory with rsync")?; - - Ok(()) - } -} - -/// Main syncer that orchestrates the sync process -struct CommonFileSyncer; - -impl CommonFileSyncer { - /// Sync common files from infra to target repository - fn sync( - infra_path: &Path, - target_path: &Path, - current_commit: &str, - ) -> Result { - let common_path = infra_path.join("common"); - if !common_path.exists() { - anyhow::bail!("Common directory not found: {}", common_path.display()); - } - - let previous_commit = FileOps::read_commit_marker(target_path)?; - - match previous_commit { - Some(prev) => Self::sync_incremental( - infra_path, - target_path, - &common_path, - &prev, - current_commit, - ), - None => Self::sync_initial(target_path, &common_path, current_commit), - } - } - - /// Handle incremental sync when previous sync exists - fn sync_incremental( - infra_path: &Path, - target_path: &Path, - common_path: &Path, - previous_commit: &str, - current_commit: &str, - ) -> Result { - println!("Previous sync: {}", previous_commit); - println!("Current commit: {}", current_commit); - - let sh = Shell::new()?; - let has_changes = - GitOps::has_changes(&sh, infra_path, previous_commit, current_commit, "common/")?; - - if !has_changes { - println!("No changes in common/ directory, skipping"); - return Ok(false); - } - - println!("Syncing changes from common/ directory"); - - // Remove deleted files - let deleted_files = - GitOps::get_deleted_files(&sh, infra_path, previous_commit, current_commit, "common/")?; - - for file_path in deleted_files { - // Strip 'common/' prefix to get target path - if let Some(rel_path) = file_path.strip_prefix("common/") { - let target_file = target_path.join(rel_path); - FileOps::remove_file(&target_file)?; - } - } - - // Sync all current files - FileOps::sync_directory(&sh, common_path, target_path)?; - - // Update commit marker - FileOps::write_commit_marker(target_path, current_commit)?; - - Ok(true) - } - - /// Handle initial sync when no previous sync exists - fn sync_initial( - target_path: &Path, - common_path: &Path, - current_commit: &str, - ) -> Result { - println!("First sync - copying all files"); - - let sh = Shell::new()?; - FileOps::sync_directory(&sh, common_path, target_path)?; - FileOps::write_commit_marker(target_path, current_commit)?; - - Ok(true) - } -} - -fn main() -> Result<()> { - let args: Vec = std::env::args().collect(); - - if args.len() != 4 { - eprintln!("Usage: {} ", args[0]); - std::process::exit(1); - } - - let infra_path = PathBuf::from(&args[1]); - let target_path = PathBuf::from(&args[2]); - let current_commit = &args[3]; - - CommonFileSyncer::sync(&infra_path, &target_path, current_commit)?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_read_commit_marker_exists() { - let dir = TempDir::new().unwrap(); - let marker = dir.path().join(COMMIT_MARKER); - fs::write(&marker, "abc123\n").unwrap(); - - let result = FileOps::read_commit_marker(dir.path()).unwrap(); - assert_eq!(result, Some("abc123".to_string())); - } - - #[test] - fn test_read_commit_marker_not_exists() { - let dir = TempDir::new().unwrap(); - let result = FileOps::read_commit_marker(dir.path()).unwrap(); - assert_eq!(result, None); - } - - #[test] - fn test_write_commit_marker() { - let dir = TempDir::new().unwrap(); - FileOps::write_commit_marker(dir.path(), "def456").unwrap(); - - let marker = dir.path().join(COMMIT_MARKER); - let content = fs::read_to_string(&marker).unwrap(); - assert_eq!(content, "def456\n"); - } - - #[test] - fn test_remove_file_exists() { - let dir = TempDir::new().unwrap(); - let file = dir.path().join("test.txt"); - fs::write(&file, "content").unwrap(); - - FileOps::remove_file(&file).unwrap(); - assert!(!file.exists()); - } - - #[test] - fn test_remove_file_not_exists() { - let dir = TempDir::new().unwrap(); - let file = dir.path().join("nonexistent.txt"); - - // Should not error - FileOps::remove_file(&file).unwrap(); - } - - #[test] - fn test_sync_common_not_found() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let result = CommonFileSyncer::sync( - infra_dir.path(), - target_dir.path(), - "abc123", - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Common directory not found")); - } - - // Helper to initialize a git repo with common/ directory - fn setup_infra_repo(dir: &Path) -> String { - let sh = Shell::new().unwrap(); - let _d = sh.push_dir(dir); - - // Initialize repo - cmd!(sh, "git init").run().unwrap(); - cmd!(sh, "git config user.email test@example.com").run().unwrap(); - cmd!(sh, "git config user.name 'Test User'").run().unwrap(); - - // Create common directory with initial files - let common_dir = dir.join("common"); - fs::create_dir(&common_dir).unwrap(); - fs::write(common_dir.join("file1.txt"), "content1").unwrap(); - fs::write(common_dir.join("file2.txt"), "content2").unwrap(); - - // Commit - cmd!(sh, "git add .").run().unwrap(); - cmd!(sh, "git commit -m 'Initial commit'").run().unwrap(); - - // Get commit hash - cmd!(sh, "git rev-parse HEAD").read().unwrap().trim().to_string() - } - - #[test] - fn test_initial_sync_with_git() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let commit = setup_infra_repo(infra_dir.path()); - - // Perform initial sync - let result = CommonFileSyncer::sync( - infra_dir.path(), - target_dir.path(), - &commit, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); - - // Verify files were synced - assert!(target_dir.path().join("file1.txt").exists()); - assert!(target_dir.path().join("file2.txt").exists()); - assert_eq!( - fs::read_to_string(target_dir.path().join("file1.txt")).unwrap(), - "content1" - ); - - // Verify marker was created - let marker = fs::read_to_string(target_dir.path().join(COMMIT_MARKER)).unwrap(); - assert_eq!(marker.trim(), commit); - } - - #[test] - fn test_incremental_sync_with_new_file() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let initial_commit = setup_infra_repo(infra_dir.path()); - - // Initial sync - CommonFileSyncer::sync(infra_dir.path(), target_dir.path(), &initial_commit).unwrap(); - - // Add a new file to common/ - let sh = Shell::new().unwrap(); - let _d = sh.push_dir(infra_dir.path()); - - let common_dir = infra_dir.path().join("common"); - fs::write(common_dir.join("file3.txt"), "content3").unwrap(); - cmd!(sh, "git add .").run().unwrap(); - cmd!(sh, "git commit -m 'Add file3'").run().unwrap(); - - let new_commit = cmd!(sh, "git rev-parse HEAD").read().unwrap().trim().to_string(); - - // Perform incremental sync - let result = CommonFileSyncer::sync( - infra_dir.path(), - target_dir.path(), - &new_commit, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); - - // Verify new file exists - assert!(target_dir.path().join("file3.txt").exists()); - assert_eq!( - fs::read_to_string(target_dir.path().join("file3.txt")).unwrap(), - "content3" - ); - } - - #[test] - fn test_incremental_sync_with_deleted_file() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let initial_commit = setup_infra_repo(infra_dir.path()); - - // Initial sync - CommonFileSyncer::sync(infra_dir.path(), target_dir.path(), &initial_commit).unwrap(); - - // Verify file2.txt exists - assert!(target_dir.path().join("file2.txt").exists()); - - // Delete file2.txt from common/ - let sh = Shell::new().unwrap(); - let _d = sh.push_dir(infra_dir.path()); - - fs::remove_file(infra_dir.path().join("common/file2.txt")).unwrap(); - cmd!(sh, "git add .").run().unwrap(); - cmd!(sh, "git commit -m 'Delete file2'").run().unwrap(); - - let new_commit = cmd!(sh, "git rev-parse HEAD").read().unwrap().trim().to_string(); - - // Perform incremental sync - let result = CommonFileSyncer::sync( - infra_dir.path(), - target_dir.path(), - &new_commit, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); - - // Verify file2.txt was deleted - assert!(!target_dir.path().join("file2.txt").exists()); - // But file1.txt should still exist - assert!(target_dir.path().join("file1.txt").exists()); - } - - #[test] - fn test_sync_no_changes() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let commit = setup_infra_repo(infra_dir.path()); - - // Initial sync - CommonFileSyncer::sync(infra_dir.path(), target_dir.path(), &commit).unwrap(); - - // Sync again with same commit (no changes) - let result = CommonFileSyncer::sync( - infra_dir.path(), - target_dir.path(), - &commit, - ); - - assert!(result.is_ok()); - assert!(!result.unwrap()); // Should return false for no changes - } - - #[test] - fn test_sync_preserves_repo_specific_files() { - let infra_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); - - let initial_commit = setup_infra_repo(infra_dir.path()); - - // Create a repo-specific file in target before sync - fs::write(target_dir.path().join("repo-specific.txt"), "local content").unwrap(); - - // Initial sync - CommonFileSyncer::sync(infra_dir.path(), target_dir.path(), &initial_commit).unwrap(); - - // Verify repo-specific file still exists - assert!(target_dir.path().join("repo-specific.txt").exists()); - assert_eq!( - fs::read_to_string(target_dir.path().join("repo-specific.txt")).unwrap(), - "local content" - ); - - // Verify synced files exist - assert!(target_dir.path().join("file1.txt").exists()); - assert!(target_dir.path().join("file2.txt").exists()); - } -} diff --git a/staged-images/.gitignore b/staged-images/.gitignore deleted file mode 100644 index 465b9a511..000000000 --- a/staged-images/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -source-config.json -out.ociarchive diff --git a/staged-images/Containerfile.staged b/staged-images/Containerfile.staged deleted file mode 100644 index e8453cb6d..000000000 --- a/staged-images/Containerfile.staged +++ /dev/null @@ -1,34 +0,0 @@ -# Containerfile.staged — Rechunk a bootc base image with chunkah -# -# Takes an upstream bootc image (fedora-bootc or centos-bootc), strips -# the /sysroot (ostree data), and rechunks using chunkah for optimal -# layer reuse across updates. The result is a "staged" base image -# suitable for use as a FROM base in bootc development. -# -# Usage: -# podman pull quay.io/fedora/fedora-bootc:44 -# podman inspect quay.io/fedora/fedora-bootc:44 > source-config.json -# buildah build --skip-unused-stages=false \ -# --build-arg SOURCE_IMAGE=quay.io/fedora/fedora-bootc:44 \ -# -f Containerfile.staged . - -ARG SOURCE_IMAGE -# renovate: datasource=docker depName=quay.io/coreos/chunkah -ARG CHUNKAH=quay.io/coreos/chunkah:v0.3.0@sha256:2be271c3cf4cae3d381dd7d560a2badb9dc83b0da4bd5003fc6dda776710a27d -ARG MAX_LAYERS=128 - -FROM ${SOURCE_IMAGE} AS source - -FROM ${CHUNKAH} AS chunkah -ARG MAX_LAYERS -RUN --mount=type=bind,target=/run/src,rw \ - --mount=from=source,target=/chunkah,ro \ - chunkah build \ - --config /run/src/source-config.json \ - --prune /sysroot/ \ - --max-layers "${MAX_LAYERS}" \ - --label ostree.commit- \ - --label ostree.final-diffid- \ - > /run/src/out.ociarchive - -FROM oci-archive:out.ociarchive diff --git a/staged-images/Justfile b/staged-images/Justfile deleted file mode 100644 index 9e5baf115..000000000 --- a/staged-images/Justfile +++ /dev/null @@ -1,43 +0,0 @@ -# Build a chunkah-staged bootc base image locally. -# -# Usage: -# just build fedora-44 -# just build centos-stream10 -# just build fedora-44 arm64 - -# Default architecture to the host -arch := arch() - -build base target_arch=arch: - #!/bin/bash - set -euo pipefail - # Map just's arch() names to container arch names - case "{{target_arch}}" in - x86_64|amd64) container_arch=amd64 ;; - aarch64|arm64) container_arch=arm64 ;; - *) container_arch="{{target_arch}}" ;; - esac - entry=$(jq -r --arg k "{{base}}" '.[$k] // empty' base-images.json) - if [ -z "$entry" ]; then - echo "Unknown base image '{{base}}'. Available:" - jq -r 'keys[]' base-images.json - exit 1 - fi - source=$(echo "$entry" | jq -r '.source') - image=$(echo "$entry" | jq -r '.image') - tag=$(echo "$entry" | jq -r '.tag') - echo "Building ${image}:${tag} (${container_arch}) from ${source}" - podman pull --arch "${container_arch}" "${source}" - podman inspect "${source}" > source-config.json - buildah build --skip-unused-stages=false \ - --build-arg SOURCE_IMAGE="${source}" \ - --build-arg MAX_LAYERS=128 \ - -f Containerfile.staged \ - -t "localhost/${image}:${tag}" \ - . - echo "=== Layer count ===" - podman inspect "localhost/${image}:${tag}" | jq '.[0].RootFS.Layers | length' - -# List available base images -list: - @jq -r 'to_entries[] | "\(.key)\t\(.value.source)"' base-images.json | column -t -s $'\t' diff --git a/staged-images/base-images.json b/staged-images/base-images.json deleted file mode 100644 index 17aafb556..000000000 --- a/staged-images/base-images.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "fedora-43": { - "image": "fedora-bootc-staged", - "tag": "43", - "depName": "quay.io/fedora/fedora-bootc", - "source": "quay.io/fedora/fedora-bootc:43@sha256:60900cb506936af583704955830c72de1d7fc2622a92a9a3039442871a798260" - }, - "fedora-44": { - "image": "fedora-bootc-staged", - "tag": "44", - "depName": "quay.io/fedora/fedora-bootc", - "source": "quay.io/fedora/fedora-bootc:44@sha256:33811dcc3d56e69a810fab88e8af08a121db2897625eec859b004b869a2d3d4e" - }, - "centos-stream9": { - "image": "centos-bootc-staged", - "tag": "stream9", - "depName": "quay.io/centos-bootc/centos-bootc", - "source": "quay.io/centos-bootc/centos-bootc:stream9@sha256:70236424fb0ffb6a59e8e3d3cbc0df9f87bd3284ccbc70a93efd24da1e6544fb" - }, - "centos-stream10": { - "image": "centos-bootc-staged", - "tag": "stream10", - "depName": "quay.io/centos-bootc/centos-bootc", - "source": "quay.io/centos-bootc/centos-bootc:stream10@sha256:d46e5198288642f45ce559f39be4ac6b34d1afe03c1d7836591b8a57655010c2" - } -} diff --git a/systemd/bootc-destructive-cleanup.service b/systemd/bootc-destructive-cleanup.service new file mode 100644 index 000000000..ad66f9331 --- /dev/null +++ b/systemd/bootc-destructive-cleanup.service @@ -0,0 +1,10 @@ +[Unit] +Description=Cleanup previous the installation after an alongside installation +Documentation=man:bootc(8) + +[Service] +Type=oneshot +ExecStart=/usr/lib/bootc/fedora-bootc-destructive-cleanup +PrivateMounts=true + +# No [Install] section, this is enabled via generator diff --git a/systemd/bootc-fetch-apply-updates.service b/systemd/bootc-fetch-apply-updates.service new file mode 100644 index 000000000..ce627a590 --- /dev/null +++ b/systemd/bootc-fetch-apply-updates.service @@ -0,0 +1,8 @@ +[Unit] +Description=Apply bootc updates +Documentation=man:bootc(8) +ConditionPathExists=/run/ostree-booted + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootc upgrade --apply --quiet diff --git a/systemd/bootc-fetch-apply-updates.timer b/systemd/bootc-fetch-apply-updates.timer new file mode 100644 index 000000000..4ebb66362 --- /dev/null +++ b/systemd/bootc-fetch-apply-updates.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Apply bootc updates +Documentation=man:bootc(8) +ConditionPathExists=/run/ostree-booted + +[Timer] +OnBootSec=1h +# This time is relatively arbitrary and obviously expected to be overridden/changed +OnUnitInactiveSec=8h +# When deploying a large number of systems, it may be beneficial to increase this value to help with load on the registry. +RandomizedDelaySec=2h + +[Install] +WantedBy=timers.target diff --git a/systemd/bootc-finalize-staged.service b/systemd/bootc-finalize-staged.service new file mode 100644 index 000000000..60e3f0683 --- /dev/null +++ b/systemd/bootc-finalize-staged.service @@ -0,0 +1,46 @@ +# Copyright (C) 2018 Red Hat, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +# For some implementation discussion, see: +# https://lists.freedesktop.org/archives/systemd-devel/2018-March/040557.html +[Unit] +Description=Composefs Finalize Staged Deployment +Documentation=man:bootc(1) +DefaultDependencies=no + +RequiresMountsFor=/sysroot +After=local-fs.target +Before=basic.target final.target +# We want to make sure the transaction logs are persisted to disk: +# https://bugzilla.redhat.com/show_bug.cgi?id=1751272 +After=systemd-journal-flush.service +Conflicts=final.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStop=/usr/bin/bootc composefs-finalize-staged +# This is a quite long timeout intentionally; the failure mode +# here is that people don't get an upgrade. We need to handle +# cases with slow rotational media, etc. +TimeoutStopSec=5m +# Bootc should never touch /var at all...except, we need to remove +# the /var/.updated flag, so we can't just `InaccessiblePaths=/var` right now. +# For now, let's at least use ProtectHome just so we have some sandboxing +# of that. +ProtectHome=yes +# And we shouldn't affect the current deployment's /etc. +ReadOnlyPaths=/etc +# We write to /sysroot and /boot of course. diff --git a/systemd/bootc-publish-rhsm-facts.service b/systemd/bootc-publish-rhsm-facts.service new file mode 100644 index 000000000..4dd8550e1 --- /dev/null +++ b/systemd/bootc-publish-rhsm-facts.service @@ -0,0 +1,12 @@ +[Unit] +Description=Publish bootc facts to Red Hat Subscription Manager +Documentation=man:bootc(8) +ConditionPathExists=/etc/rhsm/facts + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootc internals publish-rhsm-facts + +[Install] +WantedBy=bootc-status-updated.target +WantedBy=bootc-status-updated-onboot.target diff --git a/systemd/bootc-status-updated-onboot.target b/systemd/bootc-status-updated-onboot.target new file mode 100644 index 000000000..2e7678ccd --- /dev/null +++ b/systemd/bootc-status-updated-onboot.target @@ -0,0 +1,6 @@ +[Unit] +Description=Bootc status trigger state sync +Documentation=man:bootc-status-updated.target(8) +After=sysinit.target + +# No [Install] section, this is enabled via generator diff --git a/systemd/bootc-status-updated.path b/systemd/bootc-status-updated.path new file mode 100644 index 000000000..d9d071915 --- /dev/null +++ b/systemd/bootc-status-updated.path @@ -0,0 +1,9 @@ +[Unit] +Description=Monitor bootc for status changes +Documentation=man:bootc-status-updated.path(8) + +[Path] +PathChanged=/ostree/bootc +Unit=bootc-status-updated.target + +# No [Install] section, this is enabled via generator diff --git a/systemd/bootc-status-updated.target b/systemd/bootc-status-updated.target new file mode 100644 index 000000000..afeb55686 --- /dev/null +++ b/systemd/bootc-status-updated.target @@ -0,0 +1,5 @@ +[Unit] +Description=Target for bootc status changes +Documentation=man:bootc-status-updated.target(8) +StopWhenUnneeded=true +ConditionPathExists=/run/ostree-booted diff --git a/tests/build-sealed b/tests/build-sealed new file mode 100755 index 000000000..64cbb7270 --- /dev/null +++ b/tests/build-sealed @@ -0,0 +1,63 @@ +#!/bin/bash +set -euo pipefail +# This should turn into https://github.com/bootc-dev/bootc/issues/1498 + +variant=$1 +shift +# The un-sealed container image we want to use +input_image=$1 +shift +# The output container image +output_image=$1 +shift +# Optional directory with secure boot keys; if none are provided, then we'll +# generate some under target/ +secureboot=${1:-} + +runv() { + set -x + "$@" +} + +case $variant in + ostree) + # Nothing to do + echo "Not building a sealed image; forwarding tag" + runv podman tag $input_image $output_image + exit 0 + ;; + composefs-sealeduki*) + ;; + *) + echo "Unknown variant=$variant" 1>&2; exit 1 + ;; +esac + + +graphroot=$(podman system info -f '{{.Store.GraphRoot}}') +echo "Computing composefs digest..." +cfs_digest=$(podman run --rm --privileged --read-only --security-opt=label=disable -v /sys:/sys:ro --net=none \ + -v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$input_image" bootc container compute-composefs-digest) + +if test -z "${secureboot}"; then + secureboot=$(pwd)/target/test-secureboot + mkdir -p ${secureboot} + cd $secureboot + if test '!' -f db.cer; then + echo "Generating test Secure Boot keys" + systemd-id128 new -u > GUID.txt + openssl req -quiet -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Platform Key/' -out PK.crt + openssl x509 -outform DER -in PK.crt -out PK.cer + openssl req -quiet -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Key Exchange Key/' -out KEK.crt + openssl x509 -outform DER -in KEK.crt -out KEK.cer + openssl req -quiet -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Signature Database key/' -out db.crt + openssl x509 -outform DER -in db.crt -out db.cer + else + echo "Reusing Secure Boot keys in ${secureboot}" + fi + cd - +fi + +runv podman build -t $output_image --build-arg=COMPOSEFS_FSVERITY=${cfs_digest} --build-arg=base=${input_image} \ + --secret=id=key,src=${secureboot}/db.key \ + --secret=id=cert,src=${secureboot}/db.crt -f Dockerfile.cfsuki . diff --git a/tests/container/reboot/bootc-finish-test-reboot.service b/tests/container/reboot/bootc-finish-test-reboot.service new file mode 100644 index 000000000..392a1f8da --- /dev/null +++ b/tests/container/reboot/bootc-finish-test-reboot.service @@ -0,0 +1,11 @@ +[Unit] +ConditionPathExists=!/etc/initrd-release +After=local-fs.target +RequiresMountsFor=/run/bootc-test-reboot +Before=bootc-test-reboot.service +PartOf=bootc-test-reboot.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStop=touch /run/bootc-test-reboot/success diff --git a/tests/container/reboot/bootc-test-reboot.service b/tests/container/reboot/bootc-test-reboot.service new file mode 100644 index 000000000..53c887d4a --- /dev/null +++ b/tests/container/reboot/bootc-test-reboot.service @@ -0,0 +1,12 @@ +[Unit] +ConditionPathExists=!/etc/initrd-release +Requires=bootc-finish-test-reboot.service +After=bootc-finish-test-reboot.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=bootc internals reboot + +[Install] +WantedBy=multi-user.target diff --git a/tests/container/reboot/run b/tests/container/reboot/run new file mode 100755 index 000000000..41c524601 --- /dev/null +++ b/tests/container/reboot/run @@ -0,0 +1,22 @@ +#!/bin/bash +# Verify that invoking `bootc internals reboot` actually invokes a reboot, when +# running inside systemd. +# xref: +# - https://github.com/bootc-dev/bootc/issues/1416 +# - https://github.com/bootc-dev/bootc/issues/1419 +set -euo pipefail +image=$1 +tmpd=$(mktemp -d) +log() { + echo "$@" + "$@" +} +log timeout 120 podman run --rm --systemd=always --privileged -v /sys:/sys:ro --label bootc.test=reboot --net=none -v $(pwd):/src:ro -v $tmpd:/run/bootc-test-reboot $image /bin/sh -c 'cp /src/*.service /etc/systemd/system && systemctl enable bootc-test-reboot && exec /sbin/init' || true +ls -al $tmpd +if test '!' -f $tmpd/success; then + echo "reboot failed" 1>&2 + rm -rf "$tmpd" + exit 1 +fi +rm -rf "$tmpd" +echo "ok reboot" diff --git a/tests/container/run b/tests/container/run new file mode 100755 index 000000000..3e5e9266a --- /dev/null +++ b/tests/container/run @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail +image=$1 +shift + +cd $(dirname $0) + +tests=$(find . -maxdepth 1 -type d) +for case in $tests; do + if test $case = .; then continue; fi + echo "Running: $case" + cd $case + ./run $image + cd - + echo "ok $case" +done diff --git a/tmt/README.md b/tmt/README.md new file mode 100644 index 000000000..15ace2433 --- /dev/null +++ b/tmt/README.md @@ -0,0 +1,11 @@ +# Run integration test locally + +In the bootc CI, integration tests are executed via Packit on the Testing Farm. In addition, the integration tests can also be run locally on a developer's machine, which is especially valuable for debugging purposes. + +To run integration tests locally, you need to [install tmt](https://tmt.readthedocs.io/en/stable/guide.html#the-first-steps) and `provision-virtual` plugin in this case. Be ready with `dnf install -y tmt+provision-virtual`. Then, use `tmt run -vvvvv plans -n integration` command to run the all integration tests. + +To run integration tests on different distros, just change `image: fedora-rawhide` in https://github.com/bootc-dev/bootc/blob/9d15eedea0d54a4dbc15d267dbdb055817336254/tmt/plans/integration.fmf#L6. + +The available images value can be found from https://tmt.readthedocs.io/en/stable/plugins/provision.html#images. + +Enjoy integration test local running! diff --git a/tmt/bug-soft-reboot.md b/tmt/bug-soft-reboot.md new file mode 100644 index 000000000..eaef1df30 --- /dev/null +++ b/tmt/bug-soft-reboot.md @@ -0,0 +1,35 @@ +# TMT soft-reboot limitation + +TMT does not currently support systemd soft-reboots. It detects reboots by checking +if the `/proc/stat` btime (boot time) field changes, which does not happen during +a systemd soft-reboot. + +See: + +Note: This same issue affects Testing Farm as documented in `plans/integration.fmf` +where `test-27-custom-selinux-policy` is disabled for Packit (AWS) testing. + +## Impact on bootc testing + +This means that when testing `bootc switch --soft-reboot=auto` or `bootc upgrade --soft-reboot=auto`: + +1. The bootc commands will correctly prepare for a soft-reboot (staging the deployment in `/run/nextroot`) +2. However, TMT cannot detect or properly handle the soft-reboot +3. Tests must explicitly reset the soft-reboot preparation before calling `tmt-reboot` + +## Workaround + +After calling bootc with `--soft-reboot=auto`, use: + +```nushell +ostree admin prepare-soft-reboot --reset +tmt-reboot +``` + +This forces a full reboot instead of a soft-reboot, which TMT can properly detect. + +## Testing environments + +- **testcloud**: Accidentally worked because libvirt forced a full VM power cycle, overriding systemd's soft-reboot attempt +- **bcvk**: Exposes the real issue because it allows actual systemd soft-reboots +- **Production (AWS, bare metal, etc.)**: Not affected - TMT is purely a testing framework; soft-reboots work correctly in production diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf new file mode 100644 index 000000000..5158cca36 --- /dev/null +++ b/tmt/plans/integration.fmf @@ -0,0 +1,131 @@ +# Common settings for all plans +provision: + how: virtual + image: $@{test_disk_image} +prepare: + # Install image mode system on package mode system + # Do not run on image mode VM running on Github CI and Locally + # Run on package mode VM running on Packit and Gating + # order 9x means run it at the last job of prepare + - how: install + order: 97 + package: + - podman + - skopeo + - jq + - bootc + - system-reinstall-bootc + - expect + - ansible-core + - zstd + when: running_env != image_mode + - how: shell + order: 98 + script: + - mkdir -p bootc && cp /var/share/test-artifacts/*.src.rpm bootc + - cd bootc && rpm2cpio *.src.rpm | cpio -idmv && rm -f *-vendor.tar.zstd && zstd -d *.tar.zstd && tar -xvf *.tar -C . --strip-components=1 && ls -al + - pwd && ls -al && cd bootc/hack && ./provision-packit.sh + when: running_env != image_mode + # tmt-reboot and reboot do not work in this case + # reboot in ansible is the only way to reboot in tmt prepare + - how: ansible + order: 99 + playbook: + - https://github.com/bootc-dev/bootc/raw/refs/heads/main/hack/packit-reboot.yml + when: running_env != image_mode +execute: + how: tmt + +# BEGIN GENERATED PLANS +/plan-01-readonly: + summary: Execute booted readonly/nondestructive tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-01-readonly + +/plan-20-image-pushpull-upgrade: + summary: Execute local upgrade tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-20-image-pushpull-upgrade + +/plan-21-logically-bound-switch: + summary: Execute logically bound images tests for switching images + discover: + how: fmf + test: + - /tmt/tests/tests/test-21-logically-bound-switch + +/plan-22-logically-bound-install: + summary: Execute logically bound images tests for installing image + discover: + how: fmf + test: + - /tmt/tests/tests/test-22-logically-bound-install + +/plan-23-install-outside-container: + summary: Execute tests for installing outside of a container + discover: + how: fmf + test: + - /tmt/tests/tests/test-23-install-outside-container + +/plan-23-usroverlay: + summary: Execute tests for bootc usrover + discover: + how: fmf + test: + - /tmt/tests/tests/test-23-usroverlay + +/plan-24-image-upgrade-reboot: + summary: Execute local upgrade tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-24-image-upgrade-reboot + +/plan-25-soft-reboot: + summary: Execute soft reboot test + discover: + how: fmf + test: + - /tmt/tests/tests/test-25-soft-reboot + +/plan-26-examples-build: + summary: Test bootc examples build scripts + discover: + how: fmf + test: + - /tmt/tests/tests/test-26-examples-build + adjust: + - when: running_env != image_mode + enabled: false + because: packit tests use RPM bootc and does not install /usr/lib/bootc/initramfs-setup + +/plan-27-custom-selinux-policy: + summary: Execute custom selinux policy test + discover: + how: fmf + test: + - /tmt/tests/tests/test-27-custom-selinux-policy + adjust: + - when: running_env != image_mode + enabled: false + because: these tests require features only available in image mode + +/plan-28-factory-reset: + summary: Execute factory reset tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-28-factory-reset + +/plan-29-soft-reboot-selinux-policy: + summary: Test soft reboot with SELinux policy changes + discover: + how: fmf + test: + - /tmt/tests/tests/test-29-soft-reboot-selinux-policy +# END GENERATED PLANS diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade new file mode 100644 index 000000000..ab3b73c7c --- /dev/null +++ b/tmt/tests/Dockerfile.upgrade @@ -0,0 +1,3 @@ +# Just creates a file as a new layer for a synthetic upgrade test +FROM localhost/bootc-integration +RUN touch --reference=/usr/bin/bash /usr/share/testing-bootc-upgrade-apply diff --git a/tmt/tests/booted/.gitignore b/tmt/tests/booted/.gitignore new file mode 100644 index 000000000..bee8a64b7 --- /dev/null +++ b/tmt/tests/booted/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/tmt/tests/booted/README.md b/tmt/tests/booted/README.md new file mode 100644 index 000000000..359bb3962 --- /dev/null +++ b/tmt/tests/booted/README.md @@ -0,0 +1,3 @@ +# Booted tests + +These are intended to run via tmt. diff --git a/tmt/tests/booted/bootc_testlib.nu b/tmt/tests/booted/bootc_testlib.nu new file mode 100644 index 000000000..5f15586ab --- /dev/null +++ b/tmt/tests/booted/bootc_testlib.nu @@ -0,0 +1,21 @@ +# A simple nushell "library" for the + +# This is a workaround for what must be a systemd bug +# that seems to have appeared in C10S +# TODO diagnose and fill in here +export def reboot [] { + # Sometimes systemd daemons are still running old binaries and response "Access denied" when send reboot request + # Force a full sync before reboot + sync + # Allow more delay for bootc to settle + sleep 30sec + + tmt-reboot +} + +# True if we're running in bcvk with `--bind-storage-ro` and +# we can expect to be able to pull container images from the host. +# See xtask.rs +export def have_hostexports [] { + $env.BCVK_EXPORT? == "1" +} diff --git a/tmt/tests/booted/readonly/001-test-status.nu b/tmt/tests/booted/readonly/001-test-status.nu new file mode 100644 index 000000000..80c29028a --- /dev/null +++ b/tmt/tests/booted/readonly/001-test-status.nu @@ -0,0 +1,46 @@ +use std assert +use tap.nu + +tap begin "verify bootc status output formats" + +# Verify /sysroot is not writable initially (read-only operations should not make it writable) +let is_writable = (do -i { /bin/test -w /sysroot } | complete | get exit_code) == 0 +assert (not $is_writable) "/sysroot should not be writable initially" + +# Double-check with findmnt +let mnt = (findmnt /sysroot -J | from json) +let opts = ($mnt.filesystems.0.options | split row ",") +assert ($opts | any { |o| $o == "ro" }) "/sysroot should be mounted read-only" + +let st = bootc status --json | from json +# Detect composefs by checking if composefs field is present +let is_composefs = ($st.status.booted.composefs? != null) + +assert equal $st.apiVersion org.containers.bootc/v1 + +let st = bootc status --json --format-version=0 | from json +assert equal $st.apiVersion org.containers.bootc/v1 + +let st = bootc status --format=yaml | from yaml +assert equal $st.apiVersion org.containers.bootc/v1 +if not $is_composefs { + assert ($st.status.booted.image.timestamp != null) +} # else { TODO composefs: timestamp is not populated with composefs } +let ostree = $st.status.booted.ostree +if $ostree != null { + assert ($ostree.stateroot != null) +} + +let st = bootc status --json --booted | from json +assert equal $st.apiVersion org.containers.bootc/v1 +if not $is_composefs { + assert ($st.status.booted.image.timestamp != null) +} # else { TODO composefs: timestamp is not populated with composefs } +assert (($st.status | get rollback | default null) == null) +assert (($st.status | get staged | default null) == null) + +# Verify /sysroot is still not writable after bootc status (regression test for PR #1718) +let is_writable = (do -i { /bin/test -w /sysroot } | complete | get exit_code) == 0 +assert (not $is_writable) "/sysroot should remain read-only after bootc status" + +tap ok diff --git a/tmt/tests/booted/readonly/002-test-cli.nu b/tmt/tests/booted/readonly/002-test-cli.nu new file mode 100644 index 000000000..3957533e1 --- /dev/null +++ b/tmt/tests/booted/readonly/002-test-cli.nu @@ -0,0 +1,11 @@ +use std assert +use tap.nu + +tap begin "verify bootc status output formats" + +assert equal (bootc switch blah:// e>| find "\u{1B}") [] + +# Verify soft-reboot is in help +bootc upgrade --help | grep -qF -e '--soft-reboot' + +tap ok diff --git a/tmt/tests/booted/readonly/010-test-bootc-container-store.nu b/tmt/tests/booted/readonly/010-test-bootc-container-store.nu new file mode 100644 index 000000000..a7ac5b6c0 --- /dev/null +++ b/tmt/tests/booted/readonly/010-test-bootc-container-store.nu @@ -0,0 +1,20 @@ +use std assert +use tap.nu + +tap begin "verify bootc-owned container storage" + +# Detect composefs by checking if composefs field is present +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) + +if $is_composefs { + print "# TODO composefs: skipping test - /usr/lib/bootc/storage doesn't exist with composefs" +} else { + # Just verifying that the additional store works + podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images + + # And verify this works + bootc image cmd list -q o>/dev/null +} + +tap ok diff --git a/tmt/tests/booted/readonly/011-hostname.nu b/tmt/tests/booted/readonly/011-hostname.nu new file mode 100644 index 000000000..5cf48b8b8 --- /dev/null +++ b/tmt/tests/booted/readonly/011-hostname.nu @@ -0,0 +1,11 @@ +use std assert +use tap.nu + +tap begin "verify /etc/hostname is not zero sized" + +let hostname = try { ls /etc/hostname | first } +if $hostname != null { + assert not equal $hostname.size 0B +} + +tap ok diff --git a/tmt/tests/booted/readonly/011-test-ostree-ext-cli.nu b/tmt/tests/booted/readonly/011-test-ostree-ext-cli.nu new file mode 100644 index 000000000..edac11cba --- /dev/null +++ b/tmt/tests/booted/readonly/011-test-ostree-ext-cli.nu @@ -0,0 +1,21 @@ +# Verify our wrapped "bootc internals ostree-container" calling into +# the legacy ostree-ext CLI. +use std assert +use tap.nu + +tap begin "verify bootc wrapping ostree-ext" + +# Parse the status and get the booted image +let st = bootc status --json | from json +# Detect composefs by checking if composefs field is present +let is_composefs = ($st.status.booted.composefs? != null) +if $is_composefs { + print "# TODO composefs: skipping test - ostree-container commands don't work with composefs" +} else { + let booted = $st.status.booted.image + # Then verify we can extract its metadata via the ostree-container code. + let metadata = bootc internals ostree-container image metadata --repo=/ostree/repo $"($booted.image.transport):($booted.image.image)" | from json + assert equal $metadata.mediaType "application/vnd.oci.image.manifest.v1+json" +} + +tap ok diff --git a/tmt/tests/booted/readonly/011-test-resolvconf.nu b/tmt/tests/booted/readonly/011-test-resolvconf.nu new file mode 100644 index 000000000..8f040d665 --- /dev/null +++ b/tmt/tests/booted/readonly/011-test-resolvconf.nu @@ -0,0 +1,29 @@ +use std assert +use tap.nu + +tap begin "verify there's not an empty /etc/resolv.conf in the image" + +let st = bootc status --json | from json + +# Detect composefs by checking if composefs field is present +let is_composefs = ($st.status.booted.composefs? != null) +if $is_composefs { + print "# TODO composefs: skipping test - ostree commands don't work with composefs" +} else { + let booted_ostree = $st.status.booted.ostree.checksum; + + # ostree ls should probably have --json and a clean way to not error on ENOENT + let resolvconf = ostree ls $booted_ostree /usr/etc | split row (char newline) | find resolv.conf + if ($resolvconf | length) > 0 { + let parts = $resolvconf | first | split row -r '\s+' + let ty = $parts | first | split chars | first + # If resolv.conf exists in the image, currently require it in our + # test suite to be a symlink (which is hopefully to the systemd/stub-resolv.conf) + assert equal $ty 'l' + print "resolv.conf is a symlink" + } else { + print "No resolv.conf found in commit" + } +} + +tap ok diff --git a/tmt/tests/booted/readonly/012-test-unit-status.nu b/tmt/tests/booted/readonly/012-test-unit-status.nu new file mode 100644 index 000000000..ebc5363e8 --- /dev/null +++ b/tmt/tests/booted/readonly/012-test-unit-status.nu @@ -0,0 +1,26 @@ +# Verify our systemd units are enabled +use std assert +use tap.nu + +tap begin "verify our systemd units" + +# Detect composefs by checking if composefs field is present +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) + +if $is_composefs { + print "# TODO composefs: skipping test - bootc-status-updated.path watches /ostree/bootc which doesn't exist with composefs" +} else { + let units = [ + ["unit", "status"]; + # This one should be always enabled by our install logic + ["bootc-status-updated.path", "active"] + ] + + for elt in $units { + let found_status = systemctl show -P ActiveState $elt.unit | str trim + assert equal $elt.status $found_status + } +} + +tap ok diff --git a/tmt/tests/booted/readonly/015-test-fsck.nu b/tmt/tests/booted/readonly/015-test-fsck.nu new file mode 100644 index 000000000..555842681 --- /dev/null +++ b/tmt/tests/booted/readonly/015-test-fsck.nu @@ -0,0 +1,17 @@ +use std assert +use tap.nu + +tap begin "Run fsck" + +# Detect composefs by checking if composefs field is present +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) + +if $is_composefs { + print "# TODO composefs: skipping test - fsck requires ostree-booted host" +} else { + # That's it, just ensure we've run a fsck on our basic install. + bootc internals fsck +} + +tap ok diff --git a/tmt/tests/booted/readonly/017-test-bound-storage.nu b/tmt/tests/booted/readonly/017-test-bound-storage.nu new file mode 100644 index 000000000..c87761853 --- /dev/null +++ b/tmt/tests/booted/readonly/017-test-bound-storage.nu @@ -0,0 +1,27 @@ +# Verify that we have host container storage with bcvk +use std assert +use tap.nu +use ../bootc_testlib.nu + +if not (bootc_testlib have_hostexports) { + print "No host exports, skipping" + exit 0 +} + +bootc status +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) +if $is_composefs { + # TODO we don't have imageDigest yet in status + exit 0 +} + +let booted = $st.status.booted +let imgref = $booted.image.image.image +let digest = $booted.image.imageDigest +let imgref_untagged = $imgref | split row ':' | first +let digested_imgref = $"($imgref_untagged)@($digest)" +systemd-run -dqP /bin/env +podman inspect $digested_imgref + +tap ok diff --git a/tmt/tests/booted/readonly/020-test-relabel.nu b/tmt/tests/booted/readonly/020-test-relabel.nu new file mode 100644 index 000000000..ce8456cd8 --- /dev/null +++ b/tmt/tests/booted/readonly/020-test-relabel.nu @@ -0,0 +1,26 @@ +use std assert +use tap.nu + +tap begin "Relabel" + +let td = mktemp -d -p /var/tmp +cd $td + +mkdir etc/ssh +touch etc/shadow etc/ssh/ssh_config +bootc internals relabel --as-path /etc $"(pwd)/etc" + +def assert_labels_equal [p] { + let base = (getfattr --only-values -n security.selinux $"/($p)") + let target = (getfattr --only-values -n security.selinux $p) + assert equal $base $target +} + +for path in ["etc", "etc/shadow", "etc/ssh/ssh_config"] { + assert_labels_equal $path +} + +cd / +rm -rf $td + +tap ok diff --git a/tmt/tests/booted/readonly/030-test-composefs.nu b/tmt/tests/booted/readonly/030-test-composefs.nu new file mode 100644 index 000000000..b7f028e44 --- /dev/null +++ b/tmt/tests/booted/readonly/030-test-composefs.nu @@ -0,0 +1,40 @@ +use std assert +use tap.nu + +tap begin "composefs integration smoke test" + +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +# Detect composefs by checking if composefs field is present +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) +let expecting_composefs = ($env.BOOTC_variant? | default "" | find "composefs") != null +if $expecting_composefs { + assert $is_composefs + # When using systemd-boot with DPS (Discoverable Partition Specification), + # /proc/cmdline should NOT contain a root= parameter because systemd-gpt-auto-generator + # discovers the root partition automatically + # Note that there is `bootctl --json=pretty` but it doesn't actually output JSON + let bootctl_output = (bootctl) + if ($bootctl_output | str contains 'Product: systemd-boot') { + let cmdline = parse_cmdline + let has_root_param = ($cmdline | any { |param| $param | str starts-with 'root=' }) + assert (not $has_root_param) "systemd-boot image should not have root= in kernel cmdline; systemd-gpt-auto-generator should discover the root partition via DPS" + } +} + +if $is_composefs { + # When already on composefs, we can only test read-only operations + print "# TODO composefs: skipping pull test - cfs oci pull requires write access to sysroot" + bootc internals cfs --help +} else { + # When not on composefs, run the full test including initialization + bootc internals test-composefs + bootc internals cfs --help + bootc internals cfs oci pull docker://busybox busybox + test -L /sysroot/composefs/streams/refs/busybox +} + +tap ok diff --git a/tmt/tests/booted/readonly/030-test-locking-read.nu b/tmt/tests/booted/readonly/030-test-locking-read.nu new file mode 100644 index 000000000..cb8be9fa0 --- /dev/null +++ b/tmt/tests/booted/readonly/030-test-locking-read.nu @@ -0,0 +1,32 @@ +# Verify we can spawn multiple bootc status at the same time +use std assert +use tap.nu + +tap begin "concurrent bootc status" + +# Fork via systemd-run +let n = 10 +0..$n | each { |v| + # Clean up prior runs + systemctl stop $"bootc-status-($v)" | complete +} +# Fork off a concurrent bootc status +0..$n | each { |v| + systemd-run --no-block -qr -u $"bootc-status-($v)" bootc status +} + +# Await completion +0..$n | each { |v| + loop { + let r = systemctl is-active $"bootc-status-($v)" | complete + if $r.exit_code == 0 { + break + } + # check status + systemctl status $"bootc-status-($v)" out> /dev/null + # Clean it up + systemctl reset-failed $"bootc-status-($v)" + } +} + +tap ok diff --git a/tmt/tests/booted/readonly/051-test-initramfs.nu b/tmt/tests/booted/readonly/051-test-initramfs.nu new file mode 100644 index 000000000..06bb46fb6 --- /dev/null +++ b/tmt/tests/booted/readonly/051-test-initramfs.nu @@ -0,0 +1,20 @@ +use std assert +use tap.nu + +tap begin "initramfs" + +if (not ("/usr/lib/bootc/initramfs-setup" | path exists)) { + print "No initramfs support" +} else if (not (open /proc/cmdline | str contains composefs)) { + print "No composefs in cmdline" +} else { + # journalctl --grep exits with 1 if no entries found, so we need to handle that + let result = (do { journalctl -b -t bootc-root-setup.service --grep=OK } | complete) + if $result.exit_code == 0 { + print $result.stdout + } else { + print "# TODO composefs: No bootc-root-setup.service journal entries found" + } +} + +tap ok diff --git a/tmt/tests/booted/readonly/tap.nu b/tmt/tests/booted/readonly/tap.nu new file mode 120000 index 000000000..56a69a5db --- /dev/null +++ b/tmt/tests/booted/readonly/tap.nu @@ -0,0 +1 @@ +../tap.nu \ No newline at end of file diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu new file mode 100644 index 000000000..096638fa0 --- /dev/null +++ b/tmt/tests/booted/tap.nu @@ -0,0 +1,15 @@ +# A simple nushell "library" for the +# "Test anything protocol": +# https://testanything.org/tap-version-14-specification.html +export def begin [description] { + print "TAP version 14" + print $description +} + +export def ok [] { + print "ok" +} + +export def fail [] { + print "not ok" +} diff --git a/tmt/tests/booted/test-01-readonly.nu b/tmt/tests/booted/test-01-readonly.nu new file mode 100644 index 000000000..12e1315d4 --- /dev/null +++ b/tmt/tests/booted/test-01-readonly.nu @@ -0,0 +1,19 @@ +# number: 1 +# tmt: +# summary: Execute booted readonly/nondestructive tests +# duration: 30m +# +# Run all readonly tests in sequence +use tap.nu + +tap begin "readonly tests" + +# Get all readonly test files and run them in order +let tests = (ls booted/readonly/*-test-*.nu | get name | sort) + +for test_file in $tests { + print $"Running ($test_file)..." + nu $test_file +} + +tap ok \ No newline at end of file diff --git a/tmt/tests/booted/test-26-examples-build.sh b/tmt/tests/booted/test-26-examples-build.sh new file mode 100755 index 000000000..5895419d8 --- /dev/null +++ b/tmt/tests/booted/test-26-examples-build.sh @@ -0,0 +1,24 @@ +# number: 26 +# tmt: +# summary: Test bootc examples build scripts +# duration: 45m +# adjust: +# - when: running_env != image_mode +# enabled: false +# because: packit tests use RPM bootc and does not install /usr/lib/bootc/initramfs-setup +# +#!/bin/bash +set -eux + +# Test bootc-bls example +echo "Testing bootc-bls example..." +cd examples/bootc-bls +./build + +# Test bootc-uki example +echo "Testing bootc-uki example..." +cd ../bootc-uki +./build.base +./build.final + +echo "All example builds completed successfully" diff --git a/tmt/tests/booted/test-custom-selinux-policy.nu b/tmt/tests/booted/test-custom-selinux-policy.nu new file mode 100644 index 000000000..7855c6312 --- /dev/null +++ b/tmt/tests/booted/test-custom-selinux-policy.nu @@ -0,0 +1,73 @@ +# number: 27 +# tmt: +# summary: Execute custom selinux policy test +# duration: 30m +# adjust: +# - when: running_env != image_mode +# enabled: false +# because: these tests require features only available in image mode +# +# Verify that correct labels are applied after a deployment +use std assert +use tap.nu + +# This code runs on *each* boot. +# Here we just capture information. +bootc status + +# Run on the first boot +def initial_build [] { + tap begin "local image push + pull + upgrade" + + let td = mktemp -d + cd $td + + bootc image copy-to-storage + + # A simple derived container that customizes selinux policy for random dir + "FROM localhost/bootc +RUN mkdir /opt123; echo \"/opt123 /opt\" >> /etc/selinux/targeted/contexts/files/file_contexts.subs_dist +" | save Dockerfile + # Build it + podman build --security-opt label=disable -t localhost/bootc-derived . + + bootc switch --transport containers-storage localhost/bootc-derived + + assert (not ("/opt123" | path exists)) + + # See ../bug-soft-reboot.md - TMT cannot handle systemd soft-reboots + # https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + tmt-reboot +} + +# The second boot; verify we're in the derived image and directory has correct selinux label +def second_boot [] { + tap begin "Verify directory exists and has correct SELinux label" + + assert ("/opt123" | path exists) + + # Verify the directories have the correct SELinux labels + let opt123_label = (^stat --format=%C /opt123 | str trim) + let opt_label = (^stat --format=%C /opt | str trim) + + print $"opt123 SELinux label: ($opt123_label)" + print $"opt SELinux label: ($opt_label)" + + # Both should have the same label (system_u:object_r:usr_t:s0) + assert ($opt123_label | str contains "system_u:object_r:usr_t:s0") $"Expected system_u:object_r:usr_t:s0 label for /opt123, got: ($opt123_label)" + assert ($opt_label | str contains "system_u:object_r:usr_t:s0") $"Expected system_u:object_r:usr_t:s0 label for /opt, got: ($opt_label)" + + # Verify both labels are the same + assert ($opt123_label == $opt_label) $"Labels should be the same: opt123=($opt123_label) vs opt=($opt_label)" + + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-factory-reset.nu b/tmt/tests/booted/test-factory-reset.nu new file mode 100644 index 000000000..0841eec82 --- /dev/null +++ b/tmt/tests/booted/test-factory-reset.nu @@ -0,0 +1,74 @@ +# number: 28 +# tmt: +# summary: Execute factory reset tests +# duration: 30m +# +use std assert +use tap.nu +use bootc_testlib.nu + +def initial_build [] { + tap begin "factory reset test" + + # Create test files that should be removed after factory reset + print "Creating test files in /var and /etc" + echo "test file in var" | save /var/test-file-factory-reset + echo "test file in etc" | save /etc/test-file-factory-reset + + # Verify files were created + assert ("/var/test-file-factory-reset" | path exists) + assert ("/etc/test-file-factory-reset" | path exists) + + bootc install reset --experimental + + # sanity check that bootc status shows a new deployment with a non default stateroot + let reset_status = bootc status --json | from json + assert not equal $reset_status.status.otherDeployments.0.ostree.stateroot "default" + + # we need tmt in the new stateroot for second_boot + print "Copying tmt into new stateroot" + + # Get the new stateroot name from the staged deployment + let new_stateroot = $reset_status.status.otherDeployments.0.ostree.stateroot + print $"New stateroot: ($new_stateroot)" + + # Mount /sysroot as read-write and copy tmt directory to the new deployment + mount -o remount,rw /sysroot + + let new_stateroot_path = $"/sysroot/ostree/deploy/($new_stateroot)" + + # locate the workdir_root by looking backwards from a known static dir (TMT_PLAN_DATA) + # e.g. TMT_PLAN_DATA=/var/tmp/tmt/run-035/tmt/plans/integration/test-28-factory-reset/data + let workdir_root = ($env.TMT_PLAN_DATA | path dirname | path dirname | path dirname | path dirname | path dirname | path dirname ) + + # make sure workdir_root's full path exists in new stateroot + mkdir $"($new_stateroot_path)/($workdir_root)" + + # nu's cp doesn't have -T + /usr/bin/cp -r -T $workdir_root $"($new_stateroot_path)/($workdir_root)" + + bootc_testlib reboot +} + +# The second boot; verify we're in the factory reset deployment +def second_boot [] { + print "Verifying factory reset completed successfully" + RUST_LOG=trace bootc status + let status = bootc status --json | from json + assert not equal $status.status.booted.ostree.stateroot "default" + + print "Checking that test files do not exist in the reset deployment" + assert (not ("/var/test-file-factory-reset" | path exists)) "Test file in /var should not exist after factory reset" + assert (not ("/etc/test-file-factory-reset" | path exists)) "Test file in /etc should not exist after factory reset" + print "Factory reset verification completed successfully" + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-image-pushpull-upgrade.nu b/tmt/tests/booted/test-image-pushpull-upgrade.nu new file mode 100644 index 000000000..eca7e9ddb --- /dev/null +++ b/tmt/tests/booted/test-image-pushpull-upgrade.nu @@ -0,0 +1,213 @@ +# number: 20 +# tmt: +# summary: Execute local upgrade tests +# duration: 30m +# +# This test does: +# bootc image copy-to-storage +# podman build +# bootc switch +# +# Then another build, and reboot into verifying that +use std assert +use tap.nu + +const kargsv0 = ["testarg=foo", "othertestkarg", "thirdkarg=bar"] +const kargsv1 = ["testarg=foo", "thirdkarg=baz"] +let removed = ($kargsv0 | filter { not ($in in $kargsv1) }) +const quoted_karg = '"thisarg=quoted with spaces"' + +# This code runs on *each* boot. +# Here we just capture information. +bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image +let is_composefs = ($st.status.booted.composefs? != null) + +# Parse the kernel commandline into a list. +# This is not a proper parser, but good enough +# for what we need here. +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +# Run on the first boot +def initial_build [] { + tap begin "local image push + pull + upgrade" + + let td = mktemp -d + cd $td + + bootc image copy-to-storage + let img = podman image inspect localhost/bootc | from json + + mkdir usr/lib/bootc/kargs.d + { kargs: $kargsv0 } | to toml | save usr/lib/bootc/kargs.d/05-testkargs.toml + # A simple derived container that adds a file, but also injects some kargs + "FROM localhost/bootc +COPY usr/ /usr/ +RUN echo test content > /usr/share/blah.txt +" | save Dockerfile + # Build it + podman build --security-opt label=disable -t localhost/bootc-derived . + # Just sanity check it + let v = podman run --rm --security-opt label=disable localhost/bootc-derived cat /usr/share/blah.txt | str trim + assert equal $v "test content" + + let orig_root_mtime = ls -Dl /ostree/bootc | get modified + + # Now, fetch it back into the bootc storage! + # We also test the progress API here + let tmpdir = mktemp -d -t bootc-progress.XXXXXX + let progress_fifo = $"($tmpdir)/progress.fifo" + let progress_json = $"($tmpdir)/progress.json" + mkfifo $progress_fifo + # nushell doesn't support & (for good reasons) so fork off a copy task via systemd-run + # which reads from the fifo and writes to a file + try { systemctl kill test-cat-progress } + systemd-run -u test-cat-progress -- /bin/bash -c $"exec cat ($progress_fifo) > ($progress_json)" + # nushell doesn't do fd passing right now either, so run via bash + bash -c $"bootc switch --progress-fd 3 --transport containers-storage localhost/bootc-derived 3>($progress_fifo)" + # Now, let's do some checking of the progress json + let progress = open --raw $progress_json | from json -o + sanity_check_switch_progress_json $progress + + # Check that /run/reboot-required exists and is not empty + let rr_meta = (ls /run/reboot-required | first) + assert ($rr_meta.size > 0b) + + # Verify that we logged to the journal + journalctl _MESSAGE_ID=3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7 + + # The mtime should change on modification + let new_root_mtime = ls -Dl /ostree/bootc | get modified + assert ($new_root_mtime > $orig_root_mtime) + + # Test for https://github.com/ostreedev/ostree/issues/3544 + # Add a quoted karg using rpm-ostree if available + if not $is_composefs { + print "Adding quoted karg via rpm-ostree to test ostree issue #3544" + rpm-ostree kargs --append=($quoted_karg) + } + + # And reboot into it + tmt-reboot +} + +# This just does some basic verification of the progress JSON +def sanity_check_switch_progress_json [data] { + let event_count = $data | length + # The first one should always be a start event + let first = $data.0; + assert equal $first.type Start + let second = $data.1 + let steps = $second.stepsTotal + mut i = 0 + for elt in $data { + if $elt.type != "ProgressBytes" { + break + } + # Bounds check steps + assert ($elt.steps <= $elt.stepsTotal) + assert equal $elt.stepsTotal $steps + $i += 1 + } + let deploy = $data | get ($event_count - 1) + assert equal $deploy.steps 3 + assert equal $deploy.stepsTotal 3 + let deploy_tasks = $deploy.subtasks + assert equal ($deploy_tasks | length) 5 + let deploy_names = $deploy_tasks | get subtask + assert equal $deploy_names ["merging", "deploying", "bound_images", "cleanup", "cleanup"] +} + +# The second boot; verify we're in the derived image +def second_boot [] { + print "verifying second boot" + # booted from the local container storage and image + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image localhost/bootc-derived + # We wrote this file + let t = open /usr/share/blah.txt | str trim + assert equal $t "test content" + + # Verify we have updated kargs + let cmdline = parse_cmdline + print $"cmdline=($cmdline)" + for x in $kargsv0 { + print $"verifying karg: ($x)" + assert ($x in $cmdline) + } + + # Test for https://github.com/ostreedev/ostree/issues/3544 + # Verify the quoted karg added via rpm-ostree is still present + if not $is_composefs { + print "Verifying quoted karg persistence (ostree issue #3544)" + let cmdline = open /proc/cmdline + assert ($quoted_karg in $cmdline) $"Expected quoted karg ($quoted_karg) not found in cmdline" + } + + # Now do another build where we drop one of the kargs + let td = mktemp -d + cd $td + + mkdir usr/lib/bootc/kargs.d + { kargs: $kargsv1 } | to toml | save usr/lib/bootc/kargs.d/05-testkargs.toml + "FROM localhost/bootc +COPY usr/ /usr/ +RUN echo test content2 > /usr/share/blah.txt +" | save Dockerfile + # Build it + podman build --security-opt label=disable -t localhost/bootc-derived . + let booted_digest = $booted.imageDigest + print $"booted_digest = ($booted_digest)" + # We should already be fetching updates from container storage + bootc upgrade + # Verify we staged an update + let st = bootc status --json | from json + let staged_digest = $st.status.staged.image.imageDigest + assert ($booted_digest != $staged_digest) + # And reboot into the upgrade + tmt-reboot +} + +# Check we have the updated kargs +def third_boot [] { + print "verifying third boot" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image localhost/bootc-derived + let t = open /usr/share/blah.txt | str trim + assert equal $t "test content2" + + # Verify we have updated kargs + let cmdline = parse_cmdline + print $"cmdline=($cmdline)" + for x in $kargsv1 { + print $"Verifying karg ($x)" + assert ($x in $cmdline) + } + # And the kargs that should be removed are gone + for x in $removed { + assert not ($removed in $cmdline) + } + + # Test for https://github.com/ostreedev/ostree/issues/3544 + # Verify the quoted karg added via rpm-ostree is still present after upgrade + if not $is_composefs { + print "Verifying quoted karg persistence after upgrade (ostree issue #3544)" + let cmdline = open /proc/cmdline + assert ($quoted_karg in $cmdline) $"Expected quoted karg ($quoted_karg) not found in cmdline after upgrade" + } + + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-image-upgrade-reboot.nu b/tmt/tests/booted/test-image-upgrade-reboot.nu new file mode 100644 index 000000000..553cae520 --- /dev/null +++ b/tmt/tests/booted/test-image-upgrade-reboot.nu @@ -0,0 +1,76 @@ +# number: 24 +# tmt: +# summary: Execute local upgrade tests +# duration: 30m +# +# This test does: +# bootc image copy-to-storage +# podman build +# bootc switch --apply +# Verify we boot into the new image +# +use std assert +use tap.nu + +# This code runs on *each* boot. +# Here we just capture information. +bootc status +journalctl --list-boots + +let st = bootc status --json | from json +let booted = $st.status.booted.image + +# Parse the kernel commandline into a list. +# This is not a proper parser, but good enough +# for what we need here. +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +def imgsrc [] { + $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" +} + +# Run on the first boot +def initial_build [] { + tap begin "local image push + pull + upgrade" + + let imgsrc = imgsrc + # For the packit case, we build locally right now + if ($imgsrc | str ends-with "-local") { + bootc image copy-to-storage + + # A simple derived container that adds a file + "FROM localhost/bootc +RUN touch /usr/share/testing-bootc-upgrade-apply +" | save Dockerfile + # Build it + podman build -t $imgsrc . + } + + # Now, switch into the new image + print $"Applying ($imgsrc)" + bootc switch --transport containers-storage ($imgsrc) + tmt-reboot +} + +# Check we have the updated image +def second_boot [] { + print "verifying second boot" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image $"(imgsrc)" + + # Verify the new file exists + "/usr/share/testing-bootc-upgrade-apply" | path exists + + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu new file mode 100644 index 000000000..a44420f79 --- /dev/null +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -0,0 +1,43 @@ +# number: 23 +# tmt: +# summary: Execute tests for installing outside of a container +# duration: 30m +# +use std assert +use tap.nu + +# In this test we install a generic image mainly because it keeps +# this test in theory independent of starting from a bootc host, +# but also because it's useful to test "skew" between the bootc binary +# doing the install and the target image. +let target_image = "docker://quay.io/centos-bootc/centos-bootc:stream10" + +# setup filesystem +mkdir /var/mnt +truncate -s 10G disk.img +mkfs.ext4 disk.img +mount -o loop disk.img /var/mnt + +# attempt to install to filesystem without specifying a source-imgref +let result = bootc install to-filesystem /var/mnt e>| find "--source-imgref must be defined" +assert not equal $result null +umount /var/mnt + +# Mask off the bootupd state to reproduce https://github.com/bootc-dev/bootc/issues/1778 +# Also it turns out that installation outside of containers dies due to `error: Multiple commit objects found` +# so we mask off /sysroot/ostree +# And using systemd-run here breaks our install_t so we disable SELinux enforcement +setenforce 0 +systemd-run -p MountFlags=slave -qdPG -- /bin/sh -c $" +set -xeuo pipefail +if test -d /sysroot/ostree; then mount --bind /usr/share/empty /sysroot/ostree; fi +mkdir -p /tmp/ovl/{upper,work} +mount -t overlay -olowerdir=/usr,workdir=/tmp/ovl/work,upperdir=/tmp/ovl/upper overlay /usr +# Note we do keep the other bootupd state +rm -vrf /usr/lib/bootupd/updates +# Another bootc install bug, we should not look at this in outside-of-container flows +rm -vrf /usr/lib/bootc/bound-images.d +bootc install to-disk --disable-selinux --via-loopback --filesystem xfs --source-imgref ($target_image) ./disk.img +" + +tap ok diff --git a/tmt/tests/booted/test-logically-bound-install.nu b/tmt/tests/booted/test-logically-bound-install.nu new file mode 100644 index 000000000..29e42e8ca --- /dev/null +++ b/tmt/tests/booted/test-logically-bound-install.nu @@ -0,0 +1,65 @@ +# number: 22 +# tmt: +# summary: Execute logically bound images tests for installing image +# duration: 30m +# +use std assert +use tap.nu + +# This list reflects the LBIs specified in bootc/tests/containerfiles/lbi/usr/share/containers/systemd +let expected_images = [ + "quay.io/curl/curl:latest", + "quay.io/curl/curl-base:latest", + "registry.access.redhat.com/ubi9/podman:latest" # this image is signed +] + +def validate_images [images: list] { + print $"Validating images ($images)" + for expected in $expected_images { + assert ($expected in $images) + } +} + +# This test checks that bootc actually populated the bootc storage with the LBI images +def test_logically_bound_images_in_storage [] { + # Use podman to list the images in the bootc storage + let images = podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images --format {{.Repository}}:{{.Tag}} | split row "\n" + + # Debug print + print "IMAGES:" + podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images + + validate_images $images +} + +# This test makes sure that bootc itself knows how to list the LBI images in the bootc storage +def test_bootc_image_list [] { + # Use bootc to list the images in the bootc storage + let images = bootc image list --type logical --format json | from json | get image + + validate_images $images +} + +# Get just the type (foo_t) from a security context +def get_file_selinux_type [p] { + getfattr --only-values -n security.selinux $p | split row ':' | get 2 +} + +# Verify that the SELinux labels on the main "containers-storage:" instance match ours. +# See the relabeling we do in imgstorage.rs. We only verify types, because the role +# may depend on the creating user. +def test_storage_labels [] { + for v in [".", "overlay-images", "defaultNetworkBackend"] { + let base = (get_file_selinux_type $"/var/lib/containers/storage/($v)") + let target = (get_file_selinux_type $"/usr/lib/bootc/storage/($v)") + assert equal $base $target + } + # Verify the stamp file exists + test -f /usr/lib/bootc/storage/.bootc_labeled +} + +test_logically_bound_images_in_storage +test_bootc_image_list +test_storage_labels + +tap ok diff --git a/tmt/tests/booted/test-logically-bound-switch.nu b/tmt/tests/booted/test-logically-bound-switch.nu new file mode 100644 index 000000000..10db4c549 --- /dev/null +++ b/tmt/tests/booted/test-logically-bound-switch.nu @@ -0,0 +1,156 @@ +# number: 21 +# tmt: +# summary: Execute logically bound images tests for switching images +# duration: 30m +# +# This test does: +# bootc image switch bootc-bound-image +# +# +# +# bootc upgrade +# +# +# + +use std assert +use tap.nu + +# This code runs on *each* boot. +bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def initial_setup [] { + bootc image copy-to-storage + podman images + podman image inspect localhost/bootc | from json +} + +def build_image [name images containers] { + let td = mktemp -d + cd $td + mkdir usr/share/containers/systemd + + mut dockerfile = "FROM localhost/bootc +COPY usr/ /usr/ +RUN echo sanity check > /usr/share/bound-image-sanity-check.txt +" | save Dockerfile + + for image in $images { + echo $"[Image]\nImage=($image.image)" | save $"usr/share/containers/systemd/($image.name).image" + if $image.bound == true { + # these extra RUNs are suboptimal + # however, this is just a test image and the extra RUNs will only add a couple extra layers + # the benefit is simplified file creation, i.e. we don't need to handle adding "&& \" to each line + echo $"RUN ln -s /usr/share/containers/systemd/($image.name).image /usr/lib/bootc/bound-images.d/($image.name).image\n" | save Dockerfile --append + } + } + + for container in $containers { + echo $"[Container]\nImage=($container.image)" | save $"usr/share/containers/systemd/($container.name).container" + if $container.bound == true { + echo $"RUN ln -s /usr/share/containers/systemd/($container.name).container /usr/lib/bootc/bound-images.d/($container.name).container\n" | save Dockerfile --append + } + } + + # Build it + podman build --security-opt label=disable -t $name . + # Just sanity check it + let v = podman run --security-opt label=disable --rm $name cat /usr/share/bound-image-sanity-check.txt | str trim + assert equal $v "sanity check" +} + +def verify_images [images containers] { + let bound_images = $images | where bound == true + let bound_containers = $containers | where bound == true + let num_bound = ($bound_images | length) + ($bound_containers | length) + + let image_names = podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images --format json | from json | select -i Names + + for $image in $bound_images { + let found = $image_names | where Names == [$image.image] + assert (($found | length) > 0) $"($image.image) not found" + } + + for $container in $bound_containers { + let found = $image_names | where Names == [$container.image] + assert (($found | length) > 0) $"($container.image) not found" + } +} + +def first_boot [] { + tap begin "bootc switch with bound images" + + initial_setup + + # build a bootc image that includes bound images + let images = [ + { "bound": true, "image": "registry.access.redhat.com/ubi9/ubi-minimal:9.4", "name": "ubi-minimal" }, + { "bound": false, "image": "quay.io/centos-bootc/centos-bootc:stream9", "name": "centos-bootc" } + ] + + let containers = [{ + "bound": true, "image": "docker.io/library/alpine:latest", "name": "alpine" + }] + + let image_name = "localhost/bootc-bound" + build_image $image_name $images $containers + bootc switch --transport containers-storage $image_name + verify_images $images $containers + tmt-reboot +} + +def second_boot [] { + print "verifying second boot after switch" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image localhost/bootc-bound + + # verify images are still there after boot + let images = [ + { "bound": true, "image": "registry.access.redhat.com/ubi9/ubi-minimal:9.4", "name": "ubi-minimal" }, + { "bound": false, "image": "quay.io/centos-bootc/centos-bootc:stream9", "name": "centos-bootc" } + ] + + let containers = [{ + "bound": true, "image": "docker.io/library/alpine:latest", "name": "alpine" + }] + verify_images $images $containers + + # build a new bootc image with an additional bound image + print "bootc upgrade with another bound image" + let image_name = "localhost/bootc-bound" + let more_images = $images | append [{ "bound": true, "image": "registry.access.redhat.com/ubi9/ubi-minimal:9.3", "name": "ubi-minimal-9-3" }] + build_image $image_name $more_images $containers + bootc upgrade + verify_images $more_images $containers + tmt-reboot +} + +def third_boot [] { + print "verifying third boot after upgrade" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image localhost/bootc-bound + + let images = [ + { "bound": true, "image": "registry.access.redhat.com/ubi9/ubi-minimal:9.4", "name": "ubi-minimal" }, + { "bound": true, "image": "registry.access.redhat.com/ubi9/ubi-minimal:9.3", "name": "ubi-minimal-9-3" }, + { "bound": false, "image": "quay.io/centos-bootc/centos-bootc:stream9", "name": "centos-bootc" } + ] + + let containers = [{ + "bound": true, "image": "docker.io/library/alpine:latest", "name": "alpine" + }] + + verify_images $images $containers + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-soft-reboot-selinux-policy.nu b/tmt/tests/booted/test-soft-reboot-selinux-policy.nu new file mode 100644 index 000000000..b7d1d5ec8 --- /dev/null +++ b/tmt/tests/booted/test-soft-reboot-selinux-policy.nu @@ -0,0 +1,109 @@ +# number: 29 +# tmt: +# summary: Test soft reboot with SELinux policy changes +# duration: 30m +# +# Verify that soft reboot is blocked when SELinux policies differ +use std assert +use tap.nu + +let soft_reboot_capable = "/usr/lib/systemd/system/soft-reboot.target" | path exists +if not $soft_reboot_capable { + echo "Skipping, system is not soft reboot capable" + return +} + +# Check if SELinux is enabled +let selinux_enabled = "/sys/fs/selinux/enforce" | path exists +if not $selinux_enabled { + echo "Skipping, SELinux is not enabled" + return +} + +# This code runs on *each* boot. +bootc status + +# Run on the first boot +def initial_build [] { + tap begin "Build base image and test soft reboot with SELinux policy change" + + let td = mktemp -d + cd $td + + bootc image copy-to-storage + + # Create a derived container that installs a custom SELinux policy module + # Installing a policy module will change the compiled policy checksum + # Following Colin's suggestion and the composefs-rs example + # We create a minimal policy module and install it + "FROM localhost/bootc +# Install tools needed to build and install SELinux policy modules +RUN dnf install -y selinux-policy-devel checkpolicy policycoreutils + +# Create a minimal SELinux policy module that will change the policy checksum +# We install it to ensure it's part of the deployment filesystem +RUN < bootc_test_policy.te + echo 'require {' >> bootc_test_policy.te + echo ' type unconfined_t;' >> bootc_test_policy.te + echo ' class file { read write };' >> bootc_test_policy.te + echo '}' >> bootc_test_policy.te + echo 'type bootc_test_t;' >> bootc_test_policy.te + checkmodule -M -m -o bootc_test_policy.mod bootc_test_policy.te + semodule_package -o bootc_test_policy.pp -m bootc_test_policy.mod + semodule -i bootc_test_policy.pp + rm -rf /tmp/bootc-test-policy + # Clean up dnf cache and logs, and SELinux policy generation artifacts to satisfy lint checks + dnf clean all + rm -rf /var/log/dnf* /var/log/hawkey.log /var/log/rhsm + rm -rf /var/cache/dnf /var/lib/dnf + rm -rf /var/lib/sepolgen /var/lib/rhsm /var/cache/ldconfig +EORUN +" | save Dockerfile + + # Build the derived image + podman build --quiet --security-opt label=disable -t localhost/bootc-derived-policy . + + # Verify soft reboot preparation hasn't happened yet + assert (not ("/run/nextroot" | path exists)) + + # Try to soft reboot - this should fail because policies differ + bootc switch --soft-reboot=auto --transport containers-storage localhost/bootc-derived-policy + let st = bootc status --json | from json + + # Verify staged deployment exists + assert ($st.status.staged != null) "Expected staged deployment to exist" + + # The staged deployment should NOT be soft-reboot capable because policies differ + assert (not $st.status.staged.softRebootCapable) "Expected soft reboot to be blocked due to SELinux policy difference, but softRebootCapable is true" + + # Verify soft reboot preparation didn't happen + assert (not ("/run/nextroot" | path exists)) "Soft reboot should not be prepared when policies differ" + + # Do a full reboot + tmt-reboot +} + +# The second boot; verify we're in the derived image +def second_boot [] { + tap begin "Verify deployment with different SELinux policy" + + # Verify we're in the new deployment + let st = bootc status --json | from json + let booted = $st.status.booted.image + assert ($booted.image.image | str contains "bootc-derived-policy") $"Expected booted image to contain 'bootc-derived-policy', got: ($booted.image.image)" + + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-soft-reboot.nu b/tmt/tests/booted/test-soft-reboot.nu new file mode 100644 index 000000000..67509d8ac --- /dev/null +++ b/tmt/tests/booted/test-soft-reboot.nu @@ -0,0 +1,88 @@ +# number: 25 +# tmt: +# summary: Execute soft reboot test +# duration: 30m +# +# Verify that soft reboot works (on by default) +use std assert +use tap.nu + +let soft_reboot_capable = "/usr/lib/systemd/system/soft-reboot.target" | path exists +if not $soft_reboot_capable { + echo "Skipping, system is not soft reboot capable" + return +} + +# This code runs on *each* boot. +# Here we just capture information. +bootc status + +# Run on the first boot +def initial_build [] { + tap begin "local image push + pull + upgrade" + + let td = mktemp -d + cd $td + + bootc image copy-to-storage + + # A simple derived container that adds a file, but also injects some kargs + "FROM localhost/bootc +RUN echo test content > /usr/share/testfile-for-soft-reboot.txt +" | save Dockerfile + # Build it + podman build --security-opt label=disable -t localhost/bootc-derived . + + assert (not ("/run/nextroot" | path exists)) + + bootc switch --soft-reboot=auto --transport containers-storage localhost/bootc-derived + let st = bootc status --json | from json + assert $st.status.staged.softRebootCapable + + assert ("/run/nextroot" | path exists) + + # See ../bug-soft-reboot.md - TMT cannot handle systemd soft-reboots + ostree admin prepare-soft-reboot --reset + # https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + tmt-reboot +} + +# The second boot; verify we're in the derived image +def second_boot [] { + assert ("/usr/share/testfile-for-soft-reboot.txt" | path exists) + # See ../bug-soft-reboot.md - we can't verify SoftRebootsCount due to TMT limitation + #assert equal (systemctl show -P SoftRebootsCount) "1" + + # A new derived with new kargs which should stop the soft reboot. + "FROM localhost/bootc +RUN echo test content > /usr/share/testfile-for-soft-reboot.txt +RUN echo 'kargs = ["foo1=bar2"]' | tee /usr/lib/bootc/kargs.d/00-foo1bar2.toml > /dev/null +" | save Dockerfile + # Build it + podman build --security-opt label=disable -t localhost/bootc-derived . + + bootc upgrade --soft-reboot=auto + let st = bootc status --json | from json + # Should not be soft-reboot capable because of kargs diff + assert (not $st.status.staged.softRebootCapable) + + # And reboot into it + tmt-reboot +} + +# The third boot; verify we're in the derived image +def third_boot [] { + assert ("/usr/lib/bootc/kargs.d/00-foo1bar2.toml" | path exists) + + assert equal (systemctl show -P SoftRebootsCount) "0" +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/booted/test-usroverlay.nu b/tmt/tests/booted/test-usroverlay.nu new file mode 100644 index 000000000..ca68b239e --- /dev/null +++ b/tmt/tests/booted/test-usroverlay.nu @@ -0,0 +1,37 @@ +# number: 23 +# tmt: +# summary: Execute tests for bootc usrover +# duration: 30m +# +# Verify that bootc usroverlay works +use std assert +use tap.nu +use bootc_testlib.nu + +bootc status + +# We should start out in a non-writable state on each boot +let is_writable = (do -i { /bin/test -w /usr } | complete | get exit_code) == 0 +assert (not $is_writable) + +def initial_run [] { + bootc usroverlay + let is_writable = (do -i { /bin/test -w /usr } | complete | get exit_code) == 0 + assert ($is_writable) + + bootc_testlib reboot +} + +# The second boot; verify we're in the derived image +def second_boot [] { + # Nothing, we already verified non-writability above +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_run, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/examples/bootc-bls/Containerfile b/tmt/tests/examples/bootc-bls/Containerfile new file mode 100644 index 000000000..73f114730 --- /dev/null +++ b/tmt/tests/examples/bootc-bls/Containerfile @@ -0,0 +1,10 @@ +FROM quay.io/fedora/fedora-bootc:42 +COPY extra / +COPY bootc /usr/bin + +RUN passwd -d root + +# need to have bootc-initramfs-setup in the initramfs so we need this +RUN set -x; \ + kver=$(cd /usr/lib/modules && echo *); \ + dracut -vf --install "/etc/passwd /etc/group" /usr/lib/modules/$kver/initramfs.img $kver; diff --git a/tmt/tests/examples/bootc-bls/build b/tmt/tests/examples/bootc-bls/build new file mode 100755 index 000000000..38e45edbd --- /dev/null +++ b/tmt/tests/examples/bootc-bls/build @@ -0,0 +1,16 @@ +#!/bin/bash + +set -eux + +cd "${0%/*}" + +cp /usr/bin/bootc . +cp /usr/lib/bootc/initramfs-setup extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup + +mkdir -p tmp + +podman build \ + -t quay.io/fedora/fedora-bootc-bls:42 \ + -f Containerfile \ + --iidfile=tmp/iid \ + . diff --git a/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf new file mode 100644 index 000000000..d1adac96f --- /dev/null +++ b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf @@ -0,0 +1,3 @@ +# we need to force these in via the initramfs because we don't have modules in +# the base image +force_drivers+=" virtio_net vfat " diff --git a/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service new file mode 100644 index 000000000..15fdc5801 --- /dev/null +++ b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service @@ -0,0 +1,34 @@ +# Copyright (C) 2013 Colin Walters +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +[Unit] +DefaultDependencies=no +ConditionKernelCommandLine=composefs +ConditionPathExists=/etc/initrd-release +After=sysroot.mount +Requires=sysroot.mount +Before=initrd-root-fs.target +Before=initrd-switch-root.target + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootc-initramfs-setup +StandardInput=null +StandardOutput=journal +StandardError=journal+console +RemainAfterExit=yes diff --git a/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh new file mode 100755 index 000000000..b1c56206f --- /dev/null +++ b/tmt/tests/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/bash + +check() { + return 0 +} + +depends() { + return 0 +} + +install() { + inst \ + "${moddir}/bootc-initramfs-setup" /usr/bin/bootc-initramfs-setup + inst \ + "${moddir}/bootc-initramfs-setup.service" \ + "${systemdsystemunitdir}/bootc-initramfs-setup.service" + + $SYSTEMCTL -q --root "${initdir}" add-wants \ + 'initrd-root-fs.target' 'bootc-initramfs-setup.service' +} diff --git a/tmt/tests/examples/bootc-uki/Containerfile.stage1 b/tmt/tests/examples/bootc-uki/Containerfile.stage1 new file mode 100644 index 000000000..175f3e253 --- /dev/null +++ b/tmt/tests/examples/bootc-uki/Containerfile.stage1 @@ -0,0 +1,10 @@ +FROM quay.io/fedora/fedora-bootc:42 +COPY extra / +COPY bootc /usr/bin + +RUN passwd -d root + +# need to have composefs setup root in the initramfs so we need this +RUN set -x; \ + kver=$(cd /usr/lib/modules && echo *); \ + dracut -vf --install "/etc/passwd /etc/group" /usr/lib/modules/$kver/initramfs.img $kver; diff --git a/tmt/tests/examples/bootc-uki/Containerfile.stage2 b/tmt/tests/examples/bootc-uki/Containerfile.stage2 new file mode 100644 index 000000000..964a6f2ae --- /dev/null +++ b/tmt/tests/examples/bootc-uki/Containerfile.stage2 @@ -0,0 +1,46 @@ +FROM quay.io/fedora/fedora-bootc-base-uki:42 AS base + +FROM base as kernel + +ARG COMPOSEFS_FSVERITY + +RUN --mount=type=secret,id=key \ + --mount=type=secret,id=cert < /etc/kernel/cmdline + + dnf install -y systemd-ukify sbsigntools systemd-boot-unsigned + kver=$(cd /usr/lib/modules && echo *) + ukify build \ + --linux "/usr/lib/modules/$kver/vmlinuz" \ + --initrd "/usr/lib/modules/$kver/initramfs.img" \ + --uname="${kver}" \ + --cmdline "@/etc/kernel/cmdline" \ + --os-release "@/etc/os-release" \ + --signtool sbsign \ + --secureboot-private-key "/run/secrets/key" \ + --secureboot-certificate "/run/secrets/cert" \ + --measure \ + --json pretty \ + --output "/boot/$kver.efi" + sbsign \ + --key "/run/secrets/key" \ + --cert "/run/secrets/cert" \ + "/usr/lib/systemd/boot/efi/systemd-bootx64.efi" \ + --output "/boot/systemd-bootx64.efi" +EOF + +FROM base as final + +RUN --mount=type=bind,from=kernel,target=/_mount/kernel < /dev/null + systemd-id128 new -u > GUID.txt + openssl req -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Platform Key/" -out PK.crt + openssl x509 -outform DER -in PK.crt -out PK.cer + openssl req -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Key Exchange Key/" -out KEK.crt + openssl x509 -outform DER -in KEK.crt -out KEK.cer + openssl req -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Signature Database key/" -out db.crt + openssl x509 -outform DER -in db.crt -out db.cer + popd > /dev/null +fi + +# For debugging, add --no-cache to podman command +sudo podman build \ + -t quay.io/fedora/fedora-bootc-uki:42 \ + --build-arg=COMPOSEFS_FSVERITY="${COMPOSEFS_FSVERITY}" \ + -f Containerfile.stage2 \ + --secret=id=key,src=secureboot/db.key \ + --secret=id=cert,src=secureboot/db.crt \ + --iidfile=tmp/iid2 diff --git a/tmt/tests/examples/bootc-uki/build_vars b/tmt/tests/examples/bootc-uki/build_vars new file mode 100755 index 000000000..8008414b4 --- /dev/null +++ b/tmt/tests/examples/bootc-uki/build_vars @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eux + +cd "${0%/*}" + +if [[ ! -d "secureboot" ]]; then + echo "fail" + exit 1 +fi + +# See: https://github.com/rhuefi/qemu-ovmf-secureboot +# $ dnf install -y python3-virt-firmware +GUID=$(cat secureboot/GUID.txt) +virt-fw-vars --input "/usr/share/edk2/ovmf/OVMF_VARS_4M.secboot.qcow2" \ + --secure-boot \ + --set-pk $GUID "secureboot/PK.crt" \ + --add-kek $GUID "secureboot/KEK.crt" \ + --add-db $GUID "secureboot/db.crt" \ + -o "VARS_CUSTOM.secboot.qcow2.template" diff --git a/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf new file mode 100644 index 000000000..d1adac96f --- /dev/null +++ b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf @@ -0,0 +1,3 @@ +# we need to force these in via the initramfs because we don't have modules in +# the base image +force_drivers+=" virtio_net vfat " diff --git a/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service new file mode 100644 index 000000000..15fdc5801 --- /dev/null +++ b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/bootc-initramfs-setup.service @@ -0,0 +1,34 @@ +# Copyright (C) 2013 Colin Walters +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +[Unit] +DefaultDependencies=no +ConditionKernelCommandLine=composefs +ConditionPathExists=/etc/initrd-release +After=sysroot.mount +Requires=sysroot.mount +Before=initrd-root-fs.target +Before=initrd-switch-root.target + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootc-initramfs-setup +StandardInput=null +StandardOutput=journal +StandardError=journal+console +RemainAfterExit=yes diff --git a/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh new file mode 100755 index 000000000..b1c56206f --- /dev/null +++ b/tmt/tests/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37bootc/module-setup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/bash + +check() { + return 0 +} + +depends() { + return 0 +} + +install() { + inst \ + "${moddir}/bootc-initramfs-setup" /usr/bin/bootc-initramfs-setup + inst \ + "${moddir}/bootc-initramfs-setup.service" \ + "${systemdsystemunitdir}/bootc-initramfs-setup.service" + + $SYSTEMCTL -q --root "${initdir}" add-wants \ + 'initrd-root-fs.target' 'bootc-initramfs-setup.service' +} diff --git a/tmt/tests/examples/bootc-uki/install-grub.sh b/tmt/tests/examples/bootc-uki/install-grub.sh new file mode 100755 index 000000000..6a9b0bd60 --- /dev/null +++ b/tmt/tests/examples/bootc-uki/install-grub.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -eux + +curl http://192.168.122.1:8000/bootc -o bootc +chmod +x bootc + +IMAGE=quay.io/fedora/fedora-bootc-uki:42 + +# --env RUST_LOG=debug \ +# --env RUST_BACKTRACE=1 \ +podman run \ + --rm --privileged \ + --pid=host \ + -v /dev:/dev \ + -v /var/lib/containers:/var/lib/containers \ + -v /srv/bootc:/usr/bin/bootc:ro,Z \ + -v /var/tmp:/var/tmp \ + --security-opt label=type:unconfined_t \ + "${IMAGE}" \ + bootc install to-disk \ + --composefs-backend \ + --boot=uki \ + --source-imgref="containers-storage:${IMAGE}" \ + --target-imgref="${IMAGE}" \ + --target-transport="docker" \ + /dev/vdb \ + --filesystem=ext4 \ + --wipe diff --git a/tmt/tests/examples/bootc-uki/install-systemd-boot.sh b/tmt/tests/examples/bootc-uki/install-systemd-boot.sh new file mode 100755 index 000000000..9eca959a8 --- /dev/null +++ b/tmt/tests/examples/bootc-uki/install-systemd-boot.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -eux + +curl http://192.168.122.1:8000/bootc -o bootc +chmod +x bootc + +IMAGE=quay.io/fedora/fedora-bootc-uki:42 + +if [[ ! -f /srv/systemd-bootx64.efi ]]; then + echo "Needs /srv/systemd-bootx64.efi to exists for now" + exit 1 +fi + +# --env RUST_LOG=debug \ +# --env RUST_BACKTRACE=1 \ +podman run \ + --rm --privileged \ + --pid=host \ + -v /dev:/dev \ + -v /var/lib/containers:/var/lib/containers \ + -v /srv/bootc:/usr/bin/bootc:ro,Z \ + -v /var/tmp:/var/tmp \ + --security-opt label=type:unconfined_t \ + "${IMAGE}" \ + bootc install to-disk \ + --composefs-backend \ + --boot=uki \ + --source-imgref="containers-storage:${IMAGE}" \ + --target-imgref="${IMAGE}" \ + --target-transport="docker" \ + /dev/vdb \ + --filesystem=ext4 \ + --wipe + +mkdir -p efi +mount /dev/vdb2 /srv/efi + +# Manual systemd-boot installation +cp /srv/systemd-bootx64.efi /srv/efi/EFI/fedora/grubx64.efi +mkdir -p /srv/efi/loader +echo "timeout 5" > /srv/efi/loader/loader.conf +rm -rf /srv/efi/EFI/fedora/grub.cfg + +umount efi diff --git a/tmt/tests/lbi/usr/share/containers/systemd/curl-base.image b/tmt/tests/lbi/usr/share/containers/systemd/curl-base.image new file mode 100644 index 000000000..5c818c0fb --- /dev/null +++ b/tmt/tests/lbi/usr/share/containers/systemd/curl-base.image @@ -0,0 +1,2 @@ +[Image] +Image=quay.io/curl/curl-base:latest diff --git a/tmt/tests/lbi/usr/share/containers/systemd/curl.container b/tmt/tests/lbi/usr/share/containers/systemd/curl.container new file mode 100644 index 000000000..b3788916c --- /dev/null +++ b/tmt/tests/lbi/usr/share/containers/systemd/curl.container @@ -0,0 +1,3 @@ +[Container] +Image=quay.io/curl/curl:latest +GlobalArgs=--storage-opt=additionalimagestore=/usr/lib/bootc/storage diff --git a/tmt/tests/lbi/usr/share/containers/systemd/jboss-webserver-5.image b/tmt/tests/lbi/usr/share/containers/systemd/jboss-webserver-5.image new file mode 100644 index 000000000..6b04d82dc --- /dev/null +++ b/tmt/tests/lbi/usr/share/containers/systemd/jboss-webserver-5.image @@ -0,0 +1,6 @@ +# This is not symlinked to bound-images.d so it should not be pulled. +# It's here to represent an app image that exists +# in a bootc image but is not logically bound. +[Image] +Image=registry.redhat.io/jboss-webserver-5/jws5-rhel8-operator:latest +AuthFile=/root/auth.json diff --git a/tmt/tests/lbi/usr/share/containers/systemd/podman.image b/tmt/tests/lbi/usr/share/containers/systemd/podman.image new file mode 100644 index 000000000..cb37cc613 --- /dev/null +++ b/tmt/tests/lbi/usr/share/containers/systemd/podman.image @@ -0,0 +1,2 @@ +[Image] +Image=registry.access.redhat.com/ubi9/podman:latest diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf new file mode 100644 index 000000000..b867456a4 --- /dev/null +++ b/tmt/tests/tests.fmf @@ -0,0 +1,70 @@ +# THIS IS GENERATED CODE - DO NOT EDIT +# Generated by: cargo xtask tmt + +/test-01-readonly: + summary: Execute booted readonly/nondestructive tests + duration: 30m + test: nu booted/test-01-readonly.nu + +/test-20-image-pushpull-upgrade: + summary: Execute local upgrade tests + duration: 30m + test: nu booted/test-image-pushpull-upgrade.nu + +/test-21-logically-bound-switch: + summary: Execute logically bound images tests for switching images + duration: 30m + test: nu booted/test-logically-bound-switch.nu + +/test-22-logically-bound-install: + summary: Execute logically bound images tests for installing image + duration: 30m + test: nu booted/test-logically-bound-install.nu + +/test-23-install-outside-container: + summary: Execute tests for installing outside of a container + duration: 30m + test: nu booted/test-install-outside-container.nu + +/test-23-usroverlay: + summary: Execute tests for bootc usrover + duration: 30m + test: nu booted/test-usroverlay.nu + +/test-24-image-upgrade-reboot: + summary: Execute local upgrade tests + duration: 30m + test: nu booted/test-image-upgrade-reboot.nu + +/test-25-soft-reboot: + summary: Execute soft reboot test + duration: 30m + test: nu booted/test-soft-reboot.nu + +/test-26-examples-build: + summary: Test bootc examples build scripts + duration: 45m + adjust: + - when: running_env != image_mode + enabled: false + because: packit tests use RPM bootc and does not install /usr/lib/bootc/initramfs-setup + test: bash booted/test-26-examples-build.sh + +/test-27-custom-selinux-policy: + summary: Execute custom selinux policy test + duration: 30m + adjust: + - when: running_env != image_mode + enabled: false + because: these tests require features only available in image mode + test: nu booted/test-custom-selinux-policy.nu + +/test-28-factory-reset: + summary: Execute factory reset tests + duration: 30m + test: nu booted/test-factory-reset.nu + +/test-29-soft-reboot-selinux-policy: + summary: Test soft reboot with SELinux policy changes + duration: 30m + test: nu booted/test-soft-reboot-selinux-policy.nu