commit ec689d58ee34b068585ceef56e83919f13fab715 Author: robojerk Date: Sat Aug 9 22:11:50 2025 -0700 Initial Debian fork of bootupd for immutable Debian proof-of-concept diff --git a/bootupd/.cargo/config.toml b/bootupd/.cargo/config.toml new file mode 100755 index 0000000..d8c2032 --- /dev/null +++ b/bootupd/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --manifest-path ./xtask/Cargo.toml --" diff --git a/bootupd/.cci.jenkinsfile b/bootupd/.cci.jenkinsfile new file mode 100755 index 0000000..61340e0 --- /dev/null +++ b/bootupd/.cci.jenkinsfile @@ -0,0 +1,81 @@ +// Documentation: https://github.com/coreos/coreos-ci/blob/main/README-upstream-ci.md + +properties([ + // abort previous runs when a PR is updated to save resources + disableConcurrentBuilds(abortPrevious: true) +]) + +stage("Build") { +parallel build: { + def n = 5 + buildPod(runAsUser: 0, memory: "2Gi", cpu: "${n}") { + checkout scm + stage("Core build") { + shwrap(""" + make -j ${n} + """) + } + stage("Unit tests") { + shwrap(""" + dnf install -y grub2-tools-minimal + cargo test + """) + } + shwrap(""" + make install-all DESTDIR=\$(pwd)/insttree/ + tar -c -C insttree/ -zvf insttree.tar.gz . + """) + stash includes: 'insttree.tar.gz', name: 'build' + } +}, +codestyle: { + buildPod { + checkout scm + shwrap("cargo fmt -- --check") + } +} +} + +// Build FCOS and do a kola basic run +// FIXME update to main branch once https://github.com/coreos/fedora-coreos-config/pull/595 merges +// The FCOS build process is memory-intensive; 6GiB is needed to prevent OOM errors. +cosaPod(runAsUser: 0, memory: "6144Mi", cpu: "4") { + stage("Build FCOS") { + checkout scm + unstash 'build' + // Note that like {rpm-,}ostree we want to install to both / and overrides/rootfs + // because bootupd is used both during the `rpm-ostree compose tree` as well as + // inside the target operating system. + shwrap(""" + mkdir insttree + tar -C insttree -xzvf insttree.tar.gz + rsync -rlv insttree/ / + coreos-assembler init --force https://github.com/coreos/fedora-coreos-config + mkdir -p overrides/rootfs + mv insttree/* overrides/rootfs/ + rmdir insttree + cosa fetch + cosa build + cosa osbuild metal4k + """) + } + // The e2e-adopt test will use the ostree commit we just generated above + // but a static qemu base image. + try { + // Now a test that upgrades using bootupd + stage("e2e upgrade test") { + shwrap(""" + git config --global --add safe.directory "\$(pwd)" + env COSA_DIR=${env.WORKSPACE} ./tests/e2e-update/e2e-update.sh + """) + } + stage("Kola testing") { + // The previous e2e leaves things only having built an ostree update + shwrap("cosa build") + // bootupd really can't break upgrades for the OS + kola(cosaDir: "${env.WORKSPACE}", extraArgs: "ext.*bootupd*", skipUpgrade: true, skipBasicScenarios: true) + } + } finally { + archiveArtifacts allowEmptyArchive: true, artifacts: 'tmp/console.txt' + } +} diff --git a/bootupd/.copr/Makefile b/bootupd/.copr/Makefile new file mode 100755 index 0000000..011fb2a --- /dev/null +++ b/bootupd/.copr/Makefile @@ -0,0 +1,7 @@ +srpm: + dnf -y install cargo git openssl-devel + # similar to https://github.com/actions/checkout/issues/760, but for COPR + git config --global --add safe.directory '*' + cargo install cargo-vendor-filterer + cargo xtask package-srpm + mv target/*.src.rpm $$outdir diff --git a/bootupd/.dockerignore b/bootupd/.dockerignore new file mode 100755 index 0000000..a0dbd07 --- /dev/null +++ b/bootupd/.dockerignore @@ -0,0 +1,2 @@ +target +.cosa diff --git a/bootupd/.gemini/config.yaml b/bootupd/.gemini/config.yaml new file mode 100755 index 0000000..1585c84 --- /dev/null +++ b/bootupd/.gemini/config.yaml @@ -0,0 +1,12 @@ +# This config mainly overrides `summary: false` by default +# as it's really noisy. +have_fun: true +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false # turned off by default + code_review: true +ignore_patterns: [] diff --git a/bootupd/.github/ISSUE_TEMPLATE/release-checklist.md b/bootupd/.github/ISSUE_TEMPLATE/release-checklist.md new file mode 100755 index 0000000..4b9e022 --- /dev/null +++ b/bootupd/.github/ISSUE_TEMPLATE/release-checklist.md @@ -0,0 +1,111 @@ +# Release process + +The release process follows the usual PR-and-review flow, allowing an external reviewer to have a final check before publishing. + +In order to ease downstream packaging of Rust binaries, an archive of vendored dependencies is also provided (only relevant for offline builds). + +## Requirements + +This guide requires: + + * A web browser (and network connectivity) + * `git` + * [GPG setup][GPG setup] and personal key for signing + * [git-evtag](https://github.com/cgwalters/git-evtag/) + * `cargo` (suggested: latest stable toolchain from [rustup][rustup]) + * `cargo-release` (suggested: `cargo install -f cargo-release`) + * `cargo vendor-filterer` (suggested: `cargo install -f cargo-vendor-filterer`) + * A verified account on crates.io + * Write access to this GitHub project + * Upload access to this project on GitHub, crates.io + * Membership in the [Fedora CoreOS Crates Owners group](https://github.com/orgs/coreos/teams/fedora-coreos-crates-owners/members) + +## Release checklist + +- Prepare local branch+commit + - [ ] `git checkout -b release` + - [ ] Bump the version number in `Cargo.toml`. Usually you just want to bump the patch. + - [ ] Run `cargo build` to ensure `Cargo.lock` would be updated + - [ ] Commit changes `git commit -a -m 'Release x.y.z'`; include some useful brief changelog. + +- Prepare the release + - [ ] Run `./ci/prepare-release.sh` + +- Validate that `origin` points to the canonical upstream repository and not your fork: + `git remote show origin` should not be `github.com/$yourusername/$project` but should + be under the organization ownership. The remote `yourname` should be for your fork. + +- open and merge a PR for this release: + - [ ] `git push --set-upstream origin release` + - [ ] open a web browser and create a PR for the branch above + - [ ] make sure the resulting PR contains the commit + - [ ] in the PR body, write a short changelog with relevant changes since last release + - [ ] get the PR reviewed, approved and merged + +- publish the artifacts (tag and crate): + - [ ] `git fetch origin && git checkout ${RELEASE_COMMIT}` + - [ ] verify `Cargo.toml` has the expected version + - [ ] `git-evtag sign v${RELEASE_VER}` + - [ ] `git push --tags origin v${RELEASE_VER}` + - [ ] `cargo publish` + +- publish this release on GitHub: + - [ ] find the new tag in the [GitHub tag list](https://github.com/coreos/bootupd/tags), click the triple dots menu, and create a release for it + - [ ] write a short changelog with `git shortlog $last_tag..` (i.e. re-use the PR content). See previous releases for format, for example [`v0.2.25`](https://hackmd.io/@hhei/SkYe0AtMye) + - [ ] upload `target/${PROJECT}-${RELEASE_VER}-vendor.tar.gz` + - [ ] record digests of local artifacts: + - `sha256sum target/package/${PROJECT}-${RELEASE_VER}.crate` + - `sha256sum target/${PROJECT}-${RELEASE_VER}-vendor.tar.gz` + - [ ] publish release + +- clean up: + - [ ] `git push origin :release` + - [ ] `cargo clean` + - [ ] `git checkout main` + +- Fedora packaging: + - [ ] update the `rust-bootupd` spec file in [Fedora](https://src.fedoraproject.org/rpms/rust-bootupd) + - bump the `Version` + - remove any patches obsoleted by the new release + - [ ] run `spectool -g -S rust-bootupd.spec` + - [ ] run `kinit your_fas_account@FEDORAPROJECT.ORG` + - [ ] run `fedpkg new-sources ` + - [ ] PR the changes in [Fedora](https://src.fedoraproject.org/rpms/rust-bootupd) + - [ ] once the PR merges to rawhide, merge rawhide into the other relevant branches (e.g. f35) then push those, for example: + ```bash + git checkout rawhide + git pull --ff-only + git checkout f35 + git merge --ff-only rawhide + git push origin f35 + ``` + - [ ] on each of those branches run `fedpkg build` + - [ ] once the builds have finished, submit them to [bodhi](https://bodhi.fedoraproject.org/updates/new), filling in: + - `rust-bootupd` for `Packages` + - selecting the build(s) that just completed, except for the rawhide one (which gets submitted automatically) + - writing brief release notes like "New upstream release; see release notes at `link to GitHub release`" + - leave `Update name` blank + - `Type`, `Severity` and `Suggestion` can be left as `unspecified` unless it is a security release. In that case select `security` with the appropriate severity. + - `Stable karma` and `Unstable` karma can be set to `2` and `-1`, respectively. + - [ ] [submit a fast-track](https://github.com/coreos/fedora-coreos-config/actions/workflows/add-override.yml) for FCOS testing-devel + - [ ] [submit a fast-track](https://github.com/coreos/fedora-coreos-config/actions/workflows/add-override.yml) for FCOS next-devel if it is [open](https://github.com/coreos/fedora-coreos-pipeline/blob/main/next-devel/README.md) + +- RHCOS packaging: + - [ ] update the `rust-bootupd` spec file + - bump the `Version` + - switch the `Release` back to `1%{?dist}` + - remove any patches obsoleted by the new release + - update changelog + - [ ] run `spectool -g -S rust-bootupd.spec` + - [ ] run `kinit your_account@REDHAT.COM` + - [ ] run `rhpkg new-sources ` + - [ ] PR the changes + - [ ] get the PR reviewed and merge it + - [ ] update your local repo and run `rhpkg build` + +CentOS Stream 9 packaging: + - [ ] to be written + +[rustup]: https://rustup.rs/ +[crates-io]: https://crates.io/ +[GPG setup]: https://docs.github.com/en/github/authenticating-to-github/managing-commit-signature-verification diff --git a/bootupd/.github/dependabot.yml b/bootupd/.github/dependabot.yml new file mode 100755 index 0000000..c17cade --- /dev/null +++ b/bootupd/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Maintained in https://github.com/coreos/repo-templates +# Do not edit downstream. + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: ["skip-notes"] + open-pull-requests-limit: 3 + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - area/dependencies + + # Group all updates together in a single PR. We can remove some + # updates from a combined update PR via comments to dependabot: + # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates#managing-dependabot-pull-requests-for-grouped-updates-with-comment-commands + groups: + build: + patterns: + - "*" diff --git a/bootupd/.github/workflows/ci.yml b/bootupd/.github/workflows/ci.yml new file mode 100755 index 0000000..2615aed --- /dev/null +++ b/bootupd/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +permissions: + actions: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + c9s-bootc-e2e: + strategy: + matrix: + runner: + - ubuntu-24.04 + - ubuntu-24.04-arm + + runs-on: [ "${{ matrix.runner }}" ] + + steps: + - name: Get a newer podman for heredoc support (from debian testing) + run: | + set -eux + echo 'deb [trusted=yes] https://ftp.debian.org/debian/ testing main' | sudo tee /etc/apt/sources.list.d/testing.list + sudo apt update + sudo apt install -y crun/testing podman/testing skopeo/testing + + - uses: actions/checkout@v4 + + - name: Install podman + if: ( matrix.runner == 'ubuntu-24.04-arm' ) + run: | + sudo apt update -y + sudo apt install -y podman + + - name: build + run: sudo podman build -t localhost/bootupd:latest -f Dockerfile . + + - name: bootupctl status in container + run: | + set -xeuo pipefail + sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged localhost/bootupd:latest tests/tests/bootupctl-status-in-bootc.sh + + - name: bootc install to disk + run: | + set -xeuo pipefail + sudo truncate -s 10G myimage.raw + sudo podman run --rm --privileged -v .:/target --pid=host --security-opt label=disable \ + -v /var/lib/containers:/var/lib/containers \ + -v /dev:/dev \ + localhost/bootupd:latest bootc install to-disk --skip-fetch-check \ + --disable-selinux --generic-image --via-loopback /target/myimage.raw + # Verify we installed grub.cfg and shim on the disk + sudo losetup -P -f myimage.raw + device=$(losetup -a myimage.raw --output NAME -n) + esp_part=$(sudo sfdisk -l -J "${device}" | jq -r '.partitiontable.partitions[] | select(.type == "C12A7328-F81F-11D2-BA4B-00A0C93EC93B").node') + sudo mount "${esp_part}" /mnt/ + arch="$(uname --machine)" + if [[ "${arch}" == "x86_64" ]]; then + shim="shimx64.efi" + else + # Assume aarch64 for now + shim="shimaa64.efi" + fi + sudo ls /mnt/EFI/centos/{grub.cfg,${shim}} + sudo umount /mnt + # check /boot/grub2/grub.cfg permission + root_part=$(sudo sfdisk -l -J "${device}" | jq -r '.partitiontable.partitions[] | select(.name == "root").node') + sudo mount "${root_part}" /mnt/ + sudo ls /mnt/boot/grub2/grub.cfg + [ $(sudo stat -c "%a" /mnt/boot/grub2/grub.cfg) == "600" ] + sudo umount /mnt + sudo losetup -D "${device}" + sudo rm -f myimage.raw + + - name: bootc install to filesystem + run: | + set -xeuo pipefail + sudo podman run --rm -ti --privileged -v /:/target --pid=host --security-opt label=disable \ + -v /dev:/dev -v /var/lib/containers:/var/lib/containers \ + localhost/bootupd:latest bootc install to-filesystem --skip-fetch-check \ + --acknowledge-destructive \ + --disable-selinux --replace=alongside /target + # Verify we injected static configs + jq -re '.["static-configs"].version' /boot/bootupd-state.json + [ $(sudo stat -c "%a" /boot/grub2/grub.cfg) == "600" ] + + - name: bootupctl generate-update-metadata + run: | + set -xeuo pipefail + sudo podman run --rm -v $PWD:/run/src -w /run/src --privileged localhost/bootupd:latest tests/tests/move-content-to-usr.sh diff --git a/bootupd/.github/workflows/cross.yml b/bootupd/.github/workflows/cross.yml new file mode 100755 index 0000000..d608ca0 --- /dev/null +++ b/bootupd/.github/workflows/cross.yml @@ -0,0 +1,43 @@ +name: Cross build + +on: [push, pull_request] + +permissions: + actions: read + +jobs: + crossarch-check: + runs-on: ubuntu-22.04 + name: Build on ${{ matrix.arch }} + + strategy: + matrix: + include: + - arch: s390x + distro: ubuntu_latest + - arch: ppc64le + distro: ubuntu_latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + set-safe-directory: true + + - uses: uraimo/run-on-arch-action@v3.0.0 + name: Build + id: build + with: + arch: ${{ matrix.arch }} + distro: ${{ matrix.distro }} + + githubToken: ${{ github.token }} + + run: | + set -xeu + apt update -y + apt install -y gcc make curl libssl-dev pkg-config + # Install Rust 1.84.1 + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.84.1 + source $HOME/.cargo/env + rustc --version + cargo check diff --git a/bootupd/.github/workflows/rust.yml b/bootupd/.github/workflows/rust.yml new file mode 100755 index 0000000..aa47c0e --- /dev/null +++ b/bootupd/.github/workflows/rust.yml @@ -0,0 +1,119 @@ +# Maintained in https://github.com/coreos/repo-templates +# Do not edit downstream. + +name: Rust +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read + +# don't waste job slots on superseded code +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # Pinned toolchain for linting + ACTIONS_LINTS_TOOLCHAIN: 1.84.1 + +jobs: + tests-stable: + name: Tests, stable toolchain + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + - name: cargo build + run: cargo build --all-targets + - name: cargo test + run: cargo test --all-targets + tests-release-stable: + name: Tests (release), stable toolchain + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + - name: cargo build (release) + run: cargo build --all-targets --release + - name: cargo test (release) + run: cargo test --all-targets --release + tests-release-msrv: + name: Tests (release), minimum supported toolchain + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Detect crate MSRV + run: | + msrv=$(cargo metadata --format-version 1 --no-deps | \ + jq -r '.packages[0].rust_version') + echo "Crate MSRV: $msrv" + echo "MSRV=$msrv" >> $GITHUB_ENV + - name: Install toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ env.MSRV }} + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + - name: cargo build (release) + run: cargo build --all-targets --release + - name: cargo test (release) + run: cargo test --all-targets --release + linting: + name: Lints, pinned toolchain + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ env.ACTIONS_LINTS_TOOLCHAIN }} + components: rustfmt, clippy + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + - name: cargo fmt (check) + run: cargo fmt -- --check -l + - name: cargo clippy (warnings) + run: cargo clippy --all-targets -- -D warnings + tests-other-channels: + name: Tests, unstable toolchain + runs-on: ubuntu-latest + container: quay.io/coreos-assembler/fcos-buildroot:testing-devel + continue-on-error: true + strategy: + matrix: + channel: [beta, nightly] + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.channel }} + - name: Cache build artifacts + uses: Swatinem/rust-cache@v2 + - name: cargo build + run: cargo build --all-targets + - name: cargo test + run: cargo test --all-targets diff --git a/bootupd/.gitignore b/bootupd/.gitignore new file mode 100755 index 0000000..71c8efb --- /dev/null +++ b/bootupd/.gitignore @@ -0,0 +1,4 @@ +/target +fastbuild*.qcow2 +_kola_temp +.cosa diff --git a/bootupd/COPYRIGHT b/bootupd/COPYRIGHT new file mode 100755 index 0000000..62c7d7b --- /dev/null +++ b/bootupd/COPYRIGHT @@ -0,0 +1,7 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: bootupd +Source: https://www.github.com/coreos/bootupd + +Files: * +Copyright: 2020 Red Hat, Inc. +License: Apache-2.0 diff --git a/bootupd/Cargo.lock b/bootupd/Cargo.lock new file mode 100755 index 0000000..52ecaf6 --- /dev/null +++ b/bootupd/Cargo.lock @@ -0,0 +1,1631 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +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-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[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-internal-blockdev" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bb9c7f9e3f9ce605ae80d95f53a4957cacd95d8f022d9f7745bcdf1b9a94c4" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "fn-error-context", + "regex", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "bootc-internal-utils" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7576a42b06b4d1d4005aac27f93b848bda3d80b6c80a6f1aa0f494b957351b81" +dependencies = [ + "anyhow", + "chrono", + "rustix 1.0.8", + "serde", + "serde_json", + "shlex", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bootupd" +version = "0.2.28" +dependencies = [ + "anyhow", + "bincode", + "bootc-internal-blockdev", + "bootc-internal-utils", + "camino", + "cap-std-ext", + "chrono", + "clap", + "env_logger", + "fail", + "fn-error-context", + "fs2", + "hex", + "libc", + "libsystemd", + "log", + "openat", + "openat-ext", + "openssl", + "os-release", + "regex", + "rustix 1.0.8", + "serde", + "serde_json", + "signal-hook-registry", + "tempfile", + "walkdir", + "widestring", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" + +[[package]] +name = "camino" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +dependencies = [ + "serde", +] + +[[package]] +name = "cap-primitives" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc15faeed2223d8b8e8cc1857f5861935a06d06713c4ac106b722ae9ce3c369" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3dbd3e8e8d093d6ccb4b512264869e1281cdb032f7940bd50b2894f96f25609" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 0.38.44", +] + +[[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 1.0.8", +] + +[[package]] +name = "cap-tempfile" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffa1c0edc4958d742bab2e903e52f93ccee482072680e08d6ce0784873e65b1" +dependencies = [ + "cap-std", + "rand", + "rustix 0.38.44", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed87a9d530bb41a67537289bafcac159cb3ee28460e0a4571123d2a778a6a882" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f4f3f3c77c94aff3c7e9aac9a2ca1974a5adf392a8bb751e827d6d127ab966" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[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 = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[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 = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fail" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5e43d0f78a42ad591453aedb1d7ae631ce7ee445c7643691055a9ed8d3b01c" +dependencies = [ + "log", + "once_cell", + "rand", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[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", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2e6123af26f0f2c51cc66869137080199406754903cc926a7690401ce09cb4" +dependencies = [ + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[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.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "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 = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jiff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +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.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[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", + "uuid", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[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.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "openat" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95aa7c05907b3ebde2610d602f4ddd992145cc6a84493647c30396f30ba83abe" +dependencies = [ + "libc", +] + +[[package]] +name = "openat-ext" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cf3e4baa7f516441f58373f58aaf6e91a5dfa2e2b50e68a0d313b082014c61d" +dependencies = [ + "libc", + "nix 0.23.2", + "openat", + "rand", +] + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.8.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", +] + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "os-release" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f29ae2f71b53ec19cc23385f8e4f3d90975195aa3d09171ba3bef7159bec27" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.8.0", + "errno", + "itoa", + "libc", + "linux-raw-sys 0.4.15", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.8.0", + "errno", + "libc", + "linux-raw-sys 0.9.3", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix 1.0.8", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tokio" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +dependencies = [ + "getrandom 0.3.1", + "serde", +] + +[[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_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + +[[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-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.8.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.8.0", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/bootupd/Cargo.toml b/bootupd/Cargo.toml new file mode 100755 index 0000000..0fca060 --- /dev/null +++ b/bootupd/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "bootupd" +description = "Bootloader updater" +license = "Apache-2.0" +version = "0.2.28" +authors = ["Colin Walters "] +edition = "2021" +rust-version = "1.84.1" +homepage = "https://github.com/coreos/bootupd" + +include = ["src", "LICENSE", "Makefile", "systemd"] + +# See https://github.com/coreos/cargo-vendor-filterer +[package.metadata.vendor-filter] +platforms = ["*-unknown-linux-gnu"] +tier = "2" + +[[bin]] +name = "bootupd" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +bincode = "1.3.2" +bootc-internal-blockdev = "0.0.0" +bootc-internal-utils = "0.0.0" +cap-std-ext = "4.0.6" +camino = "1.1.10" +chrono = { version = "0.4.41", features = ["serde"] } +clap = { version = "4.5", default-features = false, features = ["cargo", "derive", "std", "help", "usage", "suggestions"] } +env_logger = "0.11" +fail = { version = "0.5", features = ["failpoints"] } +fn-error-context = "0.2.1" +fs2 = "0.4.3" +hex = "0.4.3" +libc = "^0.2" +libsystemd = ">= 0.3, < 0.8" +log = "^0.4" +openat = "0.1.20" +openat-ext = ">= 0.2.2, < 0.3.0" +openssl = "^0.10" +os-release = "0.1.0" +regex = "1.11.1" +rustix = { version = "1.0.8", features = ["process", "fs"] } +serde = { version = "^1.0", features = ["derive"] } +serde_json = "^1.0" +tempfile = "^3.20" +widestring = "1.2.0" +walkdir = "2.3.2" +signal-hook-registry = "1.4.5" + +[profile.release] +# We assume we're being delivered via e.g. RPM which supports split debuginfo +debug = true + +[package.metadata.release] +disable-publish = true +disable-push = true +post-release-commit-message = "cargo: development version bump" +pre-release-commit-message = "cargo: bootupd release {{version}}" +sign-commit = true +sign-tag = true +tag-message = "bootupd {{version}}" diff --git a/bootupd/Dockerfile b/bootupd/Dockerfile new file mode 100755 index 0000000..4548c6f --- /dev/null +++ b/bootupd/Dockerfile @@ -0,0 +1,28 @@ +# Build from the current git into a c9s-bootc container image. +# Use e.g. --build-arg=base=quay.io/fedora/fedora-bootc:41 to target +# Fedora or another base image instead. +# +ARG base=quay.io/centos-bootc/centos-bootc:stream9 + +FROM $base as build +# This installs our package dependencies, 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 < /cosa/component-install/usr/bin/foo + +FROM quay.io/coreos-assembler/coreos-assembler:latest +WORKDIR /srv +# Install our built binaries as overrides for the target build +COPY --from=builder /cosa/component-install/ /srv/overrides/rootfs/ +# Copy and install tests too +COPY --from=builder /cosa/component-tests /srv/tmp/component-tests +# And fix permissions +RUN sudo chown -R builder: /srv/* +# Install tests +USER root +RUN rsync -rlv /srv/tmp/component-tests/ / && rm -rf /srv/tmp/component-tests +USER builder +COPY --from=builder /src/ci/prow/fcos-e2e.sh /usr/bin/fcos-e2e diff --git a/bootupd/ci/prow/fcos-e2e.sh b/bootupd/ci/prow/fcos-e2e.sh new file mode 100755 index 0000000..8361846 --- /dev/null +++ b/bootupd/ci/prow/fcos-e2e.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -xeuo pipefail + +# Prow jobs don't support adding emptydir today +export COSA_SKIP_OVERLAY=1 +cosa init --force https://github.com/coreos/fedora-coreos-config/ +cosa fetch +cosa build +cosa kola run --qemu-firmware uefi 'ext.bootupd.*' diff --git a/bootupd/code-of-conduct.md b/bootupd/code-of-conduct.md new file mode 100755 index 0000000..a234f36 --- /dev/null +++ b/bootupd/code-of-conduct.md @@ -0,0 +1,61 @@ +## CoreOS Community Code of Conduct + +### Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing others' private information, such as physical or electronic addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct. By adopting this Code of Conduct, +project maintainers commit themselves to fairly and consistently applying these +principles to every aspect of managing this project. Project maintainers who do +not follow or enforce the Code of Conduct may be permanently removed from the +project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting a project maintainer, Brandon Philips +, and/or Rithu John . + +This Code of Conduct is adapted from the Contributor Covenant +(http://contributor-covenant.org), version 1.2.0, available at +http://contributor-covenant.org/version/1/2/0/ + +### CoreOS Events Code of Conduct + +CoreOS events are working conferences intended for professional networking and +collaboration in the CoreOS community. Attendees are expected to behave +according to professional standards and in accordance with their employer’s +policies on appropriate workplace behavior. + +While at CoreOS events or related social networking opportunities, attendees +should not engage in discriminatory or offensive speech or actions including +but not limited to gender, sexuality, race, age, disability, or religion. +Speakers should be especially aware of these concerns. + +CoreOS does not condone any statements by speakers contrary to these standards. +CoreOS reserves the right to deny entrance and/or eject from an event (without +refund) any individual found to be engaging in discriminatory or offensive +speech or actions. + +Please bring any concerns to the immediate attention of designated on-site +staff, Brandon Philips , and/or Rithu John . diff --git a/bootupd/contrib/packaging/bootupd.spec b/bootupd/contrib/packaging/bootupd.spec new file mode 100755 index 0000000..403af7b --- /dev/null +++ b/bootupd/contrib/packaging/bootupd.spec @@ -0,0 +1,82 @@ +%bcond_without check + +%global crate bootupd + +Name: rust-%{crate} +Version: 0.2.9 +Release: 1%{?dist} +Summary: Bootloader updater + +License: Apache-2.0 +URL: https://github.com/coreos/bootupd +Source0: %{url}/releases/download/v%{version}/bootupd-%{version}.tar.zstd +Source1: %{url}/releases/download/v%{version}/bootupd-%{version}-vendor.tar.zstd +%if 0%{?fedora} || 0%{?rhel} >= 10 +ExcludeArch: %{ix86} +%endif + +BuildRequires: git-core +# For now, see upstream +BuildRequires: make +BuildRequires: openssl-devel +%if 0%{?rhel} +BuildRequires: rust-toolset +%else +BuildRequires: cargo-rpm-macros >= 25 +%endif +BuildRequires: systemd + +%global _description %{expand: +Bootloader updater} +%description %{_description} + +%package -n %{crate} +Summary: %{summary} +# Apache-2.0 +# Apache-2.0 OR BSL-1.0 +# Apache-2.0 OR MIT +# Apache-2.0 WITH LLVM-exception +# 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 (Apache-2.0 WITH LLVM-exception) 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) +%{?systemd_requires} + +%description -n %{crate} %{_description} + +%files -n %{crate} +%license LICENSE +%license LICENSE.dependencies +%license cargo-vendor.txt +%doc README.md +%{_bindir}/bootupctl +%{_libexecdir}/bootupd +%{_prefix}/lib/bootupd/grub2-static/ +%{_unitdir}/bootloader-update.service + +%prep +%autosetup -n %{crate}-%{version} -p1 -Sgit -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 + +%build +%cargo_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 + +%install +%make_install INSTALL="install -p -c" +%{__make} install-grub-static DESTDIR=%{?buildroot} INSTALL="%{__install} -p" +%{__make} install-systemd-unit DESTDIR=%{?buildroot} INSTALL="%{__install} -p" + +%changelog +* Tue Oct 18 2022 Colin Walters - 0.2.8-3 +- Dummy changelog diff --git a/bootupd/doc/dependency_decisions.yml b/bootupd/doc/dependency_decisions.yml new file mode 100755 index 0000000..7da6010 --- /dev/null +++ b/bootupd/doc/dependency_decisions.yml @@ -0,0 +1,37 @@ +--- +- - :permit + - MIT OR Apache-2.0 + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:31:28.263225624 Z +- - :permit + - Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:31:42.436851761 Z +- - :permit + - MIT + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:31:54.278056841 Z +- - :permit + - Apache 2.0 + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:32:08.538863728 Z +- - :permit + - Apache-2.0 OR BSL-1.0 + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:32:17.034417362 Z +- - :permit + - New BSD + - :who: + :why: + :versions: [] + :when: 2021-02-03 19:33:02.120977990 Z diff --git a/bootupd/src/backend/mod.rs b/bootupd/src/backend/mod.rs new file mode 100755 index 0000000..df7c637 --- /dev/null +++ b/bootupd/src/backend/mod.rs @@ -0,0 +1,3 @@ +//! Internal logic for bootloader and system state manipulation. + +mod statefile; diff --git a/bootupd/src/backend/statefile.rs b/bootupd/src/backend/statefile.rs new file mode 100755 index 0000000..83ffd6b --- /dev/null +++ b/bootupd/src/backend/statefile.rs @@ -0,0 +1,112 @@ +//! On-disk saved state. + +use crate::model::SavedState; +use crate::util::SignalTerminationGuard; +use anyhow::{bail, Context, Result}; +use fn_error_context::context; +use fs2::FileExt; +use openat_ext::OpenatDirExt; +use std::fs::File; +use std::io::prelude::*; +use std::path::Path; + +impl SavedState { + /// System-wide bootupd write lock (relative to sysroot). + const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock"; + /// Top-level directory for statefile (relative to sysroot). + pub(crate) const STATEFILE_DIR: &'static str = "boot"; + /// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, stored in `/boot`. + pub(crate) const STATEFILE_NAME: &'static str = "bootupd-state.json"; + + /// Try to acquire a system-wide lock to ensure non-conflicting state updates. + /// + /// While ordinarily the daemon runs as a systemd unit (which implicitly + /// ensures a single instance) this is a double check against other + /// execution paths. + pub(crate) fn acquire_write_lock(sysroot: openat::Dir) -> Result { + let lockfile = sysroot.write_file(Self::WRITE_LOCK_PATH, 0o644)?; + lockfile.lock_exclusive()?; + let guard = StateLockGuard { + sysroot, + termguard: Some(SignalTerminationGuard::new()?), + lockfile: Some(lockfile), + }; + Ok(guard) + } + + /// Use this for cases when the target root isn't booted, which is + /// offline installs. + pub(crate) fn unlocked(sysroot: openat::Dir) -> Result { + Ok(StateLockGuard { + sysroot, + termguard: None, + lockfile: None, + }) + } + + /// Load the JSON file containing on-disk state. + #[context("Loading saved state")] + pub(crate) fn load_from_disk(root_path: impl AsRef) -> Result> { + let root_path = root_path.as_ref(); + let sysroot = openat::Dir::open(root_path) + .with_context(|| format!("opening sysroot '{}'", root_path.display()))?; + + let statefile_path = Path::new(Self::STATEFILE_DIR).join(Self::STATEFILE_NAME); + let saved_state = if let Some(statusf) = sysroot.open_file_optional(&statefile_path)? { + let mut bufr = std::io::BufReader::new(statusf); + let mut s = String::new(); + bufr.read_to_string(&mut s)?; + let state: serde_json::Result = serde_json::from_str(s.as_str()); + let r = match state { + Ok(s) => s, + Err(orig_err) => { + let state: serde_json::Result = + serde_json::from_str(s.as_str()); + match state { + Ok(s) => s.upconvert(), + Err(_) => { + return Err(orig_err.into()); + } + } + } + }; + Some(r) + } else { + None + }; + Ok(saved_state) + } + + /// Check whether statefile exists. + pub(crate) fn ensure_not_present(root_path: impl AsRef) -> Result<()> { + let statepath = Path::new(root_path.as_ref()) + .join(Self::STATEFILE_DIR) + .join(Self::STATEFILE_NAME); + if statepath.exists() { + bail!("{} already exists", statepath.display()); + } + Ok(()) + } +} + +/// Write-lock guard for statefile, protecting against concurrent state updates. +#[derive(Debug)] +pub(crate) struct StateLockGuard { + pub(crate) sysroot: openat::Dir, + #[allow(dead_code)] + termguard: Option, + #[allow(dead_code)] + lockfile: Option, +} + +impl StateLockGuard { + /// Atomically replace the on-disk state with a new version. + pub(crate) fn update_state(&mut self, state: &SavedState) -> Result<()> { + let subdir = self.sysroot.sub_dir(SavedState::STATEFILE_DIR)?; + subdir.write_file_with_sync(SavedState::STATEFILE_NAME, 0o644, |w| -> Result<()> { + serde_json::to_writer(w, state)?; + Ok(()) + })?; + Ok(()) + } +} diff --git a/bootupd/src/bios.rs b/bootupd/src/bios.rs new file mode 100755 index 0000000..4b34c20 --- /dev/null +++ b/bootupd/src/bios.rs @@ -0,0 +1,271 @@ +use anyhow::{bail, Context, Result}; +use camino::Utf8PathBuf; +use openat_ext::OpenatDirExt; +#[cfg(target_arch = "powerpc64")] +use std::borrow::Cow; +use std::io::prelude::*; +use std::path::Path; +use std::process::Command; + +use crate::blockdev; +use crate::bootupd::RootContext; +use crate::component::*; +use crate::freezethaw::fsfreeze_thaw_cycle; +use crate::grubconfigs; +use crate::model::*; +use crate::packagesystem; + +// grub2-install file path +pub(crate) const GRUB_BIN: &str = "usr/sbin/grub2-install"; + +#[cfg(target_arch = "powerpc64")] +fn target_device(device: &str) -> Result> { + const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; + /// We make a best-effort to support MBR partitioning too. + const PREPBOOT_MBR_TYPE: &str = "41"; + + // Here we use lsblk to see if the device has any partitions at all + let dev = bootc_internal_blockdev::list_dev(device.into())?; + if dev.children.is_none() { + return Ok(device.into()); + }; + // If it does, directly call `sfdisk` and bypass lsblk because inside a container + // we may not have all the cached udev state (that I think is in /run). + let device = bootc_internal_blockdev::partitions_of(device.into())?; + let prepdev = device + .partitions + .iter() + .find(|p| matches!(p.parttype.as_str(), PREPBOOT_GUID | PREPBOOT_MBR_TYPE)) + .ok_or_else(|| { + anyhow::anyhow!("Failed to find PReP partition with GUID {PREPBOOT_GUID}") + })?; + Ok(prepdev.path().as_str().to_owned().into()) +} + +#[derive(Default)] +pub(crate) struct Bios {} + +impl Bios { + // Return `true` if grub2-modules installed + fn check_grub_modules(&self) -> Result { + let usr_path = Path::new("/usr/lib/grub"); + #[cfg(target_arch = "x86_64")] + { + usr_path.join("i386-pc").try_exists().map_err(Into::into) + } + #[cfg(target_arch = "powerpc64")] + { + usr_path + .join("powerpc-ieee1275") + .try_exists() + .map_err(Into::into) + } + } + + // Run grub2-install + fn run_grub_install(&self, dest_root: &str, device: &str) -> Result<()> { + if !self.check_grub_modules()? { + bail!("Failed to find grub2-modules"); + } + let grub_install = Path::new("/").join(GRUB_BIN); + if !grub_install.exists() { + bail!("Failed to find {:?}", grub_install); + } + + let mut cmd = Command::new(grub_install); + let boot_dir = Path::new(dest_root).join("boot"); + // We forcibly inject mdraid1x because it's needed by CoreOS's default of "install raw disk image" + // We also add part_gpt because in some cases probing of the partition map can fail such + // as in a container, but we always use GPT. + #[cfg(target_arch = "x86_64")] + cmd.args(["--target", "i386-pc"]) + .args(["--boot-directory", boot_dir.to_str().unwrap()]) + .args(["--modules", "mdraid1x part_gpt"]) + .arg(device); + + #[cfg(target_arch = "powerpc64")] + { + let device = target_device(device)?; + cmd.args(&["--target", "powerpc-ieee1275"]) + .args(&["--boot-directory", boot_dir.to_str().unwrap()]) + .arg("--no-nvram") + .arg(&*device); + } + + let cmdout = cmd.output()?; + if !cmdout.status.success() { + std::io::stderr().write_all(&cmdout.stderr)?; + bail!("Failed to run {:?}", cmd); + } + Ok(()) + } +} + +impl Component for Bios { + fn name(&self) -> &'static str { + "BIOS" + } + + fn install( + &self, + src_root: &openat::Dir, + dest_root: &str, + device: &str, + _update_firmware: bool, + ) -> Result { + let Some(meta) = get_component_update(src_root, self)? else { + anyhow::bail!("No update metadata for component {} found", self.name()); + }; + + self.run_grub_install(dest_root, device)?; + Ok(InstalledContent { + meta, + filetree: None, + adopted_from: None, + }) + } + + fn generate_update_metadata(&self, sysroot_path: &str) -> Result { + let grub_install = Path::new(sysroot_path).join(GRUB_BIN); + if !grub_install.exists() { + bail!("Failed to find {:?}", grub_install); + } + + // Query the rpm database and list the package and build times for /usr/sbin/grub2-install + let meta = packagesystem::query_files(sysroot_path, [&grub_install])?; + write_update_metadata(sysroot_path, self, &meta)?; + Ok(meta) + } + + fn query_adopt(&self, devices: &Option>) -> Result> { + #[cfg(target_arch = "x86_64")] + if crate::efi::is_efi_booted()? && devices.is_none() { + log::debug!("Skip BIOS adopt"); + return Ok(None); + } + crate::component::query_adopt_state() + } + + // Backup the current grub.cfg and replace with new static config + // - Backup "/boot/loader/grub.cfg" to "/boot/grub2/grub.cfg.bak" + // - Remove symlink "/boot/grub2/grub.cfg" + // - Replace "/boot/grub2/grub.cfg" symlink with new static "grub.cfg" + fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()> { + let grub = "boot/grub2"; + // sysroot_path is /, destdir is Dir of / + let grub_config_path = Utf8PathBuf::from(sysroot_path).join(grub); + let grub_config_dir = destdir.sub_dir(grub).context("Opening boot/grub2")?; + + let grub_config = grub_config_path.join(grubconfigs::GRUBCONFIG); + + if !grub_config.exists() { + anyhow::bail!("Could not find '{}'", grub_config); + } + + let mut current_config; + // If /boot/grub2/grub.cfg is not symlink, we need to keep going + if !grub_config.is_symlink() { + println!("'{}' is not a symlink", grub_config); + current_config = grub_config.clone(); + } else { + // If /boot/grub2/grub.cfg is symlink to /boot/loader/grub.cfg, + // backup it to /boot/grub2/grub.cfg.bak + // Get real file for symlink /boot/grub2/grub.cfg + let real_config = grub_config_dir.read_link(grubconfigs::GRUBCONFIG)?; + let real_config = + Utf8PathBuf::from_path_buf(real_config).expect("Path should be valid UTF-8"); + // Resolve symlink location + current_config = grub_config_path.clone(); + current_config.push(real_config); + } + + let backup_config = grub_config_path.join(grubconfigs::GRUBCONFIG_BACKUP); + if !backup_config.exists() { + // Backup the current GRUB config which is hopefully working right now + println!( + "Creating a backup of the current GRUB config '{}' in '{}'...", + current_config, backup_config + ); + std::fs::copy(¤t_config, &backup_config) + .context("Failed to backup GRUB config")?; + } + + crate::grubconfigs::install(&destdir, None, true)?; + + // Remove the real config if it is symlink and will not + // if /boot/grub2/grub.cfg is file + if current_config != grub_config { + println!("Removing {}", current_config); + grub_config_dir.remove_file_optional(current_config.as_std_path())?; + } + + // Synchronize the filesystem containing /boot/grub2 to disk. + fsfreeze_thaw_cycle(grub_config_dir.open_file(".")?)?; + + Ok(()) + } + + fn adopt_update( + &self, + rootcxt: &RootContext, + update: &ContentMetadata, + with_static_config: bool, + ) -> Result> { + let bios_devices = blockdev::find_colocated_bios_boot(&rootcxt.devices)?; + let Some(meta) = self.query_adopt(&bios_devices)? else { + return Ok(None); + }; + + for parent in rootcxt.devices.iter() { + self.run_grub_install(rootcxt.path.as_str(), &parent)?; + log::debug!("Installed grub modules on {parent}"); + } + + if with_static_config { + // Install the static config if the OSTree bootloader is not set. + if let Some(bootloader) = crate::ostreeutil::get_ostree_bootloader()? { + println!( + "ostree repo 'sysroot.bootloader' config option is currently set to: '{bootloader}'", + ); + } else { + println!("ostree repo 'sysroot.bootloader' config option is not set yet"); + self.migrate_static_grub_config(rootcxt.path.as_str(), &rootcxt.sysroot)?; + }; + } + Ok(Some(InstalledContent { + meta: update.clone(), + filetree: None, + adopted_from: Some(meta.version), + })) + } + + fn query_update(&self, sysroot: &openat::Dir) -> Result> { + get_component_update(sysroot, self) + } + + fn run_update(&self, rootcxt: &RootContext, _: &InstalledContent) -> Result { + let updatemeta = self + .query_update(&rootcxt.sysroot)? + .expect("update available"); + + for parent in rootcxt.devices.iter() { + self.run_grub_install(rootcxt.path.as_str(), &parent)?; + log::debug!("Installed grub modules on {parent}"); + } + + let adopted_from = None; + Ok(InstalledContent { + meta: updatemeta, + filetree: None, + adopted_from, + }) + } + + fn validate(&self, _: &InstalledContent) -> Result { + Ok(ValidationResult::Skip) + } + + fn get_efi_vendor(&self, _: &openat::Dir) -> Result> { + Ok(None) + } +} diff --git a/bootupd/src/blockdev.rs b/bootupd/src/blockdev.rs new file mode 100755 index 0000000..39921b3 --- /dev/null +++ b/bootupd/src/blockdev.rs @@ -0,0 +1,99 @@ +use camino::Utf8Path; +use std::path::Path; + +use anyhow::{Context, Result}; +use bootc_internal_blockdev::PartitionTable; +use fn_error_context::context; + +#[context("get parent devices from mount point boot or sysroot")] +pub fn get_devices>(target_root: P) -> Result> { + let target_root = target_root.as_ref(); + let mut source = None; + + for path in ["boot", "sysroot"] { + let target_path = target_root.join(path); + if !target_path.exists() { + continue; + } + + let target_dir = openat::Dir::open(&target_path) + .with_context(|| format!("Opening {}", target_path.display()))?; + if let Ok(fsinfo) = crate::filesystem::inspect_filesystem(&target_dir, ".") { + source = Some(fsinfo.source); + break; + } + } + + let source = match source { + Some(s) => s, + None => anyhow::bail!("Failed to inspect filesystem from boot or sysroot"), + }; + + // Find the parent devices of the source path + let parent_devices = bootc_internal_blockdev::find_parent_devices(&source) + .with_context(|| format!("While looking for backing devices of {}", source))?; + log::debug!("Found parent devices: {parent_devices:?}"); + Ok(parent_devices) +} + +/// Find esp partition on the same device +/// using sfdisk to get partitiontable +pub fn get_esp_partition(device: &str) -> Result> { + const ESP_TYPE_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; + let device_info: PartitionTable = + bootc_internal_blockdev::partitions_of(Utf8Path::new(device))?; + let esp = device_info + .partitions + .into_iter() + .find(|p| p.parttype.as_str() == ESP_TYPE_GUID); + if let Some(esp) = esp { + return Ok(Some(esp.node)); + } + Ok(None) +} + +/// Find all ESP partitions on the devices +pub fn find_colocated_esps(devices: &Vec) -> Result>> { + // look for all ESPs on those devices + let mut esps = Vec::new(); + for device in devices { + if let Some(esp) = get_esp_partition(&device)? { + esps.push(esp) + } + } + if esps.is_empty() { + return Ok(None); + } + log::debug!("Found esp partitions: {esps:?}"); + Ok(Some(esps)) +} + +/// Find bios_boot partition on the same device +pub fn get_bios_boot_partition(device: &str) -> Result> { + const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6E6F-744E-656564454649"; + let device_info = bootc_internal_blockdev::partitions_of(Utf8Path::new(device))?; + let bios_boot = device_info + .partitions + .into_iter() + .find(|p| p.parttype.as_str() == BIOS_BOOT_TYPE_GUID); + if let Some(bios_boot) = bios_boot { + return Ok(Some(bios_boot.node)); + } + Ok(None) +} + +/// Find all bios_boot partitions on the devices +pub fn find_colocated_bios_boot(devices: &Vec) -> Result>> { + // look for all bios_boot parts on those devices + let mut bios_boots = Vec::new(); + for device in devices { + if let Some(bios) = get_bios_boot_partition(&device)? { + bios_boots.push(bios) + } + } + if bios_boots.is_empty() { + return Ok(None); + } + log::debug!("Found bios_boot partitions: {bios_boots:?}"); + Ok(Some(bios_boots)) +} diff --git a/bootupd/src/bootupd.rs b/bootupd/src/bootupd.rs new file mode 100755 index 0000000..1969b51 --- /dev/null +++ b/bootupd/src/bootupd.rs @@ -0,0 +1,772 @@ +#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] +use crate::bios; +use crate::component; +use crate::component::{Component, ValidationResult}; +use crate::coreos; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use crate::efi; +use crate::freezethaw::fsfreeze_thaw_cycle; +use crate::model::{ComponentStatus, ComponentUpdatable, ContentMetadata, SavedState, Status}; +use crate::{ostreeutil, util}; +use anyhow::{anyhow, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::crate_version; +use fn_error_context::context; +use libc::mode_t; +use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWUSR}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; + +pub(crate) enum ConfigMode { + None, + Static, + WithUUID, +} + +impl ConfigMode { + pub(crate) fn enabled_with_uuid(&self) -> Option { + match self { + ConfigMode::None => None, + ConfigMode::Static => Some(false), + ConfigMode::WithUUID => Some(true), + } + } +} + +pub(crate) fn install( + source_root: &str, + dest_root: &str, + device: Option<&str>, + configs: ConfigMode, + update_firmware: bool, + target_components: Option<&[String]>, + auto_components: bool, +) -> Result<()> { + // TODO: Change this to an Option<&str>; though this probably balloons into having + // DeviceComponent and FileBasedComponent + let device = device.unwrap_or(""); + let source_root = openat::Dir::open(source_root).context("Opening source root")?; + SavedState::ensure_not_present(dest_root) + .context("failed to install, invalid re-install attempted")?; + + let all_components = get_components_impl(auto_components); + if all_components.is_empty() { + println!("No components available for this platform."); + return Ok(()); + } + let target_components = if let Some(target_components) = target_components { + // Checked by CLI parser + assert!(!auto_components); + target_components + .iter() + .map(|name| { + all_components + .get(name.as_str()) + .ok_or_else(|| anyhow!("Unknown component: {name}")) + }) + .collect::>>()? + } else { + all_components.values().collect() + }; + + if target_components.is_empty() && !auto_components { + anyhow::bail!("No components specified"); + } + + let mut state = SavedState::default(); + let mut installed_efi_vendor = None; + for &component in target_components.iter() { + // skip for BIOS if device is empty + if component.name() == "BIOS" && device.is_empty() { + println!( + "Skip installing component {} without target device", + component.name() + ); + continue; + } + + let meta = component + .install(&source_root, dest_root, device, update_firmware) + .with_context(|| format!("installing component {}", component.name()))?; + log::info!("Installed {} {}", component.name(), meta.meta.version); + state.installed.insert(component.name().into(), meta); + // Yes this is a hack...the Component thing just turns out to be too generic. + if let Some(vendor) = component.get_efi_vendor(&source_root)? { + assert!(installed_efi_vendor.is_none()); + installed_efi_vendor = Some(vendor); + } + } + let sysroot = &openat::Dir::open(dest_root)?; + + match configs.enabled_with_uuid() { + Some(uuid) => { + let meta = get_static_config_meta()?; + state.static_configs = Some(meta); + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64", + target_arch = "riscv64" + ))] + crate::grubconfigs::install(sysroot, installed_efi_vendor.as_deref(), uuid)?; + // On other architectures, assume that there's nothing to do. + } + None => {} + } + + // Unmount the ESP, etc. + drop(target_components); + + let mut state_guard = + SavedState::unlocked(sysroot.try_clone()?).context("failed to acquire write lock")?; + state_guard + .update_state(&state) + .context("failed to update state")?; + + Ok(()) +} + +#[context("Get static config metadata")] +fn get_static_config_meta() -> Result { + let self_bin_meta = std::fs::metadata("/proc/self/exe").context("Querying self meta")?; + let self_meta = ContentMetadata { + timestamp: self_bin_meta.modified()?.into(), + version: crate_version!().into(), + }; + Ok(self_meta) +} + +type Components = BTreeMap<&'static str, Box>; + +#[allow(clippy::box_default)] +/// Return the set of known components; if `auto` is specified then the system +/// filters to the target booted state. +pub(crate) fn get_components_impl(auto: bool) -> Components { + let mut components = BTreeMap::new(); + + fn insert_component(components: &mut Components, component: Box) { + components.insert(component.name(), component); + } + + #[cfg(target_arch = "x86_64")] + { + if auto { + let is_efi_booted = crate::efi::is_efi_booted().unwrap(); + log::info!( + "System boot method: {}", + if is_efi_booted { "EFI" } else { "BIOS" } + ); + if is_efi_booted { + insert_component(&mut components, Box::new(efi::Efi::default())); + } else { + insert_component(&mut components, Box::new(bios::Bios::default())); + } + } else { + insert_component(&mut components, Box::new(bios::Bios::default())); + insert_component(&mut components, Box::new(efi::Efi::default())); + } + } + #[cfg(any(target_arch = "aarch64", target_arch = "riscv64"))] + insert_component(&mut components, Box::new(efi::Efi::default())); + + #[cfg(target_arch = "powerpc64")] + insert_component(&mut components, Box::new(bios::Bios::default())); + + components +} + +pub(crate) fn get_components() -> Components { + get_components_impl(false) +} + +pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> { + // create bootupd update dir which will save component metadata files for both components + let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR); + std::fs::create_dir_all(&updates_dir) + .with_context(|| format!("Failed to create updates dir {:?}", &updates_dir))?; + for component in get_components().values() { + let v = component.generate_update_metadata(sysroot_path)?; + println!( + "Generated update layout for {}: {}", + component.name(), + v.version, + ); + } + + Ok(()) +} + +/// Return value from daemon → client for component update +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ComponentUpdateResult { + AtLatestVersion, + Updated { + previous: ContentMetadata, + interrupted: Option, + new: ContentMetadata, + }, +} + +fn ensure_writable_boot() -> Result<()> { + util::ensure_writable_mount("/boot") +} + +/// daemon implementation of component update +pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result { + let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let component = component::new_from_name(name)?; + let inst = if let Some(inst) = state.installed.get(name) { + inst.clone() + } else { + anyhow::bail!("Component {} is not installed", name); + }; + let sysroot = &rootcxt.sysroot; + let update = component.query_update(sysroot)?; + let update = match update.as_ref() { + Some(p) if inst.meta.can_upgrade_to(p) => p, + _ => return Ok(ComponentUpdateResult::AtLatestVersion), + }; + + ensure_writable_boot()?; + + let mut pending_container = state.pending.take().unwrap_or_default(); + let interrupted = pending_container.get(component.name()).cloned(); + pending_container.insert(component.name().into(), update.clone()); + let sysroot = sysroot.try_clone()?; + let mut state_guard = + SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; + state_guard + .update_state(&state) + .context("Failed to update state")?; + + let newinst = component + .run_update(rootcxt, &inst) + .with_context(|| format!("Failed to update {}", component.name()))?; + state.installed.insert(component.name().into(), newinst); + pending_container.remove(component.name()); + state_guard.update_state(&state)?; + + Ok(ComponentUpdateResult::Updated { + previous: inst.meta, + interrupted, + new: update.clone(), + }) +} + +/// daemon implementation of component adoption +pub(crate) fn adopt_and_update( + name: &str, + rootcxt: &RootContext, + with_static_config: bool, +) -> Result> { + let sysroot = &rootcxt.sysroot; + let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let component = component::new_from_name(name)?; + if state.installed.contains_key(name) { + anyhow::bail!("Component {} is already installed", name); + }; + + ensure_writable_boot()?; + + let Some(update) = component.query_update(sysroot)? else { + anyhow::bail!("Component {} has no available update", name); + }; + + let sysroot = sysroot.try_clone()?; + let mut state_guard = + SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; + + let inst = component + .adopt_update(&rootcxt, &update, with_static_config) + .context("Failed adopt and update")?; + if let Some(inst) = inst { + state.installed.insert(component.name().into(), inst); + // Set static_configs metadata and save + if with_static_config && state.static_configs.is_none() { + let meta = get_static_config_meta()?; + state.static_configs = Some(meta); + // Set bootloader to none + ostreeutil::set_ostree_bootloader("none")?; + + println!("Static GRUB configuration has been adopted successfully."); + } + state_guard.update_state(&state)?; + return Ok(Some(update)); + } else { + // Nothing adopted, skip + log::info!("Component '{}' skipped adoption", component.name()); + return Ok(None); + } +} + +/// daemon implementation of component validate +pub(crate) fn validate(name: &str) -> Result { + let state = SavedState::load_from_disk("/")?.unwrap_or_default(); + let component = component::new_from_name(name)?; + let Some(inst) = state.installed.get(name) else { + anyhow::bail!("Component {} is not installed", name); + }; + component.validate(inst) +} + +pub(crate) fn status() -> Result { + let mut ret: Status = Default::default(); + let mut known_components = get_components(); + let sysroot = openat::Dir::open("/")?; + let state = SavedState::load_from_disk("/")?; + if let Some(state) = state { + for (name, ic) in state.installed.iter() { + log::trace!("Gathering status for installed component: {}", name); + let component = known_components + .remove(name.as_str()) + .ok_or_else(|| anyhow!("Unknown component installed: {}", name))?; + let component = component.as_ref(); + let interrupted = state.pending.as_ref().and_then(|p| p.get(name.as_str())); + let update = component.query_update(&sysroot)?; + let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref()); + let adopted_from = ic.adopted_from.clone(); + ret.components.insert( + name.to_string(), + ComponentStatus { + installed: ic.meta.clone(), + interrupted: interrupted.cloned(), + update, + updatable, + adopted_from, + }, + ); + } + } else { + log::trace!("No saved state"); + } + + // Process the remaining components not installed + log::trace!("Remaining known components: {}", known_components.len()); + for (name, _) in known_components { + // To determine if not-installed components can be adopted: + // + // `query_adopt_state()` checks for existing installation state, + // such as a `version` in `/sysroot/.coreos-aleph-version.json`, + // or the presence of `/ostree/deploy`. + // + // `component.query_adopt()` performs additional checks, + // including hardware/device requirements. + // For example, it will skip BIOS adoption if the system is booted via EFI + // and lacks a BIOS_BOOT partition. + // + // Once a component is determined to be adoptable, it is added to the + // adoptable list, and adoption proceeds automatically. + // + // Therefore, calling `query_adopt_state()` alone is sufficient. + if let Some(adopt_ver) = crate::component::query_adopt_state()? { + ret.adoptable.insert(name.to_string(), adopt_ver); + } else { + log::trace!("Not adoptable: {}", name); + } + } + + Ok(ret) +} + +pub(crate) fn print_status_avail(status: &Status) -> Result<()> { + let mut avail = Vec::new(); + for (name, component) in status.components.iter() { + if let ComponentUpdatable::Upgradable = component.updatable { + avail.push(name.as_str()); + } + } + for (name, adoptable) in status.adoptable.iter() { + if adoptable.confident { + avail.push(name.as_str()); + } + } + if !avail.is_empty() { + println!("Updates available: {}", avail.join(" ")); + } + Ok(()) +} + +pub(crate) fn print_status(status: &Status) -> Result<()> { + if status.components.is_empty() { + println!("No components installed."); + } + for (name, component) in status.components.iter() { + println!("Component {}", name); + println!(" Installed: {}", component.installed.version); + + if let Some(i) = component.interrupted.as_ref() { + println!( + " WARNING: Previous update to {} was interrupted", + i.version + ); + } + let msg = match component.updatable { + ComponentUpdatable::NoUpdateAvailable => Cow::Borrowed("No update found"), + ComponentUpdatable::AtLatestVersion => Cow::Borrowed("At latest version"), + ComponentUpdatable::WouldDowngrade => Cow::Borrowed("Ignoring downgrade"), + ComponentUpdatable::Upgradable => Cow::Owned(format!( + "Available: {}", + component.update.as_ref().expect("update").version + )), + }; + println!(" Update: {}", msg); + } + + if status.adoptable.is_empty() { + println!("No components are adoptable."); + } + for (name, adopt) in status.adoptable.iter() { + let ver = &adopt.version.version; + if adopt.confident { + println!("Detected: {}: {}", name, ver); + } else { + println!("Adoptable: {}: {}", name, ver); + } + } + + if let Some(coreos_aleph) = coreos::get_aleph_version(Path::new("/"))? { + println!("CoreOS aleph version: {}", coreos_aleph.aleph.version); + } + + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + { + let boot_method = if efi::is_efi_booted()? { "EFI" } else { "BIOS" }; + println!("Boot method: {}", boot_method); + } + + Ok(()) +} + +pub struct RootContext { + pub sysroot: openat::Dir, + pub path: Utf8PathBuf, + pub devices: Vec, +} + +impl RootContext { + fn new(sysroot: openat::Dir, path: &str, devices: Vec) -> Self { + Self { + sysroot, + path: Utf8Path::new(path).into(), + devices, + } + } +} + +/// Initialize parent devices to prepare the update +fn prep_before_update() -> Result { + let path = "/"; + let sysroot = openat::Dir::open(path).context("Opening root dir")?; + let devices = crate::blockdev::get_devices(path).context("get parent devices")?; + Ok(RootContext::new(sysroot, path, devices)) +} + +pub(crate) fn client_run_update() -> Result<()> { + crate::try_fail_point!("update"); + let rootcxt = prep_before_update()?; + let status: Status = status()?; + if status.components.is_empty() && status.adoptable.is_empty() { + println!("No components installed."); + return Ok(()); + } + let mut updated = false; + for (name, cstatus) in status.components.iter() { + match cstatus.updatable { + ComponentUpdatable::Upgradable => {} + _ => continue, + }; + match update(name, &rootcxt)? { + ComponentUpdateResult::AtLatestVersion => { + // Shouldn't happen unless we raced with another client + eprintln!( + "warning: Expected update for {}, raced with a different client?", + name + ); + continue; + } + ComponentUpdateResult::Updated { + previous, + interrupted, + new, + } => { + if let Some(i) = interrupted { + eprintln!( + "warning: Continued from previous interrupted update: {}", + i.version, + ); + } + println!("Previous {}: {}", name, previous.version); + println!("Updated {}: {}", name, new.version); + } + } + updated = true; + } + for (name, adoptable) in status.adoptable.iter() { + if adoptable.confident { + if let Some(r) = adopt_and_update(name, &rootcxt, false)? { + println!("Adopted and updated: {}: {}", name, r.version); + updated = true; + } + } else { + println!("Component {} requires explicit adopt-and-update", name); + } + } + if !updated { + println!("No update available for any component."); + } + Ok(()) +} + +pub(crate) fn client_run_adopt_and_update(with_static_config: bool) -> Result<()> { + let rootcxt = prep_before_update()?; + let status: Status = status()?; + if status.adoptable.is_empty() { + println!("No components are adoptable."); + } else { + for (name, _) in status.adoptable.iter() { + if let Some(r) = adopt_and_update(name, &rootcxt, with_static_config)? { + println!("Adopted and updated: {}: {}", name, r.version); + } + } + } + Ok(()) +} + +pub(crate) fn client_run_validate() -> Result<()> { + let status: Status = status()?; + if status.components.is_empty() { + println!("No components installed."); + return Ok(()); + } + let mut caught_validation_error = false; + for (name, _) in status.components.iter() { + match validate(name)? { + ValidationResult::Valid => { + println!("Validated: {}", name); + } + ValidationResult::Skip => { + println!("Skipped: {}", name); + } + ValidationResult::Errors(errs) => { + for err in errs { + eprintln!("{}", err); + } + caught_validation_error = true; + } + } + } + if caught_validation_error { + anyhow::bail!("Caught validation errors"); + } + Ok(()) +} + +#[context("Migrating to a static GRUB config")] +pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> { + // Did we already complete the migration? + // We need to migrate if bootloader is not none (or not set) + if let Some(bootloader) = ostreeutil::get_ostree_bootloader()? { + if bootloader == "none" { + println!("Already using a static GRUB config"); + return Ok(()); + } + println!( + "ostree repo 'sysroot.bootloader' config option is currently set to: '{}'", + bootloader + ); + } else { + println!("ostree repo 'sysroot.bootloader' config option is not set yet"); + } + + // Remount /boot read write just for this unit (we are called in a slave mount namespace by systemd) + ensure_writable_boot()?; + + let grub_config_dir = PathBuf::from("/boot/grub2"); + let dirfd = openat::Dir::open(&grub_config_dir).context("Opening /boot/grub2")?; + + // We mark the bootloader as BLS capable to disable the ostree-grub2 logic. + // We can do that as we know that we are run after the bootloader has been + // updated and all recent GRUB2 versions support reading BLS configs. + // Ignore errors as this is not critical. This is a safety net if a user + // manually overwrites the (soon) static GRUB config by calling `grub2-mkconfig`. + // We need this until we can rely on ostree-grub2 being removed from the image. + println!("Marking bootloader as BLS capable..."); + _ = File::create("/boot/grub2/.grub2-blscfg-supported"); + + // Migrate /boot/grub2/grub.cfg to a static GRUB config if it is a symlink + let grub_config_filename = PathBuf::from("/boot/grub2/grub.cfg"); + match dirfd.read_link("grub.cfg") { + Err(_) => { + println!( + "'{}' is not a symlink, nothing to migrate", + grub_config_filename.display() + ); + } + Ok(path) => { + println!("Migrating to a static GRUB config..."); + + // Resolve symlink location + let mut current_config = grub_config_dir.clone(); + current_config.push(path); + + // Backup the current GRUB config which is hopefully working right now + let backup_config = PathBuf::from("/boot/grub2/grub.cfg.backup"); + println!( + "Creating a backup of the current GRUB config '{}' in '{}'...", + current_config.display(), + backup_config.display() + ); + fs::copy(¤t_config, &backup_config).context("Failed to backup GRUB config")?; + + // Read the current config, strip the ostree generated GRUB entries and + // write the result to a temporary file + println!("Stripping ostree generated entries from GRUB config..."); + let stripped_config = "grub.cfg.stripped"; + let current_config_file = + File::open(current_config).context("Could not open current GRUB config")?; + let content = BufReader::new(current_config_file); + + strip_grub_config_file(content, &dirfd, stripped_config)?; + + // Atomically replace the symlink + dirfd + .local_rename(stripped_config, "grub.cfg") + .context("Failed to replace symlink with current GRUB config")?; + + fsfreeze_thaw_cycle(dirfd.open_file(".")?)?; + + println!("GRUB config symlink successfully replaced with the current config"); + } + }; + + println!("Setting 'sysroot.bootloader' to 'none' in ostree repo config..."); + ostreeutil::set_ostree_bootloader("none")?; + + println!("Static GRUB config migration completed successfully"); + Ok(()) +} + +/// Writes a stripped GRUB config to `stripped_config_name`, removing lines between +/// `### BEGIN /etc/grub.d/15_ostree ###` and `### END /etc/grub.d/15_ostree ###`. +fn strip_grub_config_file( + current_config_content: impl BufRead, + dirfd: &openat::Dir, + stripped_config_name: &str, +) -> Result<()> { + // mode = -rw-r--r-- (644) + let mut writer = BufWriter::new( + dirfd + .write_file( + stripped_config_name, + (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) as mode_t, + ) + .context("Failed to open temporary GRUB config")?, + ); + + let mut skip = false; + for line in current_config_content.lines() { + let line = line.context("Failed to read line from GRUB config")?; + if line == "### END /etc/grub.d/15_ostree ###" { + skip = false; + continue; + } + if skip { + continue; + } + if line == "### BEGIN /etc/grub.d/15_ostree ###" { + skip = true; + continue; + } + writer + .write_all(line.as_bytes()) + .context("Failed to write stripped GRUB config")?; + writer + .write_all(b"\n") + .context("Failed to write stripped GRUB config")?; + } + + writer + .into_inner() + .context("Failed to flush stripped GRUB config")? + .sync_data() + .context("Failed to sync stripped GRUB config")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_failpoint_update() { + let guard = fail::FailScenario::setup(); + fail::cfg("update", "return").unwrap(); + let r = client_run_update(); + assert_eq!(r.is_err(), true); + guard.teardown(); + } + + #[test] + fn test_strip_grub_config_file() -> Result<()> { + let root: &tempfile::TempDir = &tempfile::tempdir()?; + let root_path = root.path(); + let rootd = openat::Dir::open(root_path)?; + let stripped_config = root_path.join("stripped"); + let content = r" +### BEGIN /etc/grub.d/10_linux ### + +### END /etc/grub.d/10_linux ### + +### BEGIN /etc/grub.d/15_ostree ### +menuentry 'Red Hat Enterprise Linux CoreOS 4 (ostree)' --class gnu-linux --class gnu --class os --unrestricted 'ostree-0-a92522f9-74dc-456a-ae0c-05ba22bca976' { +load_video +set gfxpayload=keep +insmod gzio +insmod part_gpt +insmod ext2 +if [ x$feature_platform_search_hint = xy ]; then + search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976 +else + search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976 +fi +linuxefi /ostree/rhcos-bf3b382/vmlinuz console=tty0 console=ttyS0,115200n8 rootflags=defaults,prjquota rw $ignition_firstboot root=UUID=cbac8cdc +initrdefi /ostree/rhcos-bf3b382/initramfs.img +} +### END /etc/grub.d/15_ostree ### + +### BEGIN /etc/grub.d/20_linux_xen ### +### END /etc/grub.d/20_linux_xen ###"; + + strip_grub_config_file( + BufReader::new(std::io::Cursor::new(content)), + &rootd, + stripped_config.to_str().unwrap(), + )?; + let stripped_content = fs::read_to_string(stripped_config)?; + let expected = r" +### BEGIN /etc/grub.d/10_linux ### + +### END /etc/grub.d/10_linux ### + + +### BEGIN /etc/grub.d/20_linux_xen ### +### END /etc/grub.d/20_linux_xen ### +"; + assert_eq!(expected, stripped_content); + Ok(()) + } +} diff --git a/bootupd/src/cli/bootupctl.rs b/bootupd/src/cli/bootupctl.rs new file mode 100755 index 0000000..4e831fe --- /dev/null +++ b/bootupd/src/cli/bootupctl.rs @@ -0,0 +1,222 @@ +use crate::bootupd; +use anyhow::Result; +use clap::Parser; +use log::LevelFilter; + +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; + +static SYSTEMD_ARGS_BOOTUPD: &[&str] = &["--unit", "bootupd", "--pipe"]; + +/// Keep these properties (isolation/runtime state) in sync with +/// the systemd units in contrib/packaging/*.service +static SYSTEMD_PROPERTIES: &[&str] = &[ + "PrivateNetwork=yes", + "ProtectHome=yes", + // While only our main process during update catches SIGTERM, we don't + // want systemd to send it to other processes. + "KillMode=mixed", + "MountFlags=slave", +]; + +/// `bootupctl` sub-commands. +#[derive(Debug, Parser)] +#[clap(name = "bootupctl", about = "Bootupd client application", version)] +pub struct CtlCommand { + /// Verbosity level (higher is more verbose). + #[clap(short = 'v', action = clap::ArgAction::Count, global = true)] + verbosity: u8, + + /// CLI sub-command. + #[clap(subcommand)] + pub cmd: CtlVerb, +} + +impl CtlCommand { + /// Return the log-level set via command-line flags. + pub(crate) fn loglevel(&self) -> LevelFilter { + match self.verbosity { + 0 => LevelFilter::Warn, + 1 => LevelFilter::Info, + 2 => LevelFilter::Debug, + _ => LevelFilter::Trace, + } + } +} + +/// CLI sub-commands. +#[derive(Debug, Parser)] +pub enum CtlVerb { + // FIXME(lucab): drop this after refreshing + // https://github.com/coreos/fedora-coreos-config/pull/595 + #[clap(name = "backend", hide = true, subcommand)] + Backend(CtlBackend), + #[clap(name = "status", about = "Show components status")] + Status(StatusOpts), + #[clap(name = "update", about = "Update all components")] + Update, + #[clap(name = "adopt-and-update", about = "Update all adoptable components")] + AdoptAndUpdate(AdoptAndUpdateOpts), + #[clap(name = "validate", about = "Validate system state")] + Validate, + #[clap( + name = "migrate-static-grub-config", + hide = true, + about = "Migrate a system to a static GRUB config" + )] + MigrateStaticGrubConfig, +} + +#[derive(Debug, Parser)] +pub enum CtlBackend { + #[clap(name = "generate-update-metadata", hide = true)] + Generate(super::bootupd::GenerateOpts), + #[clap(name = "install", hide = true)] + Install(super::bootupd::InstallOpts), +} + +#[derive(Debug, Parser)] +pub struct StatusOpts { + /// If there are updates available, output `Updates available: ` to standard output; + /// otherwise output nothing. Avoid parsing this, just check whether or not + /// the output is empty. + #[clap(long, action)] + print_if_available: bool, + + /// Output JSON + #[clap(long, action)] + json: bool, +} + +#[derive(Debug, Parser)] +pub struct AdoptAndUpdateOpts { + /// Install the static GRUB config files + #[clap(long, action)] + with_static_config: bool, +} + +impl CtlCommand { + /// Run CLI application. + pub fn run(self) -> Result<()> { + match self.cmd { + CtlVerb::Status(opts) => Self::run_status(opts), + CtlVerb::Update => Self::run_update(), + CtlVerb::AdoptAndUpdate(opts) => Self::run_adopt_and_update(opts), + CtlVerb::Validate => Self::run_validate(), + CtlVerb::Backend(CtlBackend::Generate(opts)) => { + super::bootupd::DCommand::run_generate_meta(opts) + } + CtlVerb::Backend(CtlBackend::Install(opts)) => { + super::bootupd::DCommand::run_install(opts) + } + CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(), + } + } + + /// Runner for `status` verb. + fn run_status(opts: StatusOpts) -> Result<()> { + if crate::util::running_in_container() { + return run_status_in_container(opts.json); + } + ensure_running_in_systemd()?; + let r = bootupd::status()?; + if opts.json { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + serde_json::to_writer_pretty(&mut stdout, &r)?; + } else if opts.print_if_available { + bootupd::print_status_avail(&r)?; + } else { + bootupd::print_status(&r)?; + } + + Ok(()) + } + + /// Runner for `update` verb. + fn run_update() -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_update() + } + + /// Runner for `update` verb. + fn run_adopt_and_update(opts: AdoptAndUpdateOpts) -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_adopt_and_update(opts.with_static_config) + } + + /// Runner for `validate` verb. + fn run_validate() -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_validate() + } + + /// Runner for `migrate-static-grub-config` verb. + fn run_migrate_static_grub_config() -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_migrate_static_grub_config() + } +} + +/// Checks if the current process is (apparently at least) +/// running under systemd. +fn running_in_systemd() -> bool { + std::env::var_os("INVOCATION_ID").is_some() +} + +/// Require root permission +fn require_root_permission() -> Result<()> { + if !rustix::process::getuid().is_root() { + anyhow::bail!("This command requires root privileges") + } + Ok(()) +} + +/// Detect if we're running in systemd; if we're not, we re-exec ourselves via +/// systemd-run. Then we can just directly run code in what is now the daemon. +fn ensure_running_in_systemd() -> Result<()> { + require_root_permission()?; + let running_in_systemd = running_in_systemd(); + if !running_in_systemd { + // Clear any failure status that may have happened previously + let _r = Command::new("systemctl") + .arg("reset-failed") + .arg("bootupd.service") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()? + .wait()?; + let r = Command::new("systemd-run") + .args(SYSTEMD_ARGS_BOOTUPD) + .args( + SYSTEMD_PROPERTIES + .into_iter() + .flat_map(|&v| ["--property", v]), + ) + .args(std::env::args()) + .exec(); + // If we got here, it's always an error + return Err(r.into()); + } + Ok(()) +} + +/// If running in container, just print the available payloads +fn run_status_in_container(json_format: bool) -> Result<()> { + let all_components = crate::bootupd::get_components(); + if all_components.is_empty() { + return Ok(()); + } + let avail: Vec<_> = all_components.keys().cloned().collect(); + if json_format { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + let output: serde_json::Value = serde_json::json!({ + "components": avail + }); + serde_json::to_writer(&mut stdout, &output)?; + } else { + println!("Available components: {}", avail.join(" ")); + } + Ok(()) +} diff --git a/bootupd/src/cli/bootupd.rs b/bootupd/src/cli/bootupd.rs new file mode 100755 index 0000000..4a6b8cf --- /dev/null +++ b/bootupd/src/cli/bootupd.rs @@ -0,0 +1,125 @@ +use crate::bootupd::{self, ConfigMode}; +use anyhow::{Context, Result}; +use clap::Parser; +use log::LevelFilter; + +/// `bootupd` sub-commands. +#[derive(Debug, Parser)] +#[clap(name = "bootupd", about = "Bootupd backend commands", version)] +pub struct DCommand { + /// Verbosity level (higher is more verbose). + #[clap(short = 'v', action = clap::ArgAction::Count, global = true)] + verbosity: u8, + + /// CLI sub-command. + #[clap(subcommand)] + pub cmd: DVerb, +} + +impl DCommand { + /// Return the log-level set via command-line flags. + pub(crate) fn loglevel(&self) -> LevelFilter { + match self.verbosity { + 0 => LevelFilter::Warn, + 1 => LevelFilter::Info, + 2 => LevelFilter::Debug, + _ => LevelFilter::Trace, + } + } +} + +/// CLI sub-commands. +#[derive(Debug, Parser)] +pub enum DVerb { + #[clap(name = "generate-update-metadata", about = "Generate metadata")] + GenerateUpdateMetadata(GenerateOpts), + #[clap(name = "install", about = "Install components")] + Install(InstallOpts), +} + +#[derive(Debug, Parser)] +pub struct InstallOpts { + /// Source root + #[clap(long, value_parser, default_value_t = String::from("/"))] + src_root: String, + /// Target root + #[clap(value_parser)] + dest_root: String, + + /// Target device, used by bios bootloader installation + #[clap(long)] + device: Option, + + /// Enable installation of the built-in static config files + #[clap(long)] + with_static_configs: bool, + + /// Implies `--with-static-configs`. When present, this also writes a + /// file with the UUID of the target filesystems. + #[clap(long)] + write_uuid: bool, + + /// On EFI systems, invoke `efibootmgr` to update the firmware. + #[clap(long)] + update_firmware: bool, + + #[clap(long = "component", conflicts_with = "auto")] + /// Only install these components + components: Option>, + + /// Automatically choose components based on booted host state. + /// + /// For example on x86_64, if the host system is booted via EFI, + /// then only enable installation to the ESP. + #[clap(long)] + auto: bool, +} + +#[derive(Debug, Parser)] +pub struct GenerateOpts { + /// Physical root mountpoint + #[clap(value_parser)] + sysroot: Option, +} + +impl DCommand { + /// Run CLI application. + pub fn run(self) -> Result<()> { + match self.cmd { + DVerb::Install(opts) => Self::run_install(opts), + DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), + } + } + + /// Runner for `generate-install-metadata` verb. + pub(crate) fn run_generate_meta(opts: GenerateOpts) -> Result<()> { + let sysroot = opts.sysroot.as_deref().unwrap_or("/"); + if sysroot != "/" { + anyhow::bail!("Using a non-default sysroot is not supported: {}", sysroot); + } + bootupd::generate_update_metadata(sysroot).context("generating metadata failed")?; + Ok(()) + } + + /// Runner for `install` verb. + pub(crate) fn run_install(opts: InstallOpts) -> Result<()> { + let configmode = if opts.write_uuid { + ConfigMode::WithUUID + } else if opts.with_static_configs { + ConfigMode::Static + } else { + ConfigMode::None + }; + bootupd::install( + &opts.src_root, + &opts.dest_root, + opts.device.as_deref(), + configmode, + opts.update_firmware, + opts.components.as_deref(), + opts.auto, + ) + .context("boot data installation failed")?; + Ok(()) + } +} diff --git a/bootupd/src/cli/mod.rs b/bootupd/src/cli/mod.rs new file mode 100755 index 0000000..285e1ea --- /dev/null +++ b/bootupd/src/cli/mod.rs @@ -0,0 +1,107 @@ +//! Command-line interface (CLI) logic. + +use anyhow::Result; +use clap::Parser; +use log::LevelFilter; +mod bootupctl; +mod bootupd; + +/// Top-level multicall CLI. +#[derive(Debug, Parser)] +pub enum MultiCall { + Ctl(bootupctl::CtlCommand), + D(bootupd::DCommand), +} + +impl MultiCall { + pub fn from_args(args: Vec) -> Self { + use std::os::unix::ffi::OsStrExt; + + // This is a multicall binary, dispatched based on the introspected + // filename found in argv[0]. + let exe_name = { + let arg0 = args.get(0).cloned().unwrap_or_default(); + let exe_path = std::path::PathBuf::from(arg0); + exe_path.file_name().unwrap_or_default().to_os_string() + }; + #[allow(clippy::wildcard_in_or_patterns)] + match exe_name.as_bytes() { + b"bootupctl" => MultiCall::Ctl(bootupctl::CtlCommand::parse_from(args)), + b"bootupd" | _ => MultiCall::D(bootupd::DCommand::parse_from(args)), + } + } + + pub fn run(self) -> Result<()> { + match self { + MultiCall::Ctl(ctl_cmd) => ctl_cmd.run(), + MultiCall::D(d_cmd) => d_cmd.run(), + } + } + + /// Return the log-level set via command-line flags. + pub fn loglevel(&self) -> LevelFilter { + match self { + MultiCall::Ctl(cmd) => cmd.loglevel(), + MultiCall::D(cmd) => cmd.loglevel(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clap_apps() { + use clap::CommandFactory; + bootupctl::CtlCommand::command().debug_assert(); + bootupd::DCommand::command().debug_assert(); + } + + #[test] + fn test_multicall_dispatch() { + { + let d_argv = vec![ + "/usr/bin/bootupd".to_string(), + "generate-update-metadata".to_string(), + ]; + let cli = MultiCall::from_args(d_argv); + match cli { + MultiCall::Ctl(cmd) => panic!("{:?}", cmd), + MultiCall::D(_) => {} + }; + } + { + let ctl_argv = vec!["/usr/bin/bootupctl".to_string(), "validate".to_string()]; + let cli = MultiCall::from_args(ctl_argv); + match cli { + MultiCall::Ctl(_) => {} + MultiCall::D(cmd) => panic!("{:?}", cmd), + }; + } + { + let ctl_argv = vec!["/bin-mount/bootupctl".to_string(), "validate".to_string()]; + let cli = MultiCall::from_args(ctl_argv); + match cli { + MultiCall::Ctl(_) => {} + MultiCall::D(cmd) => panic!("{:?}", cmd), + }; + } + } + + #[test] + fn test_verbosity() { + let default = MultiCall::from_args(vec![ + "bootupd".to_string(), + "generate-update-metadata".to_string(), + ]); + assert_eq!(default.loglevel(), LevelFilter::Warn); + + let info = MultiCall::from_args(vec![ + "bootupd".to_string(), + "generate-update-metadata".to_string(), + "-v".to_string(), + ]); + assert_eq!(info.loglevel(), LevelFilter::Info); + } +} diff --git a/bootupd/src/component.rs b/bootupd/src/component.rs new file mode 100755 index 0000000..5ca32df --- /dev/null +++ b/bootupd/src/component.rs @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use anyhow::{Context, Result}; +use fn_error_context::context; +use openat_ext::OpenatDirExt; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::{bootupd::RootContext, model::*}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ValidationResult { + Valid, + Skip, + Errors(Vec), +} + +/// A component along with a possible update +pub(crate) trait Component { + /// Returns the name of the component; this will be used for serialization + /// and should remain stable. + fn name(&self) -> &'static str; + + /// In an operating system whose initially booted disk image is not + /// using bootupd, detect whether it looks like the component exists + /// and "synthesize" content metadata from it. + fn query_adopt(&self, devices: &Option>) -> Result>; + + // Backup the current grub config, and install static grub config from tree + fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()>; + + /// Given an adoptable system and an update, perform the update. + fn adopt_update( + &self, + rootcxt: &RootContext, + update: &ContentMetadata, + with_static_config: bool, + ) -> Result>; + + /// Implementation of `bootupd install` for a given component. This should + /// gather data (or run binaries) from the source root, and install them + /// into the target root. It is expected that sub-partitions (e.g. the ESP) + /// are mounted at the expected place. For operations that require a block device instead + /// of a filesystem root, the component should query the mount point to + /// determine the block device. + /// This will be run during a disk image build process. + fn install( + &self, + src_root: &openat::Dir, + dest_root: &str, + device: &str, + update_firmware: bool, + ) -> Result; + + /// Implementation of `bootupd generate-update-metadata` for a given component. + /// This expects to be run during an "image update build" process. For CoreOS + /// this is an `rpm-ostree compose tree` for example. For a dual-partition + /// style updater, this would be run as part of a postprocessing step + /// while the filesystem for the partition is mounted. + fn generate_update_metadata(&self, sysroot: &str) -> Result; + + /// Used on the client to query for an update cached in the current booted OS. + fn query_update(&self, sysroot: &openat::Dir) -> Result>; + + /// Used on the client to run an update. + fn run_update( + &self, + rootcxt: &RootContext, + current: &InstalledContent, + ) -> Result; + + /// Used on the client to validate an installed version. + fn validate(&self, current: &InstalledContent) -> Result; + + /// Locating efi vendor dir + fn get_efi_vendor(&self, sysroot: &openat::Dir) -> Result>; +} + +/// Given a component name, create an implementation. +pub(crate) fn new_from_name(name: &str) -> Result> { + let r: Box = match name { + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + #[allow(clippy::box_default)] + "EFI" => Box::new(crate::efi::Efi::default()), + #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] + #[allow(clippy::box_default)] + "BIOS" => Box::new(crate::bios::Bios::default()), + _ => anyhow::bail!("No component {}", name), + }; + Ok(r) +} + +/// Returns the path to the payload directory for an available update for +/// a component. +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) fn component_updatedirname(component: &dyn Component) -> PathBuf { + Path::new(BOOTUPD_UPDATES_DIR).join(component.name()) +} + +/// Returns the path to the payload directory for an available update for +/// a component. +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) fn component_updatedir(sysroot: &str, component: &dyn Component) -> PathBuf { + Path::new(sysroot).join(component_updatedirname(component)) +} + +/// Returns the name of the JSON file containing a component's available update metadata installed +/// into the booted operating system root. +fn component_update_data_name(component: &dyn Component) -> PathBuf { + Path::new(&format!("{}.json", component.name())).into() +} + +/// Helper method for writing an update file +pub(crate) fn write_update_metadata( + sysroot: &str, + component: &dyn Component, + meta: &ContentMetadata, +) -> Result<()> { + let sysroot = openat::Dir::open(sysroot)?; + let dir = sysroot.sub_dir(BOOTUPD_UPDATES_DIR)?; + let name = component_update_data_name(component); + dir.write_file_with(name, 0o644, |w| -> Result<_> { + Ok(serde_json::to_writer(w, &meta)?) + })?; + Ok(()) +} + +/// Given a component, return metadata on the available update (if any) +#[context("Loading update for component {}", component.name())] +pub(crate) fn get_component_update( + sysroot: &openat::Dir, + component: &dyn Component, +) -> Result> { + let name = component_update_data_name(component); + let path = Path::new(BOOTUPD_UPDATES_DIR).join(name); + if let Some(f) = sysroot.open_file_optional(&path)? { + let mut f = std::io::BufReader::new(f); + let u = serde_json::from_reader(&mut f) + .with_context(|| format!("failed to parse {:?}", &path))?; + Ok(Some(u)) + } else { + Ok(None) + } +} + +#[context("Querying adoptable state")] +pub(crate) fn query_adopt_state() -> Result> { + // This would be extended with support for other operating systems later + if let Some(coreos_aleph) = crate::coreos::get_aleph_version(Path::new("/"))? { + let meta = ContentMetadata { + timestamp: coreos_aleph.ts, + version: coreos_aleph.aleph.version, + }; + log::trace!("Adoptable: {:?}", &meta); + return Ok(Some(Adoptable { + version: meta, + confident: true, + })); + } else { + log::trace!("No CoreOS aleph detected"); + } + let ostree_deploy_dir = Path::new("/ostree/deploy"); + if ostree_deploy_dir.exists() { + let btime = ostree_deploy_dir.metadata()?.created()?; + let timestamp = chrono::DateTime::from(btime); + let meta = ContentMetadata { + timestamp, + version: "unknown".to_string(), + }; + return Ok(Some(Adoptable { + version: meta, + confident: true, + })); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_efi_vendor() -> Result<()> { + let td = tempfile::tempdir()?; + let tdp = td.path(); + let tdp_updates = tdp.join("usr/lib/bootupd/updates"); + let td = openat::Dir::open(tdp)?; + std::fs::create_dir_all(tdp_updates.join("EFI/BOOT"))?; + std::fs::create_dir_all(tdp_updates.join("EFI/fedora"))?; + std::fs::create_dir_all(tdp_updates.join("EFI/centos"))?; + std::fs::write( + tdp_updates.join("EFI/fedora").join(crate::efi::SHIM), + "shim data", + )?; + std::fs::write( + tdp_updates.join("EFI/centos").join(crate::efi::SHIM), + "shim data", + )?; + + let all_components = crate::bootupd::get_components(); + let target_components: Vec<_> = all_components.values().collect(); + for &component in target_components.iter() { + if component.name() == "BIOS" { + assert_eq!(component.get_efi_vendor(&td)?, None); + } + if component.name() == "EFI" { + let x = component.get_efi_vendor(&td); + assert_eq!(x.is_err(), true); + std::fs::remove_dir_all(tdp_updates.join("EFI/centos"))?; + assert_eq!(component.get_efi_vendor(&td)?, Some("fedora".to_string())); + } + } + Ok(()) + } +} diff --git a/bootupd/src/coreos.rs b/bootupd/src/coreos.rs new file mode 100755 index 0000000..8f7aa8d --- /dev/null +++ b/bootupd/src/coreos.rs @@ -0,0 +1,123 @@ +//! Bits specific to Fedora CoreOS (and derivatives). + +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use anyhow::{Context, Result}; +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::path::Path; + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +/// See https://github.com/coreos/fedora-coreos-tracker/blob/66d7d00bedd9d5eabc7287b9577f443dcefb7c04/internals/README-internals.md#aleph-version +pub(crate) struct Aleph { + #[serde(alias = "build")] + pub(crate) version: String, +} + +pub(crate) struct AlephWithTimestamp { + pub(crate) aleph: Aleph, + #[allow(dead_code)] + pub(crate) ts: chrono::DateTime, +} + +/// Path to the file, see above +const ALEPH_PATH: &str = "sysroot/.coreos-aleph-version.json"; + +pub(crate) fn get_aleph_version(root: &Path) -> Result> { + let path = &root.join(ALEPH_PATH); + if !path.exists() { + return Ok(None); + } + let statusf = File::open(path).with_context(|| format!("Opening {path:?}"))?; + let meta = statusf.metadata()?; + let bufr = std::io::BufReader::new(statusf); + let aleph: Aleph = serde_json::from_reader(bufr)?; + Ok(Some(AlephWithTimestamp { + aleph, + ts: meta.created()?.into(), + })) +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + const V1_ALEPH_DATA: &str = r##" + { + "version": "32.20201002.dev.2", + "ref": "fedora/x86_64/coreos/testing-devel", + "ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862" + }"##; + + // Waiting on https://github.com/rust-lang/rust/pull/125692 + #[cfg(not(target_env = "musl"))] + #[test] + fn test_parse_from_root_empty() -> Result<()> { + // Verify we're a no-op in an empty root + let root: &tempfile::TempDir = &tempfile::tempdir()?; + let root = root.path(); + assert!(get_aleph_version(root).unwrap().is_none()); + Ok(()) + } + + // Waiting on https://github.com/rust-lang/rust/pull/125692 + #[cfg(not(target_env = "musl"))] + #[test] + fn test_parse_from_root() -> Result<()> { + let root: &tempfile::TempDir = &tempfile::tempdir()?; + let root = root.path(); + let sysroot = &root.join("sysroot"); + std::fs::create_dir(sysroot).context("Creating sysroot")?; + std::fs::write(root.join(ALEPH_PATH), V1_ALEPH_DATA).context("Writing aleph")?; + let aleph = get_aleph_version(root).unwrap().unwrap(); + assert_eq!(aleph.aleph.version, "32.20201002.dev.2"); + Ok(()) + } + + // Waiting on https://github.com/rust-lang/rust/pull/125692 + #[cfg(not(target_env = "musl"))] + #[test] + fn test_parse_from_root_linked() -> Result<()> { + let root: &tempfile::TempDir = &tempfile::tempdir()?; + let root = root.path(); + let sysroot = &root.join("sysroot"); + std::fs::create_dir(sysroot).context("Creating sysroot")?; + let target_name = ".new-ostree-aleph.json"; + let target = &sysroot.join(target_name); + std::fs::write(root.join(target), V1_ALEPH_DATA).context("Writing aleph")?; + std::os::unix::fs::symlink(target_name, root.join(ALEPH_PATH)).context("Symlinking")?; + let aleph = get_aleph_version(root).unwrap().unwrap(); + assert_eq!(aleph.aleph.version, "32.20201002.dev.2"); + Ok(()) + } + + #[test] + fn test_parse_old_aleph() -> Result<()> { + // What the aleph file looked like before we changed it in + // https://github.com/osbuild/osbuild/pull/1475 + let alephdata = r##" +{ + "build": "32.20201002.dev.2", + "ref": "fedora/x86_64/coreos/testing-devel", + "ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862", + "imgid": "fedora-coreos-32.20201002.dev.2-qemu.x86_64.qcow2" +}"##; + let aleph: Aleph = serde_json::from_str(alephdata)?; + assert_eq!(aleph.version, "32.20201002.dev.2"); + Ok(()) + } + + #[test] + fn test_parse_aleph() -> Result<()> { + let aleph: Aleph = serde_json::from_str(V1_ALEPH_DATA)?; + assert_eq!(aleph.version, "32.20201002.dev.2"); + Ok(()) + } +} diff --git a/bootupd/src/efi.rs b/bootupd/src/efi.rs new file mode 100755 index 0000000..61de605 --- /dev/null +++ b/bootupd/src/efi.rs @@ -0,0 +1,896 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::cell::RefCell; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use bootc_internal_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use chrono::prelude::*; +use fn_error_context::context; +use openat_ext::OpenatDirExt; +use os_release::OsRelease; +use rustix::fd::BorrowedFd; +use walkdir::WalkDir; +use widestring::U16CString; + +use crate::bootupd::RootContext; +use crate::freezethaw::fsfreeze_thaw_cycle; +use crate::model::*; +use crate::ostreeutil; +use crate::util; +use crate::{blockdev, filetree, grubconfigs}; +use crate::{component::*, packagesystem}; + +/// Well-known paths to the ESP that may have been mounted external to us. +pub(crate) const ESP_MOUNTS: &[&str] = &["boot/efi", "efi", "boot"]; + +/// New efi dir under usr/lib +const EFILIB: &str = "usr/lib/efi"; + +/// The binary to change EFI boot ordering +const EFIBOOTMGR: &str = "efibootmgr"; +#[cfg(target_arch = "aarch64")] +pub(crate) const SHIM: &str = "shimaa64.efi"; + +#[cfg(target_arch = "x86_64")] +pub(crate) const SHIM: &str = "shimx64.efi"; + +#[cfg(target_arch = "riscv64")] +pub(crate) const SHIM: &str = "shimriscv64.efi"; + +/// Systemd boot loader info EFI variable names +const LOADER_INFO_VAR_STR: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; +const STUB_INFO_VAR_STR: &str = "StubInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; + +/// Return `true` if the system is booted via EFI +pub(crate) fn is_efi_booted() -> Result { + Path::new("/sys/firmware/efi") + .try_exists() + .map_err(Into::into) +} + +#[derive(Default)] +pub(crate) struct Efi { + mountpoint: RefCell>, +} + +impl Efi { + // Get mounted point for esp + pub(crate) fn get_mounted_esp(&self, root: &Path) -> Result> { + // First check all potential mount points without holding the borrow + let mut found_mount = None; + for &mnt in ESP_MOUNTS.iter() { + let path = root.join(mnt); + if !path.exists() { + continue; + } + + let st = rustix::fs::statfs(&path)?; + if st.f_type == libc::MSDOS_SUPER_MAGIC { + util::ensure_writable_mount(&path)?; + found_mount = Some(path); + break; + } + } + + // Only borrow mutably if we found a mount point + if let Some(mnt) = found_mount { + log::debug!("Reusing existing mount point {mnt:?}"); + *self.mountpoint.borrow_mut() = Some(mnt.clone()); + Ok(Some(mnt)) + } else { + Ok(None) + } + } + + // Mount the passed esp_device, return mount point + pub(crate) fn mount_esp_device(&self, root: &Path, esp_device: &Path) -> Result { + let mut mountpoint = None; + + for &mnt in ESP_MOUNTS.iter() { + let mnt = root.join(mnt); + if !mnt.exists() { + continue; + } + std::process::Command::new("mount") + .arg(&esp_device) + .arg(&mnt) + .run() + .with_context(|| format!("Failed to mount {:?}", esp_device))?; + log::debug!("Mounted at {mnt:?}"); + mountpoint = Some(mnt); + break; + } + let mnt = mountpoint.ok_or_else(|| anyhow::anyhow!("No mount point found"))?; + *self.mountpoint.borrow_mut() = Some(mnt.clone()); + Ok(mnt) + } + + // Firstly check if esp is already mounted, then mount the passed esp device + pub(crate) fn ensure_mounted_esp(&self, root: &Path, esp_device: &Path) -> Result { + if let Some(mountpoint) = self.mountpoint.borrow().as_deref() { + return Ok(mountpoint.to_owned()); + } + let destdir = if let Some(destdir) = self.get_mounted_esp(Path::new(root))? { + destdir + } else { + self.mount_esp_device(root, esp_device)? + }; + Ok(destdir) + } + + fn unmount(&self) -> Result<()> { + if let Some(mount) = self.mountpoint.borrow_mut().take() { + Command::new("umount") + .arg(&mount) + .run() + .with_context(|| format!("Failed to unmount {mount:?}"))?; + log::trace!("Unmounted"); + } + Ok(()) + } + + #[context("Updating EFI firmware variables")] + fn update_firmware(&self, device: &str, espdir: &openat::Dir, vendordir: &str) -> Result<()> { + if !is_efi_booted()? { + log::debug!("Not booted via EFI, skipping firmware update"); + return Ok(()); + } + let sysroot = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let product_name = get_product_name(&sysroot)?; + log::debug!("Get product name: '{product_name}'"); + assert!(product_name.len() > 0); + // clear all the boot entries that match the target name + clear_efi_target(&product_name)?; + create_efi_boot_entry(device, espdir, vendordir, &product_name) + } +} + +#[context("Get product name")] +fn get_product_name(sysroot: &Dir) -> Result { + let release_path = "etc/system-release"; + if sysroot.exists(release_path) { + let content = sysroot.read_to_string(release_path)?; + let re = regex::Regex::new(r" *release.*").unwrap(); + let name = re.replace_all(&content, "").trim().to_string(); + return Ok(name); + } + // Read /etc/os-release + let release: OsRelease = OsRelease::new()?; + Ok(release.name) +} + +/// Convert a nul-terminated UTF-16 byte array to a String. +fn string_from_utf16_bytes(slice: &[u8]) -> String { + // For some reason, systemd appends 3 nul bytes after the string. + // Drop the last byte if there's an odd number. + let size = slice.len() / 2; + let v: Vec = (0..size) + .map(|i| u16::from_ne_bytes([slice[2 * i], slice[2 * i + 1]])) + .collect(); + U16CString::from_vec(v).unwrap().to_string_lossy() +} + +/// Read a nul-terminated UTF-16 string from an EFI variable. +fn read_efi_var_utf16_string(name: &str) -> Option { + let efivars = Path::new("/sys/firmware/efi/efivars"); + if !efivars.exists() { + log::trace!("No efivars mount at {:?}", efivars); + return None; + } + let path = efivars.join(name); + if !path.exists() { + log::trace!("No EFI variable {name}"); + return None; + } + match std::fs::read(&path) { + Ok(buf) => { + // Skip the first 4 bytes, those are the EFI variable attributes. + if buf.len() < 4 { + log::warn!("Read less than 4 bytes from {:?}", path); + return None; + } + Some(string_from_utf16_bytes(&buf[4..])) + } + Err(reason) => { + log::warn!("Failed reading {:?}: {reason}", path); + None + } + } +} + +/// Read the LoaderInfo EFI variable if it exists. +fn get_loader_info() -> Option { + read_efi_var_utf16_string(LOADER_INFO_VAR_STR) +} + +/// Read the StubInfo EFI variable if it exists. +fn get_stub_info() -> Option { + read_efi_var_utf16_string(STUB_INFO_VAR_STR) +} + +/// Whether to skip adoption if a systemd bootloader is found. +fn skip_systemd_bootloaders() -> bool { + if let Some(loader_info) = get_loader_info() { + if loader_info.starts_with("systemd") { + log::trace!("Skipping adoption for {:?}", loader_info); + return true; + } + } + if let Some(stub_info) = get_stub_info() { + log::trace!("Skipping adoption for {:?}", stub_info); + return true; + } + false +} + +impl Component for Efi { + fn name(&self) -> &'static str { + "EFI" + } + + fn query_adopt(&self, devices: &Option>) -> Result> { + if devices.is_none() { + log::trace!("No ESP detected"); + return Ok(None); + }; + + // Don't adopt if the system is booted with systemd-boot or + // systemd-stub since those will be managed with bootctl. + if skip_systemd_bootloaders() { + return Ok(None); + } + crate::component::query_adopt_state() + } + + // Backup "/boot/efi/EFI/{vendor}/grub.cfg" to "/boot/efi/EFI/{vendor}/grub.cfg.bak" + // Replace "/boot/efi/EFI/{vendor}/grub.cfg" with new static "grub.cfg" + fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()> { + let sysroot = + openat::Dir::open(sysroot_path).with_context(|| format!("Opening {sysroot_path}"))?; + let Some(vendor) = self.get_efi_vendor(&sysroot)? else { + anyhow::bail!("Failed to find efi vendor"); + }; + + // destdir is /boot/efi/EFI + let efidir = destdir + .sub_dir(&vendor) + .with_context(|| format!("Opening EFI/{}", vendor))?; + + if !efidir.exists(grubconfigs::GRUBCONFIG_BACKUP)? { + println!("Creating a backup of the current GRUB config on EFI"); + efidir + .copy_file(grubconfigs::GRUBCONFIG, grubconfigs::GRUBCONFIG_BACKUP) + .context("Failed to backup GRUB config")?; + } + + grubconfigs::install(&sysroot, Some(&vendor), true)?; + // Synchronize the filesystem containing /boot/efi/EFI/{vendor} to disk. + fsfreeze_thaw_cycle(efidir.open_file(".")?)?; + + Ok(()) + } + + /// Given an adoptable system and an update, perform the update. + fn adopt_update( + &self, + rootcxt: &RootContext, + updatemeta: &ContentMetadata, + with_static_config: bool, + ) -> Result> { + let esp_devices = blockdev::find_colocated_esps(&rootcxt.devices)?; + let Some(meta) = self.query_adopt(&esp_devices)? else { + return Ok(None); + }; + + let updated = rootcxt + .sysroot + .sub_dir(&component_updatedirname(self)) + .context("opening update dir")?; + let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; + + let esp_devices = esp_devices.unwrap_or_default(); + for esp in esp_devices { + let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp))?; + + let efidir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?; + validate_esp_fstype(&efidir)?; + + // For adoption, we should only touch files that we know about. + let diff = updatef.relative_diff_to(&efidir)?; + log::trace!("applying adoption diff: {}", &diff); + filetree::apply_diff(&updated, &efidir, &diff, None) + .context("applying filesystem changes")?; + + // Backup current config and install static config + if with_static_config { + // Install the static config if the OSTree bootloader is not set. + if let Some(bootloader) = crate::ostreeutil::get_ostree_bootloader()? { + println!( + "ostree repo 'sysroot.bootloader' config option is currently set to: '{bootloader}'", + ); + } else { + println!("ostree repo 'sysroot.bootloader' config option is not set yet"); + self.migrate_static_grub_config(rootcxt.path.as_str(), &efidir)?; + }; + } + + // Do the sync before unmount + fsfreeze_thaw_cycle(efidir.open_file(".")?)?; + drop(efidir); + self.unmount().context("unmount after adopt")?; + } + Ok(Some(InstalledContent { + meta: updatemeta.clone(), + filetree: Some(updatef), + adopted_from: Some(meta.version), + })) + } + + fn install( + &self, + src_root: &openat::Dir, + dest_root: &str, + device: &str, + update_firmware: bool, + ) -> Result { + let Some(meta) = get_component_update(src_root, self)? else { + anyhow::bail!("No update metadata for component {} found", self.name()); + }; + log::debug!("Found metadata {}", meta.version); + let srcdir_name = component_updatedirname(self); + let ft = crate::filetree::FileTree::new_from_dir(&src_root.sub_dir(&srcdir_name)?)?; + + // Let's attempt to use an already mounted ESP at the target + // dest_root if one is already mounted there in a known ESP location. + let destpath = if let Some(destdir) = self.get_mounted_esp(Path::new(dest_root))? { + destdir + } else { + // Using `blockdev` to find the partition instead of partlabel because + // we know the target install toplevel device already. + if device.is_empty() { + anyhow::bail!("Device value not provided"); + } + let esp_device = blockdev::get_esp_partition(device)? + .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; + self.mount_esp_device(Path::new(dest_root), Path::new(&esp_device))? + }; + + let destd = &openat::Dir::open(&destpath) + .with_context(|| format!("opening dest dir {}", destpath.display()))?; + validate_esp_fstype(destd)?; + + // TODO - add some sort of API that allows directly setting the working + // directory to a file descriptor. + std::process::Command::new("cp") + .args(["-rp", "--reflink=auto"]) + .arg(&srcdir_name) + .arg(destpath) + .current_dir(format!("/proc/self/fd/{}", src_root.as_raw_fd())) + .run()?; + if update_firmware { + if let Some(vendordir) = self.get_efi_vendor(&src_root)? { + self.update_firmware(device, destd, &vendordir)? + } + } + Ok(InstalledContent { + meta, + filetree: Some(ft), + adopted_from: None, + }) + } + + fn run_update( + &self, + rootcxt: &RootContext, + current: &InstalledContent, + ) -> Result { + let currentf = current + .filetree + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + let sysroot_dir = &rootcxt.sysroot; + let updatemeta = self.query_update(sysroot_dir)?.expect("update available"); + let updated = sysroot_dir + .sub_dir(&component_updatedirname(self)) + .context("opening update dir")?; + let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; + let diff = currentf.diff(&updatef)?; + + let Some(esp_devices) = blockdev::find_colocated_esps(&rootcxt.devices)? else { + anyhow::bail!("Failed to find all esp devices"); + }; + + for esp in esp_devices { + let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp))?; + let destdir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?; + validate_esp_fstype(&destdir)?; + log::trace!("applying diff: {}", &diff); + filetree::apply_diff(&updated, &destdir, &diff, None) + .context("applying filesystem changes")?; + + // Do the sync before unmount + fsfreeze_thaw_cycle(destdir.open_file(".")?)?; + drop(destdir); + self.unmount().context("unmount after update")?; + } + + let adopted_from = None; + Ok(InstalledContent { + meta: updatemeta, + filetree: Some(updatef), + adopted_from, + }) + } + + fn generate_update_metadata(&self, sysroot: &str) -> Result { + let sysroot_path = Utf8Path::new(sysroot); + + // copy EFI files to updates dir from usr/lib/efi + let efilib_path = sysroot_path.join(EFILIB); + let meta = if efilib_path.exists() { + let mut packages = Vec::new(); + let sysroot_dir = Dir::open_ambient_dir(sysroot_path, cap_std::ambient_authority())?; + let efi_components = get_efi_component_from_usr(&sysroot_path, EFILIB)?; + if efi_components.len() == 0 { + bail!("Failed to find EFI components from {efilib_path}"); + } + for efi in efi_components { + Command::new("cp") + .args(["-rp", "--reflink=auto"]) + .arg(&efi.path) + .arg(crate::model::BOOTUPD_UPDATES_DIR) + .current_dir(format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd())) + .run()?; + packages.push(format!("{}-{}", efi.name, efi.version)); + } + + // change to now to workaround https://github.com/coreos/bootupd/issues/933 + let timestamp = std::time::SystemTime::now(); + ContentMetadata { + timestamp: chrono::DateTime::::from(timestamp), + version: packages.join(","), + } + } else { + let ostreebootdir = sysroot_path.join(ostreeutil::BOOT_PREFIX); + + // move EFI files to updates dir from /usr/lib/ostree-boot + if ostreebootdir.exists() { + let cruft = ["loader", "grub2"]; + for p in cruft.iter() { + let p = ostreebootdir.join(p); + if p.exists() { + std::fs::remove_dir_all(&p)?; + } + } + + let efisrc = ostreebootdir.join("efi/EFI"); + if !efisrc.exists() { + bail!("Failed to find {:?}", &efisrc); + } + + let dest_efidir = component_updatedir(sysroot, self); + let dest_efidir = + Utf8PathBuf::from_path_buf(dest_efidir).expect("Path is invalid UTF-8"); + // Fork off mv() because on overlayfs one can't rename() a lower level + // directory today, and this will handle the copy fallback. + Command::new("mv").args([&efisrc, &dest_efidir]).run()?; + + let efidir = openat::Dir::open(dest_efidir.as_std_path()) + .with_context(|| format!("Opening {}", dest_efidir))?; + let files = crate::util::filenames(&efidir)?.into_iter().map(|mut f| { + f.insert_str(0, "/boot/efi/EFI/"); + f + }); + packagesystem::query_files(sysroot, files)? + } else { + anyhow::bail!("Failed to find {ostreebootdir}"); + } + }; + + write_update_metadata(sysroot, self, &meta)?; + Ok(meta) + } + + fn query_update(&self, sysroot: &openat::Dir) -> Result> { + get_component_update(sysroot, self) + } + + fn validate(&self, current: &InstalledContent) -> Result { + let devices = crate::blockdev::get_devices("/").context("get parent devices")?; + let esp_devices = blockdev::find_colocated_esps(&devices)?; + if !is_efi_booted()? && esp_devices.is_none() { + return Ok(ValidationResult::Skip); + } + let currentf = current + .filetree + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + + let mut errs = Vec::new(); + let esp_devices = esp_devices.unwrap_or_default(); + for esp in esp_devices.iter() { + let destpath = &self.ensure_mounted_esp(Path::new("/"), Path::new(&esp))?; + + let efidir = openat::Dir::open(&destpath.join("EFI")) + .with_context(|| format!("opening EFI dir {}", destpath.display()))?; + let diff = currentf.relative_diff_to(&efidir)?; + + for f in diff.changes.iter() { + errs.push(format!("Changed: {}", f)); + } + for f in diff.removals.iter() { + errs.push(format!("Removed: {}", f)); + } + assert_eq!(diff.additions.len(), 0); + drop(efidir); + self.unmount().context("unmount after validate")?; + } + + if !errs.is_empty() { + Ok(ValidationResult::Errors(errs)) + } else { + Ok(ValidationResult::Valid) + } + } + + fn get_efi_vendor(&self, sysroot: &openat::Dir) -> Result> { + let updated = sysroot + .sub_dir(&component_updatedirname(self)) + .context("opening update dir")?; + let shim_files = find_file_recursive(updated.recover_path()?, SHIM)?; + + // Does not support multiple shim for efi + if shim_files.len() > 1 { + anyhow::bail!("Found multiple {SHIM} in the image"); + } + if let Some(p) = shim_files.first() { + let p = p + .parent() + .unwrap() + .file_name() + .ok_or_else(|| anyhow::anyhow!("No file name found"))?; + Ok(Some(p.to_string_lossy().into_owned())) + } else { + anyhow::bail!("Failed to find {SHIM} in the image") + } + } +} + +impl Drop for Efi { + fn drop(&mut self) { + log::debug!("Unmounting"); + let _ = self.unmount(); + } +} + +fn validate_esp_fstype(dir: &openat::Dir) -> Result<()> { + let dir = unsafe { BorrowedFd::borrow_raw(dir.as_raw_fd()) }; + let stat = rustix::fs::fstatfs(&dir)?; + if stat.f_type != libc::MSDOS_SUPER_MAGIC { + bail!( + "EFI mount is not a msdos filesystem, but is {:?}", + stat.f_type + ); + }; + Ok(()) +} + +#[derive(Debug, PartialEq)] +struct BootEntry { + id: String, + name: String, +} + +/// Parse boot entries from efibootmgr output +fn parse_boot_entries(output: &str) -> Vec { + let mut entries = Vec::new(); + + for line in output.lines().filter_map(|line| line.strip_prefix("Boot")) { + // Need to consider if output only has "Boot0000* UiApp", without additional info + if line.starts_with('0') { + let parts = if let Some((parts, _)) = line.split_once('\t') { + parts + } else { + line + }; + if let Some((id, name)) = parts.split_once(' ') { + let id = id.trim_end_matches('*').to_string(); + let name = name.trim().to_string(); + entries.push(BootEntry { id, name }); + } + } + } + entries +} + +#[context("Clearing EFI boot entries that match target {target}")] +pub(crate) fn clear_efi_target(target: &str) -> Result<()> { + let target = target.to_lowercase(); + let output = Command::new(EFIBOOTMGR).output()?; + if !output.status.success() { + anyhow::bail!("Failed to invoke {EFIBOOTMGR}") + } + + let output = String::from_utf8(output.stdout)?; + let boot_entries = parse_boot_entries(&output); + for entry in boot_entries { + if entry.name.to_lowercase() == target { + log::debug!("Deleting matched target {:?}", entry); + let mut cmd = Command::new(EFIBOOTMGR); + cmd.args(["-b", entry.id.as_str(), "-B"]); + println!("Executing: {cmd:?}"); + cmd.run_with_cmd_context()?; + } + } + + anyhow::Ok(()) +} + +#[context("Adding new EFI boot entry")] +pub(crate) fn create_efi_boot_entry( + device: &str, + espdir: &openat::Dir, + vendordir: &str, + target: &str, +) -> Result<()> { + let fsinfo = crate::filesystem::inspect_filesystem(espdir, ".")?; + let source = fsinfo.source; + let devname = source + .rsplit_once('/') + .ok_or_else(|| anyhow::anyhow!("Failed to parse {source}"))? + .1; + let partition_path = format!("/sys/class/block/{devname}/partition"); + let partition_number = std::fs::read_to_string(&partition_path) + .with_context(|| format!("Failed to read {partition_path}"))?; + let shim = format!("{vendordir}/{SHIM}"); + if espdir.exists(&shim)? { + anyhow::bail!("Failed to find {SHIM}"); + } + let loader = format!("\\EFI\\{}\\{SHIM}", vendordir); + log::debug!("Creating new EFI boot entry using '{target}'"); + let mut cmd = Command::new(EFIBOOTMGR); + cmd.args([ + "--create", + "--disk", + device, + "--part", + partition_number.trim(), + "--loader", + loader.as_str(), + "--label", + target, + ]); + println!("Executing: {cmd:?}"); + cmd.run_with_cmd_context() +} + +#[context("Find target file recursively")] +fn find_file_recursive>(dir: P, target_file: &str) -> Result> { + let mut result = Vec::new(); + + for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name == target_file { + if let Some(path) = entry.path().to_str() { + result.push(path.into()); + } + } + } + } + } + + Ok(result) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct EFIComponent { + name: String, + version: String, + path: Utf8PathBuf, +} + +/// Get EFIComponents from e.g. usr/lib/efi, like "usr/lib/efi///EFI" +fn get_efi_component_from_usr<'a>( + sysroot: &'a Utf8Path, + usr_path: &'a str, +) -> Result> { + let efilib_path = sysroot.join(usr_path); + let skip_count = Utf8Path::new(usr_path).components().count(); + + let mut components: Vec = WalkDir::new(&efilib_path) + .min_depth(3) // //EFI: so 3 levels down + .max_depth(3) + .into_iter() + .filter_map(|entry| { + let entry = entry.ok()?; + if !entry.file_type().is_dir() || entry.file_name() != "EFI" { + return None; + } + + let abs_path = entry.path(); + let rel_path = abs_path.strip_prefix(sysroot).ok()?; + let utf8_rel_path = Utf8PathBuf::from_path_buf(rel_path.to_path_buf()).ok()?; + + let mut components = utf8_rel_path.components(); + + let name = components.nth(skip_count)?.to_string(); + let version = components.next()?.to_string(); + + Some(EFIComponent { + name, + version, + path: utf8_rel_path, + }) + }) + .collect(); + + components.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(components) +} + +#[cfg(test)] +mod tests { + use cap_std_ext::dirext::CapStdExtDirExt; + + use super::*; + + #[test] + fn test_parse_boot_entries() -> Result<()> { + let output = r" +BootCurrent: 0003 +Timeout: 0 seconds +BootOrder: 0003,0001,0000,0002 +Boot0000* UiApp FvVol(7cb8bdc9-f8eb-4f34-aaea-3ee4af6516a1)/FvFile(462caa21-7614-4503-836e-8ab6f4662331) +Boot0001* UEFI Misc Device PciRoot(0x0)/Pci(0x3,0x0){auto_created_boot_option} +Boot0002* EFI Internal Shell FvVol(7cb8bdc9-f8eb-4f34-aaea-3ee4af6516a1)/FvFile(7c04a583-9e3e-4f1c-ad65-e05268d0b4d1) +Boot0003* Fedora HD(2,GPT,94ff4025-5276-4bec-adea-e98da271b64c,0x1000,0x3f800)/\EFI\fedora\shimx64.efi"; + let entries = parse_boot_entries(output); + assert_eq!( + entries, + [ + BootEntry { + id: "0000".to_string(), + name: "UiApp".to_string() + }, + BootEntry { + id: "0001".to_string(), + name: "UEFI Misc Device".to_string() + }, + BootEntry { + id: "0002".to_string(), + name: "EFI Internal Shell".to_string() + }, + BootEntry { + id: "0003".to_string(), + name: "Fedora".to_string() + } + ] + ); + let output = r" +BootCurrent: 0003 +Timeout: 0 seconds +BootOrder: 0003,0001,0000,0002"; + let entries = parse_boot_entries(output); + assert_eq!(entries, []); + + let output = r" +BootCurrent: 0003 +Timeout: 0 seconds +BootOrder: 0003,0001,0000,0002 +Boot0000* UiApp +Boot0001* UEFI Misc Device +Boot0002* EFI Internal Shell +Boot0003* test"; + let entries = parse_boot_entries(output); + assert_eq!( + entries, + [ + BootEntry { + id: "0000".to_string(), + name: "UiApp".to_string() + }, + BootEntry { + id: "0001".to_string(), + name: "UEFI Misc Device".to_string() + }, + BootEntry { + id: "0002".to_string(), + name: "EFI Internal Shell".to_string() + }, + BootEntry { + id: "0003".to_string(), + name: "test".to_string() + } + ] + ); + Ok(()) + } + #[cfg(test)] + fn fixture() -> Result { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir("etc")?; + Ok(tempdir) + } + #[test] + fn test_get_product_name() -> Result<()> { + let tmpd = fixture()?; + { + tmpd.atomic_write("etc/system-release", "Fedora release 40 (Forty)")?; + let name = get_product_name(&tmpd)?; + assert_eq!("Fedora", name); + } + { + tmpd.atomic_write("etc/system-release", "CentOS Stream release 9")?; + let name = get_product_name(&tmpd)?; + assert_eq!("CentOS Stream", name); + } + { + tmpd.atomic_write( + "etc/system-release", + "Red Hat Enterprise Linux CoreOS release 4", + )?; + let name = get_product_name(&tmpd)?; + assert_eq!("Red Hat Enterprise Linux CoreOS", name); + } + { + tmpd.atomic_write( + "etc/system-release", + "Red Hat Enterprise Linux CoreOS release 4 + ", + )?; + let name = get_product_name(&tmpd)?; + assert_eq!("Red Hat Enterprise Linux CoreOS", name); + } + { + tmpd.remove_file("etc/system-release")?; + let name = get_product_name(&tmpd)?; + assert!(name.len() > 0); + } + Ok(()) + } + + #[test] + fn test_get_efi_component_from_usr() -> Result<()> { + let tmpdir: &tempfile::TempDir = &tempfile::tempdir()?; + let tpath = tmpdir.path(); + let efi_path = tpath.join("usr/lib/efi"); + std::fs::create_dir_all(efi_path.join("BAR/1.1/EFI"))?; + std::fs::create_dir_all(efi_path.join("FOO/1.1/EFI"))?; + std::fs::create_dir_all(efi_path.join("FOOBAR/1.1/test"))?; + let utf8_tpath = + Utf8Path::from_path(tpath).ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?; + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?; + assert_eq!( + efi_comps, + vec![ + EFIComponent { + name: "BAR".to_string(), + version: "1.1".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/BAR/1.1/EFI"), + }, + EFIComponent { + name: "FOO".to_string(), + version: "1.1".to_string(), + path: Utf8PathBuf::from("usr/lib/efi/FOO/1.1/EFI"), + }, + ] + ); + std::fs::remove_dir_all(efi_path.join("BAR/1.1/EFI"))?; + std::fs::remove_dir_all(efi_path.join("FOO/1.1/EFI"))?; + let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?; + assert_eq!(efi_comps, []); + Ok(()) + } +} diff --git a/bootupd/src/failpoints.rs b/bootupd/src/failpoints.rs new file mode 100755 index 0000000..78dce44 --- /dev/null +++ b/bootupd/src/failpoints.rs @@ -0,0 +1,21 @@ +//! Wrappers and utilities on top of the `fail` crate. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// TODO: Use https://github.com/tikv/fail-rs/pull/68 once it merges +/// copy from https://github.com/coreos/rpm-ostree/commit/aa8d7fb0ceaabfaf10252180e2ddee049d07aae3#diff-adcc419e139605fae34d17b31418dbaf515af2fe9fb766fcbdb2eaad862b3daa +#[macro_export] +macro_rules! try_fail_point { + ($name:expr) => {{ + if let Some(e) = fail::eval($name, |msg| { + let msg = msg.unwrap_or_else(|| "synthetic failpoint".to_string()); + anyhow::Error::msg(msg) + }) { + return Err(From::from(e)); + } + }}; + ($name:expr, $cond:expr) => {{ + if $cond { + $crate::try_fail_point!($name); + } + }}; +} diff --git a/bootupd/src/filesystem.rs b/bootupd/src/filesystem.rs new file mode 100755 index 0000000..07a96b8 --- /dev/null +++ b/bootupd/src/filesystem.rs @@ -0,0 +1,40 @@ +use std::os::fd::AsRawFd; +use std::os::unix::process::CommandExt; +use std::process::Command; + +use anyhow::Result; +use bootc_internal_utils::CommandRunExt; +use fn_error_context::context; +use rustix::fd::BorrowedFd; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +pub(crate) struct Filesystem { + pub(crate) source: String, + pub(crate) fstype: String, + pub(crate) options: String, + pub(crate) uuid: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct Findmnt { + pub(crate) filesystems: Vec, +} + +#[context("Inspecting filesystem {path:?}")] +pub(crate) fn inspect_filesystem(root: &openat::Dir, path: &str) -> Result { + let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) }; + // SAFETY: This is unsafe just for the pre_exec, when we port to cap-std we can use cap-std-ext + let o: Findmnt = unsafe { + Command::new("findmnt") + .args(["-J", "-v", "--output=SOURCE,FSTYPE,OPTIONS,UUID", path]) + .pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into)) + .run_and_parse_json()? + }; + o.filesystems + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("findmnt returned no data")) +} diff --git a/bootupd/src/filetree.rs b/bootupd/src/filetree.rs new file mode 100755 index 0000000..f84c18a --- /dev/null +++ b/bootupd/src/filetree.rs @@ -0,0 +1,785 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::freezethaw::fsfreeze_thaw_cycle; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use anyhow::{bail, Context, Result}; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use camino::{Utf8Path, Utf8PathBuf}; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use openat_ext::OpenatDirExt; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use openssl::hash::{Hasher, MessageDigest}; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use rustix::fd::BorrowedFd; +use serde::{Deserialize, Serialize}; +#[allow(unused_imports)] +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Display; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use std::os::unix::io::AsRawFd; + +/// The prefix we apply to our temporary files. +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) const TMP_PREFIX: &str = ".btmp."; +// This module doesn't handle modes right now, because +// we're only targeting FAT filesystems for UEFI. +// In FAT there are no unix permission bits, usually +// they're set by mount options. +// See also https://github.com/coreos/fedora-coreos-config/commit/8863c2b34095a2ae5eae6fbbd121768a5f592091 +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +const DEFAULT_FILE_MODE: u32 = 0o700; + +use crate::sha512string::SHA512String; + +/// Metadata for a single file +#[derive(Clone, Serialize, Deserialize, Debug, Hash, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct FileMetadata { + /// File size in bytes + pub(crate) size: u64, + /// Content checksum; chose SHA-512 because there are not a lot of files here + /// and it's ok if the checksum is large. + pub(crate) sha512: SHA512String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct FileTree { + pub(crate) children: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct FileTreeDiff { + pub(crate) additions: HashSet, + pub(crate) removals: HashSet, + pub(crate) changes: HashSet, +} + +impl Display for FileTreeDiff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!( + f, + "additions: {} removals: {} changes: {}", + self.additions.len(), + self.removals.len(), + self.changes.len() + ) + } +} + +#[cfg(test)] +impl FileTreeDiff { + pub(crate) fn count(&self) -> usize { + self.additions.len() + self.removals.len() + self.changes.len() + } +} + +impl FileMetadata { + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn new_from_path( + dir: &openat::Dir, + name: P, + ) -> Result { + let mut r = dir.open_file(name)?; + let meta = r.metadata()?; + let mut hasher = + Hasher::new(MessageDigest::sha512()).expect("openssl sha512 hasher creation failed"); + let _ = std::io::copy(&mut r, &mut hasher)?; + let digest = SHA512String::from_hasher(&mut hasher); + Ok(FileMetadata { + size: meta.len(), + sha512: digest, + }) + } +} + +impl FileTree { + // Internal helper to generate a sub-tree + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + fn unsorted_from_dir(dir: &openat::Dir) -> Result> { + let mut ret = HashMap::new(); + for entry in dir.list_dir(".")? { + let entry = entry?; + let Some(name) = entry.file_name().to_str() else { + bail!("Invalid UTF-8 filename: {:?}", entry.file_name()) + }; + if name.starts_with(TMP_PREFIX) { + bail!("File {} contains our temporary prefix!", name); + } + match dir.get_file_type(&entry)? { + openat::SimpleType::File => { + let meta = FileMetadata::new_from_path(dir, name)?; + let _ = ret.insert(name.to_string(), meta); + } + openat::SimpleType::Dir => { + let child = dir.sub_dir(name)?; + for (mut k, v) in FileTree::unsorted_from_dir(&child)?.drain() { + k.reserve(name.len() + 1); + k.insert(0, '/'); + k.insert_str(0, name); + let _ = ret.insert(k, v); + } + } + openat::SimpleType::Symlink => { + bail!("Unsupported symbolic link {:?}", entry.file_name()) + } + openat::SimpleType::Other => { + bail!("Unsupported non-file/directory {:?}", entry.file_name()) + } + } + } + Ok(ret) + } + + /// Create a FileTree from the target directory. + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn new_from_dir(dir: &openat::Dir) -> Result { + let mut children = BTreeMap::new(); + for (k, v) in Self::unsorted_from_dir(dir)?.drain() { + children.insert(k, v); + } + + Ok(Self { children }) + } + + /// Determine the changes *from* self to the updated tree + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn diff(&self, updated: &Self) -> Result { + self.diff_impl(updated, true) + } + + /// Determine any changes only using the files tracked in self as + /// a reference. In other words, this will ignore any unknown + /// files and not count them as additions. + #[cfg(test)] + pub(crate) fn changes(&self, current: &Self) -> Result { + self.diff_impl(current, false) + } + + /// The inverse of `changes` - determine if there are any files + /// changed or added in `current` compared to self. + #[cfg(test)] + pub(crate) fn updates(&self, current: &Self) -> Result { + current.diff_impl(self, false) + } + + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + fn diff_impl(&self, updated: &Self, check_additions: bool) -> Result { + let mut additions = HashSet::new(); + let mut removals = HashSet::new(); + let mut changes = HashSet::new(); + + for (k, v1) in self.children.iter() { + if let Some(v2) = updated.children.get(k) { + if v1 != v2 { + changes.insert(k.clone()); + } + } else { + removals.insert(k.clone()); + } + } + if check_additions { + for k in updated.children.keys() { + if self.children.contains_key(k) { + continue; + } + additions.insert(k.clone()); + } + } + Ok(FileTreeDiff { + additions, + removals, + changes, + }) + } + + /// Create a diff from a target directory. This will ignore + /// any files or directories that are not part of the original tree. + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn relative_diff_to(&self, dir: &openat::Dir) -> Result { + let mut removals = HashSet::new(); + let mut changes = HashSet::new(); + + for (path, info) in self.children.iter() { + assert!(!path.starts_with('/')); + + if let Some(meta) = dir.metadata_optional(path)? { + match meta.simple_type() { + openat::SimpleType::File => { + let target_info = FileMetadata::new_from_path(dir, path)?; + if info != &target_info { + changes.insert(path.clone()); + } + } + _ => { + // If a file became a directory + changes.insert(path.clone()); + } + } + } else { + removals.insert(path.clone()); + } + } + Ok(FileTreeDiff { + additions: HashSet::new(), + removals, + changes, + }) + } +} + +// Recursively remove all files/dirs in the directory that start with our TMP_PREFIX +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +fn cleanup_tmp(dir: &openat::Dir) -> Result<()> { + for entry in dir.list_dir(".")? { + let entry = entry?; + let Some(name) = entry.file_name().to_str() else { + // Skip invalid UTF-8 for now, we will barf on it later though. + continue; + }; + + match dir.get_file_type(&entry)? { + openat::SimpleType::Dir => { + if name.starts_with(TMP_PREFIX) { + dir.remove_all(name)?; + continue; + } else { + let child = dir.sub_dir(name)?; + cleanup_tmp(&child)?; + } + } + openat::SimpleType::File => { + if name.starts_with(TMP_PREFIX) { + dir.remove_file(name)?; + } + } + _ => {} + } + } + Ok(()) +} + +#[derive(Default, Clone)] +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) struct ApplyUpdateOptions { + pub(crate) skip_removals: bool, + pub(crate) skip_sync: bool, +} + +/// Copy from src to dst at root dir +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +fn copy_dir(root: &openat::Dir, src: &str, dst: &str) -> Result<()> { + use bootc_internal_utils::CommandRunExt; + use std::os::unix::process::CommandExt; + use std::process::Command; + + let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) }; + unsafe { + Command::new("cp") + .args(["-a"]) + .arg(src) + .arg(dst) + .pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into)) + .run()? + }; + log::debug!("Copy {src} to {dst}"); + Ok(()) +} + +/// Get first sub dir and tmp sub dir for the path +/// "fedora/foo/bar" -> ("fedora", ".btmp.fedora") +/// "foo" -> ("foo", ".btmp.foo") +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +fn get_first_dir(path: &Utf8Path) -> Result<(&Utf8Path, String)> { + let first = path + .iter() + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid path: {path}"))?; + let mut tmp = first.to_owned(); + tmp.insert_str(0, TMP_PREFIX); + Ok((first.into(), tmp)) +} + +/// Given two directories, apply a diff generated from srcdir to destdir +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) fn apply_diff( + srcdir: &openat::Dir, + destdir: &openat::Dir, + diff: &FileTreeDiff, + opts: Option<&ApplyUpdateOptions>, +) -> Result<()> { + let default_opts = ApplyUpdateOptions { + ..Default::default() + }; + let opts = opts.unwrap_or(&default_opts); + cleanup_tmp(destdir).context("cleaning up temporary files")?; + + let mut updates = HashMap::new(); + // Handle removals in temp dir, or remove directly if file not in dir + if !opts.skip_removals { + for pathstr in diff.removals.iter() { + let path = Utf8Path::new(pathstr); + let (first_dir, first_dir_tmp) = get_first_dir(path)?; + let path_tmp; + if first_dir != path { + path_tmp = Utf8Path::new(&first_dir_tmp).join(path.strip_prefix(&first_dir)?); + // copy to temp dir and remember + // skip copying if dir not existed in dest + if !destdir.exists(&first_dir_tmp)? && destdir.exists(first_dir.as_std_path())? { + copy_dir(destdir, first_dir.as_str(), &first_dir_tmp).with_context(|| { + format!("copy {first_dir} to {first_dir_tmp} before removing {pathstr}") + })?; + updates.insert(first_dir, first_dir_tmp); + } + } else { + path_tmp = path.to_path_buf(); + } + destdir + .remove_file_optional(path_tmp.as_std_path()) + .with_context(|| format!("removing {:?}", path_tmp))?; + } + } + // Write changed or new files to temp dir or temp file + for pathstr in diff.changes.iter().chain(diff.additions.iter()) { + let path = Utf8Path::new(pathstr); + let (first_dir, first_dir_tmp) = get_first_dir(path)?; + let mut path_tmp = Utf8PathBuf::from(&first_dir_tmp); + if first_dir != path { + if !destdir.exists(&first_dir_tmp)? && destdir.exists(first_dir.as_std_path())? { + // copy to temp dir if not exists + copy_dir(destdir, first_dir.as_str(), &first_dir_tmp).with_context(|| { + format!("copy {first_dir} to {first_dir_tmp} before updating {pathstr}") + })?; + } + path_tmp = path_tmp.join(path.strip_prefix(&first_dir)?); + // ensure new additions dir exists + if let Some(parent) = path_tmp.parent() { + destdir.ensure_dir_all(parent.as_std_path(), DEFAULT_FILE_MODE)?; + } + // remove changed file before copying + destdir + .remove_file_optional(path_tmp.as_std_path()) + .with_context(|| format!("removing {path_tmp} before copying"))?; + } + updates.insert(first_dir, first_dir_tmp); + srcdir + .copy_file_at(path.as_std_path(), destdir, path_tmp.as_std_path()) + .with_context(|| format!("copying {:?} to {:?}", path, path_tmp))?; + } + + // do local exchange or rename + for (dst, tmp) in updates.iter() { + let dst = dst.as_std_path(); + log::trace!("doing local exchange for {} and {:?}", tmp, dst); + if destdir.exists(dst)? { + destdir + .local_exchange(tmp, dst) + .with_context(|| format!("exchange for {} and {:?}", tmp, dst))?; + } else { + destdir + .local_rename(tmp, dst) + .with_context(|| format!("rename for {} and {:?}", tmp, dst))?; + } + crate::try_fail_point!("update::exchange"); + } + // Ensure all of the updates & changes are written persistently to disk + if !opts.skip_sync { + destdir.syncfs()?; + } + + // finally remove the temp dir + for (_, tmp) in updates.iter() { + log::trace!("cleanup: {}", tmp); + destdir.remove_all(tmp).context("clean up temp")?; + } + // A second full filesystem sync to narrow any races rather than + // waiting for writeback to kick in. + if !opts.skip_sync { + fsfreeze_thaw_cycle(destdir.open_file(".")?)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use std::path::Path; + + fn run_diff(a: &openat::Dir, b: &openat::Dir) -> Result { + let ta = FileTree::new_from_dir(a)?; + let tb = FileTree::new_from_dir(b)?; + let diff = ta.diff(&tb)?; + Ok(diff) + } + + fn test_one_apply, BP: AsRef>( + a: AP, + b: BP, + opts: Option<&ApplyUpdateOptions>, + ) -> Result<()> { + let a = a.as_ref(); + let b = b.as_ref(); + let t = tempfile::tempdir()?; + let c = t.path().join("c"); + let r = std::process::Command::new("cp") + .arg("-rp") + .args([a, &c]) + .status()?; + if !r.success() { + bail!("failed to cp"); + }; + let c = openat::Dir::open(&c)?; + let da = openat::Dir::open(a)?; + let db = openat::Dir::open(b)?; + let ta = FileTree::new_from_dir(&da)?; + let tb = FileTree::new_from_dir(&db)?; + let diff = ta.diff(&tb)?; + let rdiff = tb.diff(&ta)?; + assert_eq!(diff.count(), rdiff.count()); + assert_eq!(diff.additions.len(), rdiff.removals.len()); + assert_eq!(diff.changes.len(), rdiff.changes.len()); + apply_diff(&db, &c, &diff, opts)?; + let tc = FileTree::new_from_dir(&c)?; + let newdiff = tb.diff(&tc)?; + let skip_removals = opts.map(|o| o.skip_removals).unwrap_or(false); + if skip_removals { + let n = newdiff.count(); + if n != 0 { + assert_eq!(n, diff.removals.len()); + } + for f in diff.removals.iter() { + assert!(c.exists(f)?); + assert!(da.exists(f)?); + } + } else { + assert_eq!(newdiff.count(), 0); + } + Ok(()) + } + + fn test_apply, BP: AsRef>(a: AP, b: BP) -> Result<()> { + let a = a.as_ref(); + let b = b.as_ref(); + let skip_removals = ApplyUpdateOptions { + skip_removals: true, + ..Default::default() + }; + test_one_apply(a, b, None).context("testing apply (with removals)")?; + test_one_apply(a, b, Some(&skip_removals)).context("testing apply (skipping removals)")?; + Ok(()) + } + + #[test] + fn test_filetree() -> Result<()> { + let tmpd = tempfile::tempdir()?; + let p = tmpd.path(); + let pa = p.join("a"); + let pb = p.join("b"); + std::fs::create_dir(&pa)?; + std::fs::create_dir(&pb)?; + let a = openat::Dir::open(&pa)?; + let b = openat::Dir::open(&pb)?; + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 0); + a.create_dir("foo", 0o755)?; + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 0); + { + let mut bar = a.write_file("foo/bar", 0o644)?; + bar.write_all("foobarcontents".as_bytes())?; + } + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 1); + assert_eq!(diff.removals.len(), 1); + let ta = FileTree::new_from_dir(&a)?; + let tb = FileTree::new_from_dir(&b)?; + let cdiff = ta.changes(&tb)?; + assert_eq!(cdiff.count(), 1); + assert_eq!(cdiff.removals.len(), 1); + let udiff = ta.updates(&tb)?; + assert_eq!(udiff.count(), 0); + test_apply(&pa, &pb).context("testing apply 1")?; + let rdiff = ta.relative_diff_to(&b)?; + assert_eq!(rdiff.removals.len(), cdiff.removals.len()); + + b.create_dir("foo", 0o755)?; + { + let mut bar = b.write_file("foo/bar", 0o644)?; + bar.write_all("foobarcontents".as_bytes())?; + } + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 0); + test_apply(&pa, &pb).context("testing apply 2")?; + { + let mut bar2 = b.write_file("foo/bar", 0o644)?; + bar2.write_all("foobarcontents2".as_bytes())?; + } + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 1); + assert_eq!(diff.changes.len(), 1); + let ta = FileTree::new_from_dir(&a)?; + let rdiff = ta.relative_diff_to(&b)?; + assert_eq!(rdiff.count(), diff.count()); + assert_eq!(rdiff.changes.len(), diff.changes.len()); + test_apply(&pa, &pb).context("testing apply 3")?; + Ok(()) + } + + #[test] + fn test_filetree2() -> Result<()> { + let tmpd = tempfile::tempdir()?; + let tmpdp = tmpd.path(); + let relp = "EFI/fedora"; + let a = tmpdp.join("a"); + let b = tmpdp.join("b"); + for d in &[&a, &b] { + let efidir = d.join(relp); + fs::create_dir_all(&efidir)?; + let shimdata = "shim data"; + fs::write(efidir.join("shim.x64"), shimdata)?; + let grubdata = "grub data"; + fs::write(efidir.join("grub.x64"), grubdata)?; + } + fs::write(b.join(relp).join("grub.x64"), "grub data 2")?; + let newsubp = Path::new(relp).join("subdir"); + fs::create_dir_all(b.join(&newsubp))?; + fs::write(b.join(&newsubp).join("newgrub.x64"), "newgrub data")?; + fs::remove_file(b.join(relp).join("shim.x64"))?; + { + let a = openat::Dir::open(&a)?; + let b = openat::Dir::open(&b)?; + let ta = FileTree::new_from_dir(&a)?; + let tb = FileTree::new_from_dir(&b)?; + let diff = ta.diff(&tb)?; + assert_eq!(diff.changes.len(), 1); + assert_eq!(diff.additions.len(), 1); + assert_eq!(diff.count(), 3); + super::apply_diff(&b, &a, &diff, None)?; + } + assert_eq!( + String::from_utf8(std::fs::read(a.join(relp).join("grub.x64"))?)?, + "grub data 2" + ); + assert_eq!( + String::from_utf8(std::fs::read(a.join(&newsubp).join("newgrub.x64"))?)?, + "newgrub data" + ); + assert!(!a.join(relp).join("shim.x64").exists()); + Ok(()) + } + #[test] + fn test_get_first_dir() -> Result<()> { + // test path + let path = Utf8Path::new("foo/subdir/bar"); + let (tp, tp_tmp) = get_first_dir(path)?; + assert_eq!(tp, Utf8Path::new("foo")); + assert_eq!(tp_tmp, ".btmp.foo"); + // test file + let path = Utf8Path::new("testfile"); + let (tp, tp_tmp) = get_first_dir(path)?; + assert_eq!(tp, Utf8Path::new("testfile")); + assert_eq!(tp_tmp, ".btmp.testfile"); + Ok(()) + } + #[test] + fn test_cleanup_tmp() -> Result<()> { + let tmpd = tempfile::tempdir()?; + let p = tmpd.path(); + let pa = p.join("a/.btmp.a"); + let pb = p.join(".btmp.b/b"); + std::fs::create_dir_all(&pa)?; + std::fs::create_dir_all(&pb)?; + let dp = openat::Dir::open(p)?; + { + let mut buf = dp.write_file("a/foo", 0o644)?; + buf.write_all("foocontents".as_bytes())?; + let mut buf = dp.write_file("a/.btmp.foo", 0o644)?; + buf.write_all("foocontents".as_bytes())?; + let mut buf = dp.write_file(".btmp.b/foo", 0o644)?; + buf.write_all("foocontents".as_bytes())?; + } + assert!(dp.exists("a/.btmp.a")?); + assert!(dp.exists("a/foo")?); + assert!(dp.exists("a/.btmp.foo")?); + assert!(dp.exists("a/.btmp.a")?); + assert!(dp.exists(".btmp.b/b")?); + assert!(dp.exists(".btmp.b/foo")?); + cleanup_tmp(&dp)?; + assert!(!dp.exists("a/.btmp.a")?); + assert!(dp.exists("a/foo")?); + assert!(!dp.exists("a/.btmp.foo")?); + assert!(!dp.exists(".btmp.b")?); + Ok(()) + } + // Waiting on https://github.com/rust-lang/rust/pull/125692 + #[cfg(not(target_env = "musl"))] + #[test] + fn test_apply_with_file() -> Result<()> { + let tmpd = tempfile::tempdir()?; + let p = tmpd.path(); + let pa = p.join("a"); + let pb = p.join("b"); + std::fs::create_dir(&pa)?; + std::fs::create_dir(&pb)?; + let a = openat::Dir::open(&pa)?; + let b = openat::Dir::open(&pb)?; + a.create_dir("foo", 0o755)?; + a.create_dir("bar", 0o755)?; + let foo = Path::new("foo/bar"); + let bar = Path::new("bar/foo"); + let testfile = "testfile"; + { + let mut buf = a.write_file(foo, 0o644)?; + buf.write_all("foocontents".as_bytes())?; + let mut buf = a.write_file(bar, 0o644)?; + buf.write_all("barcontents".as_bytes())?; + let mut buf = a.write_file(testfile, 0o644)?; + buf.write_all("testfilecontents".as_bytes())?; + } + + let diff = run_diff(&a, &b)?; + assert_eq!(diff.count(), 3); + b.create_dir("foo", 0o755)?; + { + let mut buf = b.write_file(foo, 0o644)?; + buf.write_all("foocontents".as_bytes())?; + } + let b_btime_foo = fs::metadata(pb.join(foo))?.created()?; + + { + let diff = run_diff(&b, &a)?; + assert_eq!(diff.count(), 2); + apply_diff(&a, &b, &diff, None).context("test additional files")?; + assert_eq!( + String::from_utf8(std::fs::read(pb.join(testfile))?)?, + "testfilecontents" + ); + assert_eq!( + String::from_utf8(std::fs::read(pb.join(bar))?)?, + "barcontents" + ); + // creation time is not changed for unchanged file + let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?; + assert_eq!(b_btime_foo_new, b_btime_foo); + } + { + fs::write(pa.join(testfile), "newtestfile")?; + fs::write(pa.join(bar), "newbar")?; + let diff = run_diff(&b, &a)?; + assert_eq!(diff.count(), 2); + apply_diff(&a, &b, &diff, None).context("test changed files")?; + assert_eq!( + String::from_utf8(std::fs::read(pb.join(testfile))?)?, + "newtestfile" + ); + assert_eq!(String::from_utf8(std::fs::read(pb.join(bar))?)?, "newbar"); + // creation time is not changed for unchanged file + let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?; + assert_eq!(b_btime_foo_new, b_btime_foo); + } + { + b.remove_file(testfile)?; + let ta = FileTree::new_from_dir(&a)?; + let diff = ta.relative_diff_to(&b)?; + assert_eq!(diff.removals.len(), 1); + apply_diff(&a, &b, &diff, None).context("test removed files with relative_diff")?; + assert_eq!(b.exists(testfile)?, false); + } + { + a.remove_file(bar)?; + let diff = run_diff(&b, &a)?; + assert_eq!(diff.count(), 2); + apply_diff(&a, &b, &diff, None).context("test removed files")?; + assert_eq!(b.exists(testfile)?, true); + assert_eq!(b.exists(bar)?, false); + let diff = run_diff(&b, &a)?; + assert_eq!(diff.count(), 0); + // creation time is not changed for unchanged file + let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?; + assert_eq!(b_btime_foo_new, b_btime_foo); + } + Ok(()) + } +} diff --git a/bootupd/src/freezethaw.rs b/bootupd/src/freezethaw.rs new file mode 100755 index 0000000..7904a41 --- /dev/null +++ b/bootupd/src/freezethaw.rs @@ -0,0 +1,50 @@ +use rustix::fd::AsFd; +use rustix::ffi as c; +use rustix::io::Errno; +use rustix::ioctl::opcode; +use rustix::{io, ioctl}; + +use crate::util::SignalTerminationGuard; + +fn ioctl_fifreeze(fd: Fd) -> io::Result<()> { + // SAFETY: `FIFREEZE` is a no-argument opcode. + // `FIFREEZE` is defined as `_IOWR('X', 119, int)`. + unsafe { + let ctl = ioctl::NoArg::<{ opcode::read_write::(b'X', 119) }>::new(); + ioctl::ioctl(fd, ctl) + } +} + +fn ioctl_fithaw(fd: Fd) -> io::Result<()> { + // SAFETY: `FITHAW` is a no-argument opcode. + // `FITHAW` is defined as `_IOWR('X', 120, int)`. + unsafe { + let ctl = ioctl::NoArg::<{ opcode::read_write::(b'X', 120) }>::new(); + ioctl::ioctl(fd, ctl) + } +} + +/// syncfs() doesn't flush the journal on XFS, +/// and since GRUB2 can't read the XFS journal, the system +/// could fail to boot. +/// +/// http://marc.info/?l=linux-fsdevel&m=149520244919284&w=2 +/// https://github.com/ostreedev/ostree/pull/1049 +/// +/// This function always call syncfs() first, then calls +/// `ioctl(FIFREEZE)` and `ioctl(FITHAW)`, ignoring `EOPNOTSUPP` and `EPERM` +pub(crate) fn fsfreeze_thaw_cycle(fd: Fd) -> anyhow::Result<()> { + rustix::fs::syncfs(&fd)?; + + let _guard = SignalTerminationGuard::new()?; + + let freeze = ioctl_fifreeze(&fd); + match freeze { + // Ignore permissions errors (tests) + Err(Errno::PERM) => Ok(()), + // Ignore unsupported FS + Err(Errno::NOTSUP) => Ok(()), + Ok(()) => Ok(ioctl_fithaw(fd)?), + _ => Ok(freeze?), + } +} diff --git a/bootupd/src/grub2/README.md b/bootupd/src/grub2/README.md new file mode 100755 index 0000000..79d9b88 --- /dev/null +++ b/bootupd/src/grub2/README.md @@ -0,0 +1,3 @@ +# Static GRUB configuration files + +These static files were taken from https://github.com/coreos/coreos-assembler/blob/5824720ec3a9ec291532b23b349b6d8d8b2e9edd/src/grub.cfg diff --git a/bootupd/src/grub2/configs.d/01_users.cfg b/bootupd/src/grub2/configs.d/01_users.cfg new file mode 100755 index 0000000..beca41e --- /dev/null +++ b/bootupd/src/grub2/configs.d/01_users.cfg @@ -0,0 +1,10 @@ +# Keep the comment for grub2-set-password +### BEGIN /etc/grub.d/01_users ### +if [ -f ${prefix}/user.cfg ]; then + source ${prefix}/user.cfg + if [ -n "${GRUB2_PASSWORD}" ]; then + set superusers="root" + export superusers + password_pbkdf2 root ${GRUB2_PASSWORD} + fi +fi diff --git a/bootupd/src/grub2/configs.d/10_blscfg.cfg b/bootupd/src/grub2/configs.d/10_blscfg.cfg new file mode 100755 index 0000000..f3945a9 --- /dev/null +++ b/bootupd/src/grub2/configs.d/10_blscfg.cfg @@ -0,0 +1 @@ +blscfg diff --git a/bootupd/src/grub2/configs.d/14_menu_show_once.cfg b/bootupd/src/grub2/configs.d/14_menu_show_once.cfg new file mode 100755 index 0000000..8f170bf --- /dev/null +++ b/bootupd/src/grub2/configs.d/14_menu_show_once.cfg @@ -0,0 +1,8 @@ +# Force the menu to be shown once, with a timeout of ${menu_show_once_timeout} +# if requested by ${menu_show_once_timeout} being set in the env. +if [ "${menu_show_once_timeout}" ]; then + set timeout_style=menu + set timeout="${menu_show_once_timeout}" + unset menu_show_once_timeout + save_env menu_show_once_timeout +fi diff --git a/bootupd/src/grub2/configs.d/30_uefi-firmware.cfg b/bootupd/src/grub2/configs.d/30_uefi-firmware.cfg new file mode 100755 index 0000000..fa9fade --- /dev/null +++ b/bootupd/src/grub2/configs.d/30_uefi-firmware.cfg @@ -0,0 +1,5 @@ +if [ "$grub_platform" = "efi" ]; then + menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' { + fwsetup + } +fi diff --git a/bootupd/src/grub2/configs.d/41_custom.cfg b/bootupd/src/grub2/configs.d/41_custom.cfg new file mode 100755 index 0000000..80c2f91 --- /dev/null +++ b/bootupd/src/grub2/configs.d/41_custom.cfg @@ -0,0 +1,3 @@ +if [ -f $prefix/custom.cfg ]; then + source $prefix/custom.cfg +fi diff --git a/bootupd/src/grub2/configs.d/README.md b/bootupd/src/grub2/configs.d/README.md new file mode 100755 index 0000000..a278f52 --- /dev/null +++ b/bootupd/src/grub2/configs.d/README.md @@ -0,0 +1,4 @@ +Add drop-in grub fragments into this directory to have +them be installed into the final config. + +The filenames must end in `.cfg`. diff --git a/bootupd/src/grub2/grub-static-efi.cfg b/bootupd/src/grub2/grub-static-efi.cfg new file mode 100755 index 0000000..3d552c3 --- /dev/null +++ b/bootupd/src/grub2/grub-static-efi.cfg @@ -0,0 +1,24 @@ +if [ -e (md/md-boot) ]; then + # The search command might pick a RAID component rather than the RAID, + # since the /boot RAID currently uses superblock 1.0. See the comment in + # the main grub.cfg. + set prefix=md/md-boot +else + if [ -f ${config_directory}/bootuuid.cfg ]; then + source ${config_directory}/bootuuid.cfg + fi + if [ -n "${BOOT_UUID}" ]; then + search --fs-uuid "${BOOT_UUID}" --set prefix --no-floppy + else + search --label boot --set prefix --no-floppy + fi +fi +if [ -d ($prefix)/grub2 ]; then + set prefix=($prefix)/grub2 + configfile $prefix/grub.cfg +else + set prefix=($prefix)/boot/grub2 + configfile $prefix/grub.cfg +fi +boot + diff --git a/bootupd/src/grub2/grub-static-pre.cfg b/bootupd/src/grub2/grub-static-pre.cfg new file mode 100755 index 0000000..a092008 --- /dev/null +++ b/bootupd/src/grub2/grub-static-pre.cfg @@ -0,0 +1,55 @@ +# This file is copied from https://github.com/coreos/coreos-assembler/blob/0eb25d1c718c88414c0b9aedd19dc56c09afbda8/src/grub.cfg +# Changes: +# - Dropped Ignition glue, that can be injected into platform.cfg +# petitboot doesn't support -e and doesn't support an empty path part +if [ -d (md/md-boot)/grub2 ]; then + # fcct currently creates /boot RAID with superblock 1.0, which allows + # component partitions to be read directly as filesystems. This is + # necessary because transposefs doesn't yet rerun grub2-install on BIOS, + # so GRUB still expects /boot to be a partition on the first disk. + # + # There are two consequences: + # 1. On BIOS and UEFI, the search command might pick an individual RAID + # component, but we want it to use the full RAID in case there are bad + # sectors etc. The undocumented --hint option is supposed to support + # this sort of override, but it doesn't seem to work, so we set $boot + # directly. + # 2. On BIOS, the "normal" module has already been loaded from an + # individual RAID component, and $prefix still points there. We want + # future module loads to come from the RAID, so we reset $prefix. + # (On UEFI, the stub grub.cfg has already set $prefix properly.) + set boot=md/md-boot + set prefix=($boot)/grub2 +else + if [ -f ${config_directory}/bootuuid.cfg ]; then + source ${config_directory}/bootuuid.cfg + fi + if [ -n "${BOOT_UUID}" ]; then + search --fs-uuid "${BOOT_UUID}" --set boot --no-floppy + else + search --label boot --set boot --no-floppy + fi +fi +set root=$boot + +if [ -f ${config_directory}/grubenv ]; then + load_env -f ${config_directory}/grubenv +elif [ -s $prefix/grubenv ]; then + load_env +fi + +if [ -f $prefix/console.cfg ]; then + # Source in any GRUB console settings if provided by the user/platform + source $prefix/console.cfg +fi + +menuentry_id_option="--id" + +function load_video { + insmod all_video +} + +set timeout_style=menu +set timeout=1 + +# Other package code will be injected from here diff --git a/bootupd/src/grubconfigs.rs b/bootupd/src/grubconfigs.rs new file mode 100755 index 0000000..f686f95 --- /dev/null +++ b/bootupd/src/grubconfigs.rs @@ -0,0 +1,180 @@ +use std::fmt::Write; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use bootc_internal_utils::CommandRunExt; +use fn_error_context::context; +use openat_ext::OpenatDirExt; + +use crate::freezethaw::fsfreeze_thaw_cycle; + +/// The subdirectory of /boot we use +const GRUB2DIR: &str = "grub2"; +const CONFIGDIR: &str = "/usr/lib/bootupd/grub2-static"; +const DROPINDIR: &str = "configs.d"; +// The related grub files +const GRUBENV: &str = "grubenv"; +pub(crate) const GRUBCONFIG: &str = "grub.cfg"; +pub(crate) const GRUBCONFIG_BACKUP: &str = "grub.cfg.backup"; +// File mode for /boot/grub2/grub.config +// https://github.com/coreos/bootupd/issues/952 +const GRUBCONFIG_FILE_MODE: u32 = 0o600; + +/// Install the static GRUB config files. +#[context("Installing static GRUB configs")] +pub(crate) fn install( + target_root: &openat::Dir, + installed_efi_vendor: Option<&str>, + write_uuid: bool, +) -> Result<()> { + let bootdir = &target_root.sub_dir("boot").context("Opening /boot")?; + let boot_is_mount = { + let root_dev = target_root.self_metadata()?.stat().st_dev; + let boot_dev = bootdir.self_metadata()?.stat().st_dev; + log::debug!("root_dev={root_dev} boot_dev={boot_dev}"); + root_dev != boot_dev + }; + + if !bootdir.exists(GRUB2DIR)? { + bootdir.create_dir(GRUB2DIR, 0o700)?; + } + + let mut config = String::from("# Generated by bootupd / do not edit\n\n"); + + let pre = std::fs::read_to_string(Path::new(CONFIGDIR).join("grub-static-pre.cfg"))?; + config.push_str(pre.as_str()); + + let dropindir = openat::Dir::open(&Path::new(CONFIGDIR).join(DROPINDIR))?; + // Sort the files for reproducibility + let mut entries = dropindir + .list_dir(".")? + .map(|e| e.map_err(anyhow::Error::msg)) + .collect::>>()?; + entries.sort_by(|a, b| a.file_name().cmp(b.file_name())); + for ent in entries { + let name = ent.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow!("Invalid UTF-8: {name:?}"))?; + if !name.ends_with(".cfg") { + log::debug!("Ignoring {name}"); + continue; + } + writeln!(config, "\n### BEGIN {name} ###")?; + let dropin = std::fs::read_to_string(Path::new(CONFIGDIR).join(DROPINDIR).join(name))?; + config.push_str(dropin.as_str()); + writeln!(config, "### END {name} ###")?; + println!("Added {name}"); + } + + let grub2dir = bootdir.sub_dir(GRUB2DIR)?; + grub2dir + .write_file_contents("grub.cfg", GRUBCONFIG_FILE_MODE, config.as_bytes()) + .context("Copying grub-static.cfg")?; + println!("Installed: grub.cfg"); + + write_grubenv(&bootdir).context("Create grubenv")?; + + let uuid_path = if write_uuid { + let target_fs = if boot_is_mount { bootdir } else { target_root }; + let bootfs_meta = crate::filesystem::inspect_filesystem(target_fs, ".")?; + let bootfs_uuid = bootfs_meta + .uuid + .ok_or_else(|| anyhow::anyhow!("Failed to find UUID for boot"))?; + let grub2_uuid_contents = format!("set BOOT_UUID=\"{bootfs_uuid}\"\n"); + let uuid_path = "bootuuid.cfg"; + grub2dir + .write_file_contents(uuid_path, 0o644, grub2_uuid_contents) + .context("Writing bootuuid.cfg")?; + println!("Installed: bootuuid.cfg"); + Some(uuid_path) + } else { + None + }; + + fsfreeze_thaw_cycle(grub2dir.open_file(".")?)?; + + if let Some(vendordir) = installed_efi_vendor { + log::debug!("vendordir={:?}", &vendordir); + let vendor = PathBuf::from(vendordir); + let target = &vendor.join("grub.cfg"); + let dest_efidir = target_root + .sub_dir_optional("boot/efi/EFI") + .context("Opening /boot/efi/EFI")?; + if let Some(efidir) = dest_efidir { + efidir + .copy_file(&Path::new(CONFIGDIR).join("grub-static-efi.cfg"), target) + .context("Copying static EFI")?; + println!("Installed: {target:?}"); + if let Some(uuid_path) = uuid_path { + let target = &vendor.join(uuid_path); + grub2dir + .copy_file_at(uuid_path, &efidir, target) + .context("Writing bootuuid.cfg to efi dir")?; + println!("Installed: {target:?}"); + } + fsfreeze_thaw_cycle(efidir.open_file(".")?)?; + } else { + println!("Could not find /boot/efi/EFI when installing {target:?}"); + } + } + + Ok(()) +} + +#[context("Create file boot/grub2/grubenv")] +fn write_grubenv(bootdir: &openat::Dir) -> Result<()> { + let grubdir = &bootdir.sub_dir(GRUB2DIR).context("Opening boot/grub2")?; + + if grubdir.exists(GRUBENV)? { + return Ok(()); + } + let editenv = Path::new("/usr/bin/grub2-editenv"); + if !editenv.exists() { + anyhow::bail!("Failed to find {:?}", editenv); + } + + std::process::Command::new(editenv) + .args([GRUBENV, "create"]) + .current_dir(format!("/proc/self/fd/{}", grubdir.as_raw_fd())) + .run_with_cmd_context() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] + fn test_install() -> Result<()> { + env_logger::init(); + let td = tempfile::tempdir()?; + let tdp = td.path(); + let td = openat::Dir::open(tdp)?; + std::fs::create_dir_all(tdp.join("boot/grub2"))?; + std::fs::create_dir_all(tdp.join("boot/efi/EFI/BOOT"))?; + std::fs::create_dir_all(tdp.join("boot/efi/EFI/fedora"))?; + install(&td, Some("fedora"), false).unwrap(); + + assert!(td.exists("boot/grub2/grub.cfg")?); + assert!(td.exists("boot/efi/EFI/fedora/grub.cfg")?); + Ok(()) + } + #[test] + fn test_write_grubenv() -> Result<()> { + // Skip this test if grub2-editenv is not installed + let editenv = Path::new("/usr/bin/grub2-editenv"); + if !editenv.try_exists()? { + return Ok(()); + } + let td = tempfile::tempdir()?; + let tdp = td.path(); + std::fs::create_dir_all(tdp.join("boot/grub2"))?; + let td = openat::Dir::open(&tdp.join("boot"))?; + write_grubenv(&td)?; + + assert!(td.exists("grub2/grubenv")?); + Ok(()) + } +} diff --git a/bootupd/src/main.rs b/bootupd/src/main.rs new file mode 100755 index 0000000..5554366 --- /dev/null +++ b/bootupd/src/main.rs @@ -0,0 +1,84 @@ +/*! +**Boot**loader **upd**ater. + +This is an early prototype hidden/not-yet-standardized mechanism +which just updates EFI for now (x86_64/aarch64/riscv64 only). + +But in the future will hopefully gain some independence from +ostree and also support e.g. updating the MBR etc. + +Refs: + * +!*/ + +#![deny(unused_must_use)] +// The style lints are more annoying than useful +#![allow(clippy::style)] +#![deny(clippy::dbg_macro)] + +mod backend; +#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] +mod bios; +mod blockdev; +mod bootupd; +mod cli; +mod component; +mod coreos; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +mod efi; +mod failpoints; +mod filesystem; +mod filetree; +mod freezethaw; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64", + target_arch = "riscv64" +))] +mod grubconfigs; +mod model; +mod model_legacy; +mod ostreeutil; +mod packagesystem; +mod sha512string; +mod util; + +use clap::crate_name; + +/// Binary entrypoint, for both daemon and client logic. +fn main() { + let _scenario = fail::FailScenario::setup(); + let exit_code = run_cli(); + std::process::exit(exit_code); +} + +/// CLI logic. +fn run_cli() -> i32 { + // Parse command-line options. + let args: Vec<_> = std::env::args().collect(); + let cli_opts = cli::MultiCall::from_args(args); + + // Setup logging. + env_logger::Builder::from_default_env() + .format_timestamp(None) + .format_module_path(false) + .filter(Some(crate_name!()), cli_opts.loglevel()) + .init(); + + log::trace!("executing cli"); + + // Dispatch CLI subcommand. + match cli_opts.run() { + Ok(_) => libc::EXIT_SUCCESS, + Err(e) => { + // Use the alternative formatter to get everything on a single line... it reads better. + eprintln!("error: {:#}", e); + libc::EXIT_FAILURE + } + } +} diff --git a/bootupd/src/model.rs b/bootupd/src/model.rs new file mode 100755 index 0000000..86b866a --- /dev/null +++ b/bootupd/src/model.rs @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The directory where updates are stored +pub(crate) const BOOTUPD_UPDATES_DIR: &str = "usr/lib/bootupd/updates"; + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct ContentMetadata { + /// The timestamp, which is used to determine update availability + pub(crate) timestamp: DateTime, + /// Human readable version number, like ostree it is not ever parsed, just displayed + pub(crate) version: String, +} + +impl ContentMetadata { + /// Returns `true` if `target` is different and chronologically newer + pub(crate) fn can_upgrade_to(&self, target: &Self) -> bool { + if self.version == target.version { + return false; + } + target.timestamp > self.timestamp + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct InstalledContent { + /// Associated metadata + pub(crate) meta: ContentMetadata, + /// Human readable version number, like ostree it is not ever parsed, just displayed + pub(crate) filetree: Option, + /// The version this was originally adopted from + pub(crate) adopted_from: Option, +} + +/// Will be serialized into /boot/bootupd-state.json +#[derive(Serialize, Deserialize, Default, Debug)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct SavedState { + /// Maps a component name to its currently installed version + pub(crate) installed: BTreeMap, + /// Maps a component name to an in progress update + pub(crate) pending: Option>, + /// If static bootloader configs are enabled, this contains the version + pub(crate) static_configs: Option, +} + +/// The status of an individual component. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ComponentUpdatable { + NoUpdateAvailable, + AtLatestVersion, + Upgradable, + WouldDowngrade, +} + +impl ComponentUpdatable { + pub(crate) fn from_metadata(from: &ContentMetadata, to: Option<&ContentMetadata>) -> Self { + match to { + Some(to) => { + if from.version == to.version { + ComponentUpdatable::AtLatestVersion + } else if from.can_upgrade_to(to) { + ComponentUpdatable::Upgradable + } else { + ComponentUpdatable::WouldDowngrade + } + } + None => ComponentUpdatable::NoUpdateAvailable, + } + } +} + +/// The status of an individual component. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct ComponentStatus { + /// Currently installed version + pub(crate) installed: ContentMetadata, + /// In progress update that was interrupted + pub(crate) interrupted: Option, + /// Update in the deployed filesystem tree + pub(crate) update: Option, + /// Is true if the version in `update` is different from `installed` + pub(crate) updatable: ComponentUpdatable, + /// Originally adopted version + pub(crate) adopted_from: Option, +} + +/// Information on a component that can be adopted +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Adoptable { + /// A synthetic version + pub(crate) version: ContentMetadata, + /// True if we are likely to be able to reliably update this system + pub(crate) confident: bool, +} + +/// Representation of bootupd's worldview at a point in time. +/// This is intended to be a stable format that is output by `bootupctl status --json` +/// and parsed by higher level management tools. Transitively then +/// everything referenced from here should also be stable. +#[derive(Serialize, Deserialize, Default, Debug)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct Status { + /// Maps a component name to status + pub(crate) components: BTreeMap, + /// Components that appear to be installed, not via bootupd + pub(crate) adoptable: BTreeMap, +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + use chrono::Duration; + + #[test] + fn test_meta_compare() { + let t = Utc::now(); + let a = ContentMetadata { + timestamp: t, + version: "v1".into(), + }; + let b = ContentMetadata { + timestamp: t + Duration::try_seconds(1).unwrap(), + version: "v2".into(), + }; + assert!(a.can_upgrade_to(&b)); + assert!(!b.can_upgrade_to(&a)); + } + + /// Validate we're not breaking the serialized format of /boot/bootupd-state.json + #[test] + fn test_deserialize_state() -> Result<()> { + let data = include_str!("../tests/fixtures/example-state-v0.json"); + let state: SavedState = serde_json::from_str(data)?; + let efi = state.installed.get("EFI").expect("EFI"); + assert_eq!( + efi.meta.version, + "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + ); + Ok(()) + } + + /// Validate we're not breaking the serialized format of `bootupctl status --json` + #[test] + fn test_deserialize_status() -> Result<()> { + let data = include_str!("../tests/fixtures/example-status-v0.json"); + let status: Status = serde_json::from_str(data)?; + let efi = status.components.get("EFI").expect("EFI"); + assert_eq!( + efi.installed.version, + "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + ); + Ok(()) + } +} diff --git a/bootupd/src/model_legacy.rs b/bootupd/src/model_legacy.rs new file mode 100755 index 0000000..0487d2d --- /dev/null +++ b/bootupd/src/model_legacy.rs @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Implementation of the original bootupd data format, which is the same +//! as the current one except that the date is defined to be in UTC. + +use crate::model::ContentMetadata as NewContentMetadata; +use crate::model::InstalledContent as NewInstalledContent; +use crate::model::SavedState as NewSavedState; +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct ContentMetadata01 { + /// The timestamp, which is used to determine update availability + pub(crate) timestamp: NaiveDateTime, + /// Human readable version number, like ostree it is not ever parsed, just displayed + pub(crate) version: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct InstalledContent01 { + /// Associated metadata + pub(crate) meta: ContentMetadata01, + /// File tree + pub(crate) filetree: Option, +} + +/// Will be serialized into /boot/bootupd-state.json +#[derive(Serialize, Deserialize, Default, Debug)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct SavedState01 { + /// Maps a component name to its currently installed version + pub(crate) installed: BTreeMap, + /// Maps a component name to an in progress update + pub(crate) pending: Option>, +} + +impl ContentMetadata01 { + pub(crate) fn upconvert(self) -> NewContentMetadata { + let timestamp = self.timestamp.and_utc(); + NewContentMetadata { + timestamp, + version: self.version, + } + } +} + +impl InstalledContent01 { + pub(crate) fn upconvert(self) -> NewInstalledContent { + NewInstalledContent { + meta: self.meta.upconvert(), + filetree: self.filetree, + adopted_from: None, + } + } +} + +impl SavedState01 { + pub(crate) fn upconvert(self) -> NewSavedState { + let mut r: NewSavedState = Default::default(); + for (k, v) in self.installed { + r.installed.insert(k, v.upconvert()); + } + r + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + /// Validate we're not breaking the serialized format of `bootupctl status --json` + #[test] + fn test_deserialize_status() -> Result<()> { + let data = include_str!("../tests/fixtures/example-state-v0-legacy.json"); + let state: SavedState01 = serde_json::from_str(data)?; + let efi = state.installed.get("EFI").expect("EFI"); + assert_eq!( + efi.meta.version, + "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + ); + let state: NewSavedState = state.upconvert(); + let efi = state.installed.get("EFI").expect("EFI"); + let t = chrono::DateTime::parse_from_rfc3339("2020-09-15T13:01:21Z")?; + assert_eq!(t, efi.meta.timestamp); + assert_eq!( + efi.meta.version, + "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + ); + Ok(()) + } +} diff --git a/bootupd/src/ostreeutil.rs b/bootupd/src/ostreeutil.rs new file mode 100755 index 0000000..bc53a3a --- /dev/null +++ b/bootupd/src/ostreeutil.rs @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::path::Path; + +use anyhow::{Context, Result}; +use log::debug; + +/// https://github.com/coreos/rpm-ostree/pull/969/commits/dc0e8db5bd92e1f478a0763d1a02b48e57022b59 +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +pub(crate) const BOOT_PREFIX: &str = "usr/lib/ostree-boot"; +const LEGACY_RPMOSTREE_DBPATH: &str = "usr/share/rpm"; +const SYSIMAGE_RPM_DBPATH: &str = "usr/lib/sysimage/rpm"; + +/// Returns true if the target directory contains at least one file that does +/// not start with `.` +fn is_nonempty_dir(path: impl AsRef) -> Result { + let path = path.as_ref(); + let it = match std::fs::read_dir(path) { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + for ent in it { + let ent = ent?; + let name = ent.file_name(); + if name.as_encoded_bytes().starts_with(b".") { + continue; + } + return Ok(true); + } + Ok(false) +} + +pub(crate) fn rpm_cmd>(sysroot: P) -> Result { + let mut c = std::process::Command::new("rpm"); + let sysroot = sysroot.as_ref(); + // Take the first non-empty database path + let mut arg = None; + for dbpath in [SYSIMAGE_RPM_DBPATH, LEGACY_RPMOSTREE_DBPATH] { + let dbpath = sysroot.join(dbpath); + if !is_nonempty_dir(&dbpath)? { + continue; + } + let mut s = std::ffi::OsString::new(); + s.push("--dbpath="); + s.push(dbpath.as_os_str()); + arg = Some(s); + break; + } + if let Some(arg) = arg { + debug!("Using dbpath {arg:?}"); + c.arg(arg); + } else { + debug!("Failed to find dbpath"); + } + Ok(c) +} + +/// Get sysroot.bootloader in ostree repo config. +pub(crate) fn get_ostree_bootloader() -> Result> { + let mut cmd = std::process::Command::new("ostree"); + let result = cmd + .args([ + "config", + "--repo=/sysroot/ostree/repo", + "get", + "sysroot.bootloader", + ]) + .output() + .context("Querying ostree sysroot.bootloader")?; + if !result.status.success() { + // ostree will exit with a none zero return code if the key does not exists + return Ok(None); + } else { + let res = String::from_utf8(result.stdout) + .with_context(|| "decoding as UTF-8 output of ostree command")?; + let bootloader = res.trim_end().to_string(); + return Ok(Some(bootloader)); + } +} + +pub(crate) fn set_ostree_bootloader(bootloader: &str) -> Result<()> { + let status = std::process::Command::new("ostree") + .args([ + "config", + "--repo=/sysroot/ostree/repo", + "set", + "sysroot.bootloader", + bootloader, + ]) + .status()?; + if !status.success() { + anyhow::bail!("Failed to set 'sysroot.bootloader' to '{bootloader}' in ostree repo config"); + } + Ok(()) +} diff --git a/bootupd/src/packagesystem.rs b/bootupd/src/packagesystem.rs new file mode 100755 index 0000000..8c5d1f7 --- /dev/null +++ b/bootupd/src/packagesystem.rs @@ -0,0 +1,78 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use chrono::prelude::*; + +use crate::model::*; +use crate::ostreeutil; + +/// Parse the output of `rpm -q` +fn rpm_parse_metadata(stdout: &[u8]) -> Result { + let pkgs = std::str::from_utf8(stdout)? + .split_whitespace() + .map(|s| -> Result<_> { + let parts: Vec<_> = s.splitn(2, ',').collect(); + let name = parts[0]; + if let Some(ts) = parts.get(1) { + let nt = DateTime::parse_from_str(ts, "%s") + .context("Failed to parse rpm buildtime")? + .with_timezone(&chrono::Utc); + Ok((name, nt)) + } else { + bail!("Failed to parse: {}", s); + } + }) + .collect::>>>()?; + if pkgs.is_empty() { + bail!("Failed to find any RPM packages matching files in source efidir"); + } + let timestamps: BTreeSet<&DateTime> = pkgs.values().collect(); + // Unwrap safety: We validated pkgs has at least one value above + let largest_timestamp = timestamps.iter().last().unwrap(); + let version = pkgs.keys().fold("".to_string(), |mut s, n| { + if !s.is_empty() { + s.push(','); + } + s.push_str(n); + s + }); + Ok(ContentMetadata { + timestamp: **largest_timestamp, + version, + }) +} + +/// Query the rpm database and list the package and build times. +pub(crate) fn query_files( + sysroot_path: &str, + paths: impl IntoIterator, +) -> Result +where + T: AsRef, +{ + let mut c = ostreeutil::rpm_cmd(sysroot_path)?; + c.args(["-q", "--queryformat", "%{nevra},%{buildtime} ", "-f"]); + for arg in paths { + c.arg(arg.as_ref()); + } + + let rpmout = c.output()?; + if !rpmout.status.success() { + std::io::stderr().write_all(&rpmout.stderr)?; + bail!("Failed to invoke rpm -qf"); + } + + rpm_parse_metadata(&rpmout.stdout) +} + +#[test] +fn test_parse_rpmout() { + let testdata = "grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566"; + let parsed = rpm_parse_metadata(testdata.as_bytes()).unwrap(); + assert_eq!( + parsed.version, + "grub2-efi-x64-1:2.06-95.fc38.x86_64,shim-x64-15.6-2.x86_64" + ); +} diff --git a/bootupd/src/sha512string.rs b/bootupd/src/sha512string.rs new file mode 100755 index 0000000..eb8e6c1 --- /dev/null +++ b/bootupd/src/sha512string.rs @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use openssl::hash::Hasher; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)] +pub(crate) struct SHA512String(pub(crate) String); + +impl fmt::Display for SHA512String { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl SHA512String { + #[allow(dead_code)] + pub(crate) fn from_hasher(hasher: &mut Hasher) -> Self { + Self(format!( + "sha512:{}", + hex::encode(hasher.finish().expect("completing hash")) + )) + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + #[test] + fn test_empty() -> Result<()> { + let mut h = Hasher::new(openssl::hash::MessageDigest::sha512())?; + let s = SHA512String::from_hasher(&mut h); + assert_eq!("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", format!("{}", s)); + Ok(()) + } +} diff --git a/bootupd/src/util.rs b/bootupd/src/util.rs new file mode 100755 index 0000000..3d32bc5 --- /dev/null +++ b/bootupd/src/util.rs @@ -0,0 +1,122 @@ +use std::collections::HashSet; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use openat_ext::OpenatDirExt; + +/// Parse an environment variable as UTF-8 +#[allow(dead_code)] +pub(crate) fn getenv_utf8(n: &str) -> Result> { + if let Some(v) = std::env::var_os(n) { + Ok(Some( + v.to_str() + .ok_or_else(|| anyhow::anyhow!("{} is invalid UTF-8", n))? + .to_string(), + )) + } else { + Ok(None) + } +} + +pub(crate) fn filenames(dir: &openat::Dir) -> Result> { + let mut ret = HashSet::new(); + for entry in dir.list_dir(".")? { + let entry = entry?; + let Some(name) = entry.file_name().to_str() else { + bail!("Invalid UTF-8 filename: {:?}", entry.file_name()) + }; + match dir.get_file_type(&entry)? { + openat::SimpleType::File => { + ret.insert(format!("/{name}")); + } + openat::SimpleType::Dir => { + let child = dir.sub_dir(name)?; + for mut k in filenames(&child)?.drain() { + k.reserve(name.len() + 1); + k.insert_str(0, name); + k.insert(0, '/'); + ret.insert(k); + } + } + openat::SimpleType::Symlink => { + bail!("Unsupported symbolic link {:?}", entry.file_name()) + } + openat::SimpleType::Other => { + bail!("Unsupported non-file/directory {:?}", entry.file_name()) + } + } + } + Ok(ret) +} + +pub(crate) fn ensure_writable_mount>(p: P) -> Result<()> { + let p = p.as_ref(); + let stat = rustix::fs::statvfs(p)?; + if !stat.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) { + return Ok(()); + } + let status = std::process::Command::new("mount") + .args(["-o", "remount,rw"]) + .arg(p) + .status()?; + if !status.success() { + anyhow::bail!("Failed to remount {:?} writable", p); + } + Ok(()) +} + +/// Runs the provided Command object, captures its stdout, and swallows its stderr except on +/// failure. Returns a Result describing whether the command failed, and if not, its +/// standard output. Output is assumed to be UTF-8. Errors are adequately prefixed with the full +/// command. +#[allow(dead_code)] +pub(crate) fn cmd_output(cmd: &mut Command) -> Result { + let result = cmd + .output() + .with_context(|| format!("running {:#?}", cmd))?; + if !result.status.success() { + eprintln!("{}", String::from_utf8_lossy(&result.stderr)); + bail!("{:#?} failed with {}", cmd, result.status); + } + String::from_utf8(result.stdout) + .with_context(|| format!("decoding as UTF-8 output of `{:#?}`", cmd)) +} + +/// Copy from https://github.com/containers/bootc/blob/main/ostree-ext/src/container_utils.rs#L20 +/// 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 Path::new(p).exists() { + return true; + } + } + false +} + +/// Suppress SIGTERM while active +// TODO: In theory we could record if we got SIGTERM and exit +// on drop, but in practice we don't care since we're going to exit anyways. +#[derive(Debug)] +pub(crate) struct SignalTerminationGuard(signal_hook_registry::SigId); + +impl SignalTerminationGuard { + pub(crate) fn new() -> Result { + let signal = unsafe { signal_hook_registry::register(libc::SIGTERM, || {})? }; + Ok(Self(signal)) + } +} + +impl Drop for SignalTerminationGuard { + fn drop(&mut self) { + signal_hook_registry::unregister(self.0); + } +} diff --git a/bootupd/systemd/bootloader-update.service b/bootupd/systemd/bootloader-update.service new file mode 100755 index 0000000..5b6f487 --- /dev/null +++ b/bootupd/systemd/bootloader-update.service @@ -0,0 +1,16 @@ +[Unit] +Description=Update bootloader on boot +Documentation=https://github.com/coreos/bootupd + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootupctl update +RemainAfterExit=yes +# Keep this stuff in sync with SYSTEMD_ARGS_BOOTUPD in general +PrivateNetwork=yes +ProtectHome=yes +KillMode=mixed +MountFlags=slave + +[Install] +WantedBy=multi-user.target diff --git a/bootupd/tests/e2e-update/e2e-update-in-vm.sh b/bootupd/tests/e2e-update/e2e-update-in-vm.sh new file mode 100755 index 0000000..b6bf6e5 --- /dev/null +++ b/bootupd/tests/e2e-update/e2e-update-in-vm.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# Run inside the vm spawned from e2e.sh +set -euo pipefail + +dn=$(cd $(dirname $0) && pwd) +bn=$(basename $0) +. ${dn}/../kola/data/libtest.sh + +cd $(mktemp -d) + +echo "Starting $0" + +current_commit=$(rpm-ostree status --json | jq -r .deployments[0].checksum) + +stampfile=/etc/${bn}.upgraded +if ! test -f ${stampfile}; then + if test "${current_commit}" = ${TARGET_COMMIT}; then + fatal "already at ${TARGET_COMMIT}" + fi + + current_grub=$(rpm -q --queryformat='%{nevra}\n' ${TARGET_GRUB_NAME}) + if test "${current_grub}" == "${TARGET_GRUB_PKG}"; then + fatal "Current grub ${current_grub} is same as target ${TARGET_GRUB_PKG}" + fi + + # FIXME + # https://github.com/coreos/rpm-ostree/issues/2210 + runv setenforce 0 + runv rpm-ostree rebase /run/cosadir/tmp/repo:${TARGET_COMMIT} + runv touch ${stampfile} + runv systemd-run -- systemctl reboot + touch /run/rebooting + sleep infinity +else + if test "${current_commit}" != ${TARGET_COMMIT}; then + fatal "not at ${TARGET_COMMIT}" + fi +fi + +# We did setenforce 0 above for https://github.com/coreos/rpm-ostree/issues/2210 +# Validate that on reboot we're still enforcing. +semode=$(getenforce) +if test "$semode" != Enforcing; then + fatal "SELinux mode is ${semode}" +fi + +if ! test -n "${TARGET_GRUB_PKG}"; then + fatal "Missing TARGET_GRUB_PKG" +fi + +bootupctl validate +ok validate + +bootupctl status | tee out.txt +assert_file_has_content_literal out.txt 'Component EFI' +assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' +assert_not_file_has_content out.txt ' Installed:.*test-bootupd-payload' +assert_not_file_has_content out.txt ' Installed:.*'"${TARGET_GRUB_PKG}" +assert_file_has_content out.txt 'Update: Available:.*'"${TARGET_GRUB_PKG}" +assert_file_has_content out.txt 'Update: Available:.*test-bootupd-payload-1.0' +bootupctl status --print-if-available > out.txt +assert_file_has_content_literal 'out.txt' 'Updates available: BIOS EFI' +ok update avail + +# Mount the EFI partition. +tmpefimount=$(mount_tmp_efi) + +assert_not_has_file ${tmpefimount}/EFI/fedora/test-bootupd.efi + +if env FAILPOINTS='update::exchange=return' bootupctl update -vvv 2>err.txt; then + fatal "should have errored" +fi +assert_file_has_content err.txt "error: .*synthetic failpoint" + +bootupctl update -vvv | tee out.txt +assert_file_has_content out.txt "Previous EFI: .*" +assert_file_has_content out.txt "Updated EFI: ${TARGET_GRUB_PKG}.*,test-bootupd-payload-1.0" + +assert_file_has_content ${tmpefimount}/EFI/fedora/test-bootupd.efi test-payload + +bootupctl status --print-if-available > out.txt +if test -s out.txt; then + fatal "Found available updates: $(cat out.txt)" +fi +ok update not avail + +mount -o remount,rw /boot +rm -f /boot/bootupd-state.json +bootupctl adopt-and-update | tee out.txt +assert_file_has_content out.txt "Adopted and updated: BIOS: .*" +assert_file_has_content out.txt "Adopted and updated: EFI: .*" +bootupctl validate +ok adopt-and-update + +# Verify the adoption does not fail when install files if they are missing on the disk. +# see https://github.com/coreos/bootupd/issues/762 +rm -f /boot/bootupd-state.json +[ -f "${tmpefimount}/EFI/fedora/test-bootupd.efi" ] && rm -f ${tmpefimount}/EFI/fedora/test-bootupd.efi +bootupctl adopt-and-update | tee out.txt +assert_file_has_content out.txt "Adopted and updated: BIOS: .*" +assert_file_has_content out.txt "Adopted and updated: EFI: .*" +if bootupctl validate 2>err.txt; then + fatal "unexpectedly passed validation" +fi + +tap_finish +touch /run/testtmp/success +sync +# TODO maybe try to make this use more of the exttest infrastructure? +exec poweroff -ff diff --git a/bootupd/tests/e2e-update/e2e-update.sh b/bootupd/tests/e2e-update/e2e-update.sh new file mode 100755 index 0000000..6105fc8 --- /dev/null +++ b/bootupd/tests/e2e-update/e2e-update.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Given a coreos-assembler dir (COSA_DIR) and assuming +# the current dir is a git repository for bootupd, +# synthesize a test update and upgrade to it. This +# assumes that the latest cosa build is using the +# code we want to test (as happens in CI). +set -euo pipefail + +dn=$(cd $(dirname $0) && pwd) +testprefix=$(cd ${dn} && git rev-parse --show-prefix) +. ${dn}/../kola/data/libtest.sh +. ${dn}/testrpmbuild.sh + +if test -z "${COSA_DIR:-}"; then + fatal "COSA_DIR must be set" +fi +# Validate source directory +bootupd_git=$(cd ${dn} && git rev-parse --show-toplevel) +# https://github.com/coreos/bootupd/issues/551 +! test -f ${bootupd_git}/systemd/bootupd.service + +testtmp=$(mktemp -d -p /var/tmp bootupd-e2e.XXXXXXX) +export test_tmpdir=${testtmp} + +# This is new content for our update +test_bootupd_payload_file=/boot/efi/EFI/fedora/test-bootupd.efi +test_bootupd_payload_file1=/boot/efi/EFI/BOOT/test-bootupd1.efi +build_rpm test-bootupd-payload \ + files "${test_bootupd_payload_file} + ${test_bootupd_payload_file1}" \ + install "mkdir -p %{buildroot}/$(dirname ${test_bootupd_payload_file}) + echo test-payload > %{buildroot}/${test_bootupd_payload_file} + mkdir -p %{buildroot}/$(dirname ${test_bootupd_payload_file1}) + echo test-payload1 > %{buildroot}/${test_bootupd_payload_file1}" + +# Start in cosa dir +cd ${COSA_DIR} +test -d builds + +overrides=${COSA_DIR}/overrides +test -d "${overrides}" +mkdir -p ${overrides}/rpm +add_override() { + override=$1 + shift + # This relies on "gold" grub not being pruned, and different from what's + # in the latest fcos + (cd ${overrides}/rpm && runv koji download-build --arch=noarch --arch=$(arch) ${override}) +} + +if test -z "${e2e_skip_build:-}"; then + echo "Building starting image" + rm -f ${overrides}/rpm/*.rpm + # Version from F42 prior to GA + add_override grub2-2.12-26.fc42 + runv cosa build + prev_image=$(runv cosa meta --image-path qemu) + # Modify manifest to include `test-bootupd-payload` RPM + runv git -C src/config checkout manifest.yaml # first make sure it's clean + echo "packages: [test-bootupd-payload]" >> src/config/manifest.yaml + rm -f ${overrides}/rpm/*.rpm + echo "Building update ostree" + # Latest (current) version in F42 + add_override grub2-2.12-28.fc42 + mv ${test_tmpdir}/yumrepo/packages/$(arch)/*.rpm ${overrides}/rpm/ + # Only build ostree update + runv cosa build ostree + # Undo manifest modification + runv git -C src/config checkout manifest.yaml +fi +echo "Preparing test" +grubarch= +case $(arch) in + x86_64) grubarch=x64;; + aarch64) grubarch=aa64;; + *) fatal "Unhandled arch $(arch)";; +esac +target_grub_name=grub2-efi-${grubarch} +target_grub_pkg=$(rpm -qp --queryformat='%{nevra}\n' ${overrides}/rpm/${target_grub_name}-2*.rpm) +target_commit=$(cosa meta --get-value ostree-commit) +echo "Target commit: ${target_commit}" +# For some reason 9p can't write to tmpfs + +cat >${testtmp}/test.bu << EOF +variant: fcos +version: 1.0.0 +systemd: + units: + - name: bootupd-test.service + enabled: true + contents: | + [Unit] + RequiresMountsFor=/run/testtmp + [Service] + Type=oneshot + RemainAfterExit=yes + Environment=TARGET_COMMIT=${target_commit} + Environment=TARGET_GRUB_NAME=${target_grub_name} + Environment=TARGET_GRUB_PKG=${target_grub_pkg} + Environment=SRCDIR=/run/bootupd-source + # Run via shell because selinux denies systemd writing to 9p apparently + ExecStart=/bin/sh -c '/run/bootupd-source/${testprefix}/e2e-update-in-vm.sh &>>/run/testtmp/out.txt; test -f /run/rebooting || poweroff -ff' + [Install] + WantedBy=multi-user.target +EOF +runv butane -o ${testtmp}/test.ign ${testtmp}/test.bu +cd ${testtmp} +qemuexec_args=(kola qemuexec --propagate-initramfs-failure --qemu-image "${prev_image}" --qemu-firmware uefi \ + -i test.ign --bind-ro ${COSA_DIR},/run/cosadir --bind-ro ${bootupd_git},/run/bootupd-source --bind-rw ${testtmp},/run/testtmp) +if test -n "${e2e_debug:-}"; then + runv ${qemuexec_args[@]} --devshell +else + runv timeout 5m "${qemuexec_args[@]}" --console-to-file ${COSA_DIR}/tmp/console.txt +fi +if ! test -f ${testtmp}/success; then + if test -s ${testtmp}/out.txt; then + sed -e 's,^,# ,' < ${testtmp}/out.txt + else + echo "No out.txt created, systemd unit failed to start" + fi + fatal "test failed" +fi +echo "ok bootupd e2e" diff --git a/bootupd/tests/e2e-update/testrpmbuild.sh b/bootupd/tests/e2e-update/testrpmbuild.sh new file mode 100755 index 0000000..5a6f3c2 --- /dev/null +++ b/bootupd/tests/e2e-update/testrpmbuild.sh @@ -0,0 +1,142 @@ +# Copied from rpm-ostree + +# builds a new RPM and adds it to the testdir's repo +# $1 - name +# $2+ - optional, treated as directive/value pairs +build_rpm() { + local name=$1; shift + # Unset, not zero https://github.com/projectatomic/rpm-ostree/issues/349 + local epoch="" + local version=1.0 + local release=1 + local arch=x86_64 + + mkdir -p $test_tmpdir/yumrepo/{specs,packages} + local spec=$test_tmpdir/yumrepo/specs/$name.spec + + # write out the header + cat > $spec << EOF +Name: $name +Summary: %{name} +License: GPLv2+ +EOF + + local build= install= files= pretrans= pre= post= posttrans= post_args= + local verifyscript= uinfo= + local transfiletriggerin= transfiletriggerin_patterns= + local transfiletriggerin2= transfiletriggerin2_patterns= + local transfiletriggerun= transfiletriggerun_patterns= + while [ $# -ne 0 ]; do + local section=$1; shift + local arg=$1; shift + case $section in + requires) + echo "Requires: $arg" >> $spec;; + recommends) + echo "Recommends: $arg" >> $spec;; + provides) + echo "Provides: $arg" >> $spec;; + conflicts) + echo "Conflicts: $arg" >> $spec;; + post_args) + post_args="$arg";; + version|release|epoch|arch|build|install|files|pretrans|pre|post|posttrans|verifyscript|uinfo) + declare $section="$arg";; + transfiletriggerin) + transfiletriggerin_patterns="$arg"; + declare $section="$1"; shift;; + transfiletriggerin2) + transfiletriggerin2_patterns="$arg"; + declare $section="$1"; shift;; + transfiletriggerun) + transfiletriggerun_patterns="$arg"; + declare $section="$1"; shift;; + *) + assert_not_reached "unhandled section $section";; + esac + done + + cat >> $spec << EOF +Version: $version +Release: $release +${epoch:+Epoch: $epoch} +BuildArch: $arch + +%description +%{summary} + +# by default, we create a /usr/bin/$name script which just outputs $name +%build +echo -e "#!/bin/sh\necho $name-$version-$release.$arch" > $name +chmod a+x $name +$build + +${pretrans:+%pretrans} +$pretrans + +${pre:+%pre} +$pre + +${post:+%post} ${post_args} +$post + +${posttrans:+%posttrans} +$posttrans + +${transfiletriggerin:+%transfiletriggerin -- ${transfiletriggerin_patterns}} +$transfiletriggerin + +${transfiletriggerin2:+%transfiletriggerin -- ${transfiletriggerin2_patterns}} +$transfiletriggerin2 + +${transfiletriggerun:+%transfiletriggerun -- ${transfiletriggerun_patterns}} +$transfiletriggerun + +${verifyscript:+%verifyscript} +$verifyscript + +%install +mkdir -p %{buildroot}/usr/bin +install $name %{buildroot}/usr/bin +$install + +%clean +rm -rf %{buildroot} + +%files +/usr/bin/$name +$files +EOF + + # because it'd be overkill to set up mock for this, let's just fool + # rpmbuild using setarch + local buildarch=$arch + if [ "$arch" == "noarch" ]; then + buildarch=$(uname -m) + fi + + (cd $test_tmpdir/yumrepo/specs && + setarch $buildarch rpmbuild --target $arch -ba $name.spec \ + --define "_topdir $PWD" \ + --define "_sourcedir $PWD" \ + --define "_specdir $PWD" \ + --define "_builddir $PWD/.build" \ + --define "_srcrpmdir $PWD" \ + --define "_rpmdir $test_tmpdir/yumrepo/packages" \ + --define "_buildrootdir $PWD") + # use --keep-all-metadata to retain previous updateinfo + (cd $test_tmpdir/yumrepo && + createrepo_c --no-database --update --keep-all-metadata .) + # convenience function to avoid follow-up add-pkg + if [ -n "$uinfo" ]; then + uinfo_cmd add-pkg $uinfo $name 0 $version $release $arch + fi + if test '!' -f $test_tmpdir/yumrepo.repo; then + cat > $test_tmpdir/yumrepo.repo.tmp << EOF +[test-repo] +name=test-repo +baseurl=file:///$PWD/yumrepo +EOF + mv $test_tmpdir/yumrepo.repo{.tmp,} + fi +} diff --git a/bootupd/tests/fixtures/example-lsblk-output.json b/bootupd/tests/fixtures/example-lsblk-output.json new file mode 100755 index 0000000..f0aac3e --- /dev/null +++ b/bootupd/tests/fixtures/example-lsblk-output.json @@ -0,0 +1,33 @@ +{ + "blockdevices": [ + { + "path": "/dev/sr0", + "pttype": null, + "parttypename": null + },{ + "path": "/dev/zram0", + "pttype": null, + "parttypename": null + },{ + "path": "/dev/vda", + "pttype": "gpt", + "parttypename": null + },{ + "path": "/dev/vda1", + "pttype": "gpt", + "parttypename": "EFI System" + },{ + "path": "/dev/vda2", + "pttype": "gpt", + "parttypename": "Linux extended boot" + },{ + "path": "/dev/vda3", + "pttype": "gpt", + "parttypename": "Linux filesystem" + },{ + "path": "/dev/mapper/luks-df2d5f95-5725-44dd-83e1-81bc4cdc49b8", + "pttype": null, + "parttypename": null + } + ] +} diff --git a/bootupd/tests/fixtures/example-state-v0-legacy.json b/bootupd/tests/fixtures/example-state-v0-legacy.json new file mode 100755 index 0000000..85a0ae6 --- /dev/null +++ b/bootupd/tests/fixtures/example-state-v0-legacy.json @@ -0,0 +1,48 @@ +{ + "installed": { + "EFI": { + "meta": { + "timestamp": "2020-09-15T13:01:21", + "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + }, + "filetree": { + "timestamp": "1970-01-01T00:00:00", + "children": { + "BOOT/BOOTX64.EFI": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + }, + "BOOT/fbx64.efi": { + "size": 357248, + "sha512": "sha512:81fed5039bdd2bc53a203a1eaf56c6a6c9a95aa7ac88f037718a342205d83550f409741c8ef86b481f55ea7188ce0d661742548596f92ef97ba2a1695bc4caae" + }, + "fedora/BOOTX64.CSV": { + "size": 110, + "sha512": "sha512:0c29b8ae73171ef683ba690069c1bae711e130a084a81169af33a83dfbae4e07d909c2482dbe89a96ab26e171f17c53f1de8cb13d558bc1535412ff8accf253f" + }, + "fedora/grubx64.efi": { + "size": 2528520, + "sha512": "sha512:b35a6317658d07844d6bf0f96c35f2df90342b8b13a329b4429ac892351ff74fc794a97bc3d3e2d79bef4c234b49a8dd5147b71a3376f24bc956130994e9961c" + }, + "fedora/mmx64.efi": { + "size": 1159560, + "sha512": "sha512:f83ea67756cfcc3ec4eb1c83104c719ba08e66abfadb94b4bd75891e237c448bbec0fdb5bd42826e291ccc3dee559af424900b3d642a7d11c5bc9f117718837a" + }, + "fedora/shim.efi": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + }, + "fedora/shimx64-fedora.efi": { + "size": 1204496, + "sha512": "sha512:dc3656b90c0d1767365bea462cc94a2a3044899f510bd61a9a7ae1a9ca586e3d6189592b1ba1ee859f45614421297fa2f5353328caa615f51da5aed9ecfbf29c" + }, + "fedora/shimx64.efi": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + } + } + } + } + }, + "pending": null +} diff --git a/bootupd/tests/fixtures/example-state-v0.json b/bootupd/tests/fixtures/example-state-v0.json new file mode 100755 index 0000000..467e4b0 --- /dev/null +++ b/bootupd/tests/fixtures/example-state-v0.json @@ -0,0 +1,47 @@ +{ + "installed": { + "EFI": { + "meta": { + "timestamp": "2020-09-15T13:01:21Z", + "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + }, + "filetree": { + "children": { + "BOOT/BOOTX64.EFI": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + }, + "BOOT/fbx64.efi": { + "size": 357248, + "sha512": "sha512:81fed5039bdd2bc53a203a1eaf56c6a6c9a95aa7ac88f037718a342205d83550f409741c8ef86b481f55ea7188ce0d661742548596f92ef97ba2a1695bc4caae" + }, + "fedora/BOOTX64.CSV": { + "size": 110, + "sha512": "sha512:0c29b8ae73171ef683ba690069c1bae711e130a084a81169af33a83dfbae4e07d909c2482dbe89a96ab26e171f17c53f1de8cb13d558bc1535412ff8accf253f" + }, + "fedora/grubx64.efi": { + "size": 2528520, + "sha512": "sha512:b35a6317658d07844d6bf0f96c35f2df90342b8b13a329b4429ac892351ff74fc794a97bc3d3e2d79bef4c234b49a8dd5147b71a3376f24bc956130994e9961c" + }, + "fedora/mmx64.efi": { + "size": 1159560, + "sha512": "sha512:f83ea67756cfcc3ec4eb1c83104c719ba08e66abfadb94b4bd75891e237c448bbec0fdb5bd42826e291ccc3dee559af424900b3d642a7d11c5bc9f117718837a" + }, + "fedora/shim.efi": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + }, + "fedora/shimx64-fedora.efi": { + "size": 1204496, + "sha512": "sha512:dc3656b90c0d1767365bea462cc94a2a3044899f510bd61a9a7ae1a9ca586e3d6189592b1ba1ee859f45614421297fa2f5353328caa615f51da5aed9ecfbf29c" + }, + "fedora/shimx64.efi": { + "size": 1210776, + "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" + } + } + } + } + }, + "pending": null +} diff --git a/bootupd/tests/fixtures/example-status-v0.json b/bootupd/tests/fixtures/example-status-v0.json new file mode 100755 index 0000000..3df4159 --- /dev/null +++ b/bootupd/tests/fixtures/example-status-v0.json @@ -0,0 +1,26 @@ +{ + "components": { + "EFI": { + "installed": { + "timestamp": "2020-09-15T13:01:21Z", + "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + }, + "interrupted": null, + "update": { + "timestamp": "2020-09-15T13:01:21Z", + "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" + }, + "updatable": "at-latest-version", + "adopted-from": null + } + }, + "adoptable": { + "BIOS": { + "version": { + "version": "grub2-bios-42.x86_64", + "timestamp": "2020-09-15T13:01:21Z" + }, + "confident": true + } + } +} diff --git a/bootupd/tests/kola/data/libtest.sh b/bootupd/tests/kola/data/libtest.sh new file mode 100755 index 0000000..cc19f46 --- /dev/null +++ b/bootupd/tests/kola/data/libtest.sh @@ -0,0 +1,91 @@ +# Source library for shell script tests +# Copyright (C) 2020 Red Hat, Inc. +# SPDX-License-Identifier: Apache-2.0 + +runv() { + (set -x && "$@") +} + +N_TESTS=0 +ok() { + echo "ok" $@ + N_TESTS=$((N_TESTS + 1)) +} + +tap_finish() { + echo "Completing TAP test with:" + echo "1..${N_TESTS}" +} + +fatal() { + echo error: $@ 1>&2; exit 1 +} + +runv() { + set -x + "$@" +} + +# Dump ls -al + file contents to stderr, then fatal() +_fatal_print_file() { + file="$1" + shift + ls -al "$file" >&2 + sed -e 's/^/# /' < "$file" >&2 + fatal "$@" +} + +assert_not_has_file () { + fpath=$1 + shift + if test -e "$fpath"; then + fatal "Path exists: ${fpath}" + fi +} + +assert_file_has_content () { + fpath=$1 + shift + for re in "$@"; do + if ! grep -q -e "$re" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' doesn't match regexp '$re'" + fi + done +} + +assert_file_has_content_literal () { + fpath=$1; shift + for s in "$@"; do + if ! grep -q -F -e "$s" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' doesn't match fixed string list '$s'" + fi + done +} + +assert_not_file_has_content () { + fpath=$1 + shift + for re in "$@"; do + if grep -q -e "$re" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' matches regexp '$re'" + fi + done +} + +assert_not_file_has_content_literal () { + fpath=$1; shift + for s in "$@"; do + if grep -q -F -e "$s" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' matches fixed string list '$s'" + fi + done +} + +# Mount the EFI partition at a temporary location. +efipart=/dev/disk/by-partlabel/EFI-SYSTEM +mount_tmp_efi () { + tmpmount=$(mktemp -d) + mkdir -p ${tmpmount} + mount ${efipart} ${tmpmount} + echo ${tmpmount} +} diff --git a/bootupd/tests/kola/raid1/config.bu b/bootupd/tests/kola/raid1/config.bu new file mode 100755 index 0000000..8a9a598 --- /dev/null +++ b/bootupd/tests/kola/raid1/config.bu @@ -0,0 +1,7 @@ +variant: fcos +version: 1.5.0 +boot_device: + mirror: + devices: + - /dev/vda + - /dev/vdb diff --git a/bootupd/tests/kola/raid1/data/libtest.sh b/bootupd/tests/kola/raid1/data/libtest.sh new file mode 120000 index 0000000..5953257 --- /dev/null +++ b/bootupd/tests/kola/raid1/data/libtest.sh @@ -0,0 +1 @@ +../../data/libtest.sh \ No newline at end of file diff --git a/bootupd/tests/kola/raid1/test.sh b/bootupd/tests/kola/raid1/test.sh new file mode 100755 index 0000000..2a690ab --- /dev/null +++ b/bootupd/tests/kola/raid1/test.sh @@ -0,0 +1,42 @@ +#!/bin/bash +## kola: +## # additionalDisks is only supported on qemu. +## platforms: qemu +## # Root reprovisioning requires at least 4GiB of memory. +## minMemory: 4096 +## # Linear RAID is setup on these disks. +## additionalDisks: ["10G"] +## # This test includes a lot of disk I/O and needs a higher +## # timeout value than the default. +## timeoutMin: 15 +## description: Verify updating multiple EFIs using RAID 1 works. + +set -xeuo pipefail + +# shellcheck disable=SC1091 +. "$KOLA_EXT_DATA/libtest.sh" + +tmpdir=$(mktemp -d) +cd ${tmpdir} + +srcdev=$(findmnt -nvr /sysroot -o SOURCE) +[[ ${srcdev} == "/dev/md126" ]] + +blktype=$(lsblk -o TYPE "${srcdev}" --noheadings) +[[ ${blktype} == "raid1" ]] + +fstype=$(findmnt -nvr /sysroot -o FSTYPE) +[[ ${fstype} == "xfs" ]] +ok "source is XFS on RAID1 device" + +mount -o remount,rw /boot +rm -f -v /boot/bootupd-state.json + +bootupctl adopt-and-update | tee out.txt +assert_file_has_content out.txt "Adopted and updated: BIOS: .*" +assert_file_has_content out.txt "Adopted and updated: EFI: .*" + +bootupctl status | tee out.txt +assert_file_has_content_literal out.txt 'Component BIOS' +assert_file_has_content_literal out.txt 'Component EFI' +ok "bootupctl adopt-and-update supports multiple EFIs on RAID1" diff --git a/bootupd/tests/kola/test-bootupd b/bootupd/tests/kola/test-bootupd new file mode 100755 index 0000000..315656c --- /dev/null +++ b/bootupd/tests/kola/test-bootupd @@ -0,0 +1,122 @@ +#!/bin/bash +set -xeuo pipefail + +. ${KOLA_EXT_DATA}/libtest.sh + +tmpdir=$(mktemp -d) +cd ${tmpdir} +echo "using tmpdir: ${tmpdir}" +touch .testtmp +trap cleanup EXIT +function cleanup () { + if test -z "${TEST_SKIP_CLEANUP:-}"; then + if test -f "${tmpdir}"/.testtmp; then + cd / + rm "${tmpdir}" -rf + fi + else + echo "Skipping cleanup of ${tmpdir}" + fi +} + +# Mount the EFI partition. +tmpefimount=$(mount_tmp_efi) +bootmount=/boot +tmpefidir=${tmpefimount}/EFI +bootupdir=/usr/lib/bootupd/updates +efiupdir=${bootupdir}/EFI +ostbaseefi=/usr/lib/ostree-boot/efi/EFI +efisubdir=fedora +efidir=${efiupdir}/${efisubdir} +ostefi=${ostbaseefi}/${efisubdir} +shim=shimx64.efi + +test -f "${efidir}/${shim}" + +prepare_efi_update() { + test -w /usr + mkdir -p ${ostbaseefi} + cp -a ${efiupdir}.orig/* ${ostbaseefi}/ + rm -rf ${efiupdir} ${bootupdir}/EFI.json +} + +bootupctl status > out.txt +assert_file_has_content_literal out.txt 'Component EFI' +assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' +assert_file_has_content_literal out.txt 'Update: At latest version' +assert_file_has_content out.txt '^CoreOS aleph version:' +ok status + +bootupctl validate | tee out.txt +ok validate + +if env LANG=C.UTF-8 runuser -u bin bootupctl status 2>err.txt; then + fatal "Was able to bootupctl status as non-root" +fi +assert_file_has_content err.txt 'error: This command requires root privileges' + +# From here we'll fake updates +test -w /usr || rpm-ostree usroverlay +# Save a backup copy of the update dir +cp -a ${efiupdir} ${efiupdir}.orig + +prepare_efi_update +# FIXME need to synthesize an RPM for this +# echo somenewfile > ${ostefi}/somenew.efi +rm -v ${ostefi}/shim.efi +echo bootupd-test-changes >> ${ostefi}/grubx64.efi +/usr/libexec/bootupd generate-update-metadata / +ver=$(jq -r .version < ${bootupdir}/EFI.json) +cat >ver.json << EOF +{ "version": "${ver},test", "timestamp": "$(date -u --iso-8601=seconds)" } +EOF +jq -s add ${bootupdir}/EFI.json ver.json > new.json +mv new.json ${bootupdir}/EFI.json + +bootupctl status | tee out.txt +assert_file_has_content_literal out.txt 'Component EFI' +assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' +assert_not_file_has_content out.txt ' Installed: grub2-efi-x64.*,test' +assert_file_has_content_literal out.txt 'Update: Available:' +ok update avail + +bootupctl status --json > status.json +jq -r '.components.EFI.installed.version' < status.json > installed.txt +assert_file_has_content installed.txt '^grub2-efi-x64' + +bootupctl update | tee out.txt +assert_file_has_content out.txt 'Updated EFI: grub2-efi-x64.*,test' + +bootupctl status > out.txt +assert_file_has_content_literal out.txt 'Component EFI' +assert_file_has_content out.txt ' Installed: grub2-efi-x64.*,test' +assert_file_has_content_literal out.txt 'Update: At latest version' +ok status after update + +bootupctl validate | tee out.txt +ok validate after update + +# FIXME see above +# assert_file_has_content ${tmpefidir}/${efisubdir}/somenew.efi 'somenewfile' +if test -f ${tmpefidir}/${efisubdir}/shim.efi; then + fatal "failed to remove file" +fi +if ! grep -q 'bootupd-test-changes' ${tmpefidir}/${efisubdir}/grubx64.efi; then + fatal "failed to update modified file" +fi +cmp ${tmpefidir}/${efisubdir}/shimx64.efi ${efiupdir}/${efisubdir}/shimx64.efi +ok filesystem changes + +bootupctl update | tee out.txt +assert_file_has_content_literal out.txt 'No update available for any component' +assert_not_file_has_content_literal out.txt 'Updated EFI' + +echo "some additions" >> ${tmpefidir}/${efisubdir}/shimx64.efi +if bootupctl validate 2>err.txt; then + fatal "unexpectedly passed validation" +fi +assert_file_has_content err.txt "Changed: ${efisubdir}/shimx64.efi" +test "$(grep -cEe '^Changed:' err.txt)" = "1" +ok validate detected changes + +tap_finish diff --git a/bootupd/tests/kolainst/Makefile b/bootupd/tests/kolainst/Makefile new file mode 100755 index 0000000..1b74efc --- /dev/null +++ b/bootupd/tests/kolainst/Makefile @@ -0,0 +1,6 @@ +all: + echo "No build step" + +install: + mkdir -p $(DESTDIR)/usr/lib/coreos-assembler/tests/kola/ + rsync -rlv ../kola $(DESTDIR)/usr/lib/coreos-assembler/tests/kola/bootupd diff --git a/bootupd/tests/tests/bootupctl-status-in-bootc.sh b/bootupd/tests/tests/bootupctl-status-in-bootc.sh new file mode 100755 index 0000000..180163d --- /dev/null +++ b/bootupd/tests/tests/bootupctl-status-in-bootc.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -xeuo pipefail + +# Verify that bootupctl status running in bootc container +if [ ! -d "/sysroot/ostree/repo/" ]; then + echo "Error: should run test in bootc container" + exit 100 +fi + +# check if running in container +if [ "$container" ] || [ -f /run/.containerenv ] || [ -f /.dockerenv ]; then + arch="$(uname --machine)" + if [[ "${arch}" == "x86_64" ]]; then + components_text='Available components: BIOS EFI' + components_json='{"components":["BIOS","EFI"]}' + else + # Assume aarch64 for now + components_text='Available components: EFI' + components_json='{"components":["EFI"]}' + fi + + output=$(bootupctl status | tr -d '\r') + [ "${components_text}" == "${output}" ] + output=$(bootupctl status --json) + [ "${components_json}" == "${output}" ] +else + echo "Skip running as not in container" +fi diff --git a/bootupd/tests/tests/move-content-to-usr.sh b/bootupd/tests/tests/move-content-to-usr.sh new file mode 100755 index 0000000..caea8fd --- /dev/null +++ b/bootupd/tests/tests/move-content-to-usr.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -xeuo pipefail + +updates=/usr/lib/bootupd/updates +rm -fv ${updates}/{BIOS,EFI}.json +cp -r ${updates}/EFI /usr/lib/ostree-boot/efi +# prepare /usr/lib/efi// +if [ ! -d "/usr/lib/efi" ]; then + arch="$(uname --machine)" + if [[ "${arch}" == "x86_64" ]]; then + suffix="x64" + else + # Assume aarch64 for now + suffix="aa64" + fi + + grub_ver=$(rpm -qa grub2-efi-${suffix} --queryformat '%{VERSION}-%{RELEASE}') + mkdir -p /usr/lib/efi/grub2/${grub_ver}/EFI/centos + mv ${updates}/EFI/centos/grub${suffix}.efi /usr/lib/efi/grub2/${grub_ver}/EFI/centos/ + + shim_ver=$(rpm -qa shim-${suffix} --queryformat '%{VERSION}-%{RELEASE}') + mkdir -p /usr/lib/efi/shim/${shim_ver}/EFI/ + mv ${updates}/EFI /usr/lib/efi/shim/${shim_ver}/ +else + rm -rf ${updates}/EFI +fi +bootupctl backend generate-update-metadata -vvv +cat ${updates}/EFI.json | jq diff --git a/bootupd/xtask/.gitignore b/bootupd/xtask/.gitignore new file mode 100755 index 0000000..4906db3 --- /dev/null +++ b/bootupd/xtask/.gitignore @@ -0,0 +1,5 @@ +/target +fastbuild*.qcow2 +_kola_temp +.cosa +Cargo.lock diff --git a/bootupd/xtask/Cargo.toml b/bootupd/xtask/Cargo.toml new file mode 100755 index 0000000..fdefe35 --- /dev/null +++ b/bootupd/xtask/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.68" +camino = "1.0" +chrono = { version = "0.4.23", default-features = false, features = ["std"] } +fn-error-context = "0.2.0" +toml = "0.8" +tempfile = "3.3" +xshell = { version = "0.2" } diff --git a/bootupd/xtask/src/main.rs b/bootupd/xtask/src/main.rs new file mode 100755 index 0000000..cf5f395 --- /dev/null +++ b/bootupd/xtask/src/main.rs @@ -0,0 +1,253 @@ +use std::fs::File; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::process::Command; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use fn_error_context::context; +use xshell::{cmd, Shell}; + +const NAME: &str = "bootupd"; +const VENDORPATH: &str = "vendor.tar.zstd"; +const TAR_REPRODUCIBLE_OPTS: &[&str] = &[ + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime", +]; + +fn main() { + if let Err(e) = try_main() { + eprintln!("error: {e:#}"); + std::process::exit(1); + } +} + +fn try_main() -> Result<()> { + let task = std::env::args().nth(1); + let sh = xshell::Shell::new()?; + if let Some(cmd) = task.as_deref() { + let f = match cmd { + "vendor" => vendor, + "package" => package, + "package-srpm" => package_srpm, + _ => print_help, + }; + f(&sh)?; + } else { + print_help(&sh)?; + } + Ok(()) +} + +fn get_target_dir() -> Result { + let target = Utf8Path::new("target"); + std::fs::create_dir_all(&target)?; + Ok(target.to_owned()) +} + +fn vendor(sh: &Shell) -> Result<()> { + let _targetdir = get_target_dir()?; + let target = VENDORPATH; + cmd!( + sh, + "cargo vendor-filterer --prefix=vendor --format=tar.zstd {target}" + ) + .run()?; + Ok(()) +} + +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").ignore_stderr().read() { + Ok(gitrev_to_version(&rev)) + } else { + let mut desc = cmd!(sh, "git describe --tags --always").read()?; + desc.insert_str(0, "0."); + Ok(desc) + } +} + +/// 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. +#[allow(dead_code)] +#[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 impl_srpm(sh: &Shell) -> Result { + let pkg = impl_package(sh)?; + vendor(sh)?; + let td = tempfile::tempdir_in("target").context("Allocating tmpdir")?; + let td = td.into_path(); + let td: &Utf8Path = td.as_path().try_into().unwrap(); + let srcpath = td.join(pkg.srcpath.file_name().unwrap()); + std::fs::rename(pkg.srcpath, srcpath)?; + let v = pkg.version; + let vendorpath = td.join(format!("{NAME}-{v}-vendor.tar.zstd")); + std::fs::rename(VENDORPATH, vendorpath)?; + { + 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}")); + } + let spec = format!("{NAME}.spec"); + cmd.arg("--define") + .arg(format!("_buildrootdir {td}/.build")) + .args(["-bs", spec.as_str()]) + .run()?; + drop(d); + let mut srpm = None; + for e in std::fs::read_dir(td)? { + let e = e?; + let n = e.file_name(); + let n = if let Some(n) = n.to_str() { + n + } 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 _targetdir = get_target_dir()?; + let srpm = impl_srpm(sh)?; + println!("Generated: {srpm}"); + Ok(()) +} + +fn print_help(_sh: &Shell) -> Result<()> { + eprintln!( + "Tasks: + - vendor +" + ); + Ok(()) +}