diff --git a/Cargo.lock b/Cargo.lock index 3fafc1bfc..811c6c9e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" +[[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" @@ -171,6 +180,7 @@ dependencies = [ "anstyle", "anyhow", "bootc-utils", + "bootupd", "camino", "cap-std-ext", "chrono", @@ -218,6 +228,37 @@ dependencies = [ "tracing", ] +[[package]] +name = "bootupd" +version = "0.2.24" +dependencies = [ + "anyhow", + "bincode", + "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", + "serde", + "serde_json", + "tempfile", + "walkdir", + "widestring", +] + [[package]] name = "bstr" version = "0.2.17" @@ -563,6 +604,29 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -588,6 +652,17 @@ dependencies = [ "rustversion", ] +[[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.1.1" @@ -660,6 +735,16 @@ dependencies = [ "windows-sys 0.52.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 = "futures-channel" version = "0.3.30" @@ -926,6 +1011,12 @@ dependencies = [ "digest", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -1076,7 +1167,7 @@ dependencies = [ "hmac", "libc", "log", - "nix", + "nix 0.27.1", "nom", "once_cell", "serde", @@ -1151,6 +1242,15 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[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.0" @@ -1187,6 +1287,19 @@ dependencies = [ "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.27.1" @@ -1196,7 +1309,7 @@ dependencies = [ "bitflags 2.4.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.9.0", ] [[package]] @@ -1296,6 +1409,27 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[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.68" @@ -1334,6 +1468,15 @@ dependencies = [ "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 = "ostree" version = "0.19.1" @@ -1674,6 +1817,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[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 = "schemars" version = "0.8.21" @@ -2288,6 +2440,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[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" @@ -2348,6 +2510,12 @@ version = "0.2.91" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + [[package]] name = "winapi" version = "0.3.9" @@ -2364,6 +2532,15 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 0f775d4a7..fe73cbb44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cli", "lib", "xtask", "tests-integration"] +members = ["cli", "lib", "bootupd", "xtask", "tests-integration"] resolver = "2" [profile.dev] @@ -21,7 +21,7 @@ lto = "yes" anyhow = "1.0.82" camino = "1.1.6" cap-std-ext = "4.0.2" -chrono = { version = "0.4.38", default-features = false } +chrono = { version = "0.4.38", features = ["serde"] } clap = "4.5.4" indoc = "2.0.5" fn-error-context = "0.2.1" diff --git a/bootupd/.cargo/config.toml b/bootupd/.cargo/config.toml new file mode 100644 index 000000000..d8c2032b4 --- /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 100644 index 000000000..a13a827b5 --- /dev/null +++ b/bootupd/.cci.jenkinsfile @@ -0,0 +1,78 @@ +// 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(""" + cargo test + """) + } + shwrap(""" + make install 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 +cosaPod(runAsUser: 0, memory: "4608Mi", 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 + """) + } + // 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 100644 index 000000000..011fb2ad8 --- /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 100644 index 000000000..a0dbd07ba --- /dev/null +++ b/bootupd/.dockerignore @@ -0,0 +1,2 @@ +target +.cosa diff --git a/bootupd/.github/ISSUE_TEMPLATE/release-checklist.md b/bootupd/.github/ISSUE_TEMPLATE/release-checklist.md new file mode 100644 index 000000000..344107db8 --- /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]) + * 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 (i.e. re-use the PR content) + - [ ] 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` + - 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_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 100644 index 000000000..b97ea3e85 --- /dev/null +++ b/bootupd/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Maintained in https://github.com/coreos/repo-templates +# Do not edit downstream. + +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - area/dependencies diff --git a/bootupd/.github/workflows/ci.yml b/bootupd/.github/workflows/ci.yml new file mode 100644 index 000000000..f87569d5d --- /dev/null +++ b/bootupd/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +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: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + - name: build + run: sudo podman build -t localhost/bootupd:latest -f ci/Containerfile.c9s . + - name: bootc install to disk + run: | + set -xeuo pipefail + sudo truncate -s 10G myimage.raw + sudo podman run --rm -ti --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 --list --noheadings --output NAME,BACK-FILE | grep myimage.raw | awk '{print $1}') + sudo mount "${device}p2" /mnt/ + sudo ls /mnt/EFI/centos/{grub.cfg,shimx64.efi} + 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 \ + --disable-selinux --replace=alongside /target + # Verify we injected static configs + jq -re '.["static-configs"].version' /boot/bootupd-state.json diff --git a/bootupd/.github/workflows/cross.yml b/bootupd/.github/workflows/cross.yml new file mode 100644 index 000000000..12ddcd5a4 --- /dev/null +++ b/bootupd/.github/workflows/cross.yml @@ -0,0 +1,41 @@ +name: Cross build + +on: [push, pull_request] + +permissions: + actions: read + +jobs: + crossarch-check: + runs-on: ubuntu-latest + name: Build on ${{ matrix.arch }} + + strategy: + matrix: + include: + - arch: aarch64 + distro: ubuntu_latest + - arch: s390x + distro: ubuntu_latest + - arch: ppc64le + distro: ubuntu_latest + steps: + - uses: actions/checkout@v3.0.2 + with: + submodules: true + set-safe-directory: true + + - uses: uraimo/run-on-arch-action@v2.8.1 + 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 cargo libssl-dev pkg-config + cargo check diff --git a/bootupd/.github/workflows/rust.yml b/bootupd/.github/workflows/rust.yml new file mode 100644 index 000000000..4a559fb30 --- /dev/null +++ b/bootupd/.github/workflows/rust.yml @@ -0,0 +1,114 @@ +# 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.75.0 + +jobs: + tests-stable: + name: Tests, stable toolchain + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - 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 + steps: + - name: Check out repository + uses: actions/checkout@v3 + - 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 + steps: + - name: Check out repository + uses: actions/checkout@v3 + - 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 + steps: + - name: Check out repository + uses: actions/checkout@v3 + - 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 + continue-on-error: true + strategy: + matrix: + channel: [beta, nightly] + steps: + - name: Check out repository + uses: actions/checkout@v3 + - 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 100644 index 000000000..71c8efb41 --- /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 100644 index 000000000..62c7d7b2c --- /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.toml b/bootupd/Cargo.toml new file mode 100644 index 000000000..6c93bf81b --- /dev/null +++ b/bootupd/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "bootupd" +description = "Bootloader updater" +license = "Apache-2.0" +version = "0.2.24" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +bincode = "1.3.2" +cap-std-ext = "4.0.3" +camino = { workspace = true, features = ["serde1"] } +chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive", "cargo", "help", "usage", "suggestions"] } +env_logger = "0.11" +fail = { version = "0.5", features = ["failpoints"] } +fn-error-context = { workspace = true } +fs2 = "0.4.3" +hex = "0.4.3" +libc = { workspace = true } +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.10" +rustix = { workspace = true, features = ["process", "fs"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +widestring = "1.1.0" +walkdir = "2.3.2" + +# FIXME uncomment +#[lints] +#workspace = true \ No newline at end of file diff --git a/bootupd/Dockerfile.build b/bootupd/Dockerfile.build new file mode 100644 index 000000000..ef80691a7 --- /dev/null +++ b/bootupd/Dockerfile.build @@ -0,0 +1,9 @@ +FROM registry.fedoraproject.org/fedora:latest + +VOLUME /srv/bootupd + +WORKDIR /srv/bootupd + +RUN dnf update -y && \ + dnf install -y make cargo rust glib2-devel openssl-devel ostree-devel && \ + dnf clean all diff --git a/bootupd/LICENSE b/bootupd/LICENSE new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/bootupd/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/bootupd/Makefile b/bootupd/Makefile new file mode 100644 index 000000000..f5292a4cb --- /dev/null +++ b/bootupd/Makefile @@ -0,0 +1,47 @@ +DESTDIR ?= +PREFIX ?= /usr +LIBEXECDIR ?= ${PREFIX}/libexec +RELEASE ?= 1 +CONTAINER_RUNTIME ?= podman +IMAGE_PREFIX ?= +IMAGE_NAME ?= bootupd-build + +ifeq ($(RELEASE),1) + PROFILE ?= release + CARGO_ARGS = --release +else + PROFILE ?= debug + CARGO_ARGS = +endif + +ifeq ($(CONTAINER_RUNTIME), podman) + IMAGE_PREFIX = localhost/ +endif + +.PHONY: all +all: + cargo build ${CARGO_ARGS} + ln -f target/${PROFILE}/bootupd target/${PROFILE}/bootupctl + +.PHONY: create-build-container +create-build-container: + ${CONTAINER_RUNTIME} build -t ${IMAGE_NAME} -f Dockerfile.build + +.PHONY: build-in-container +build-in-container: create-build-container + ${CONTAINER_RUNTIME} run -ti --rm -v .:/srv/bootupd:z ${IMAGE_PREFIX}${IMAGE_NAME} make + +.PHONY: install +install: + mkdir -p "${DESTDIR}$(PREFIX)/bin" "${DESTDIR}$(LIBEXECDIR)" + install -D -t "${DESTDIR}$(LIBEXECDIR)" target/${PROFILE}/bootupd + ln -f ${DESTDIR}$(LIBEXECDIR)/bootupd ${DESTDIR}$(PREFIX)/bin/bootupctl + +install-grub-static: + install -m 644 -D -t ${DESTDIR}$(PREFIX)/lib/bootupd/grub2-static src/grub2/*.cfg + install -m 755 -d ${DESTDIR}$(PREFIX)/lib/bootupd/grub2-static/configs.d + +bin-archive: + rm target/inst -rf + $(MAKE) install install-grub-static DESTDIR=$$(pwd)/target/inst + tar -C target/inst -c --zstd -f target/bootupd.tar.zst . diff --git a/bootupd/README-design.md b/bootupd/README-design.md new file mode 100644 index 000000000..07d52b77f --- /dev/null +++ b/bootupd/README-design.md @@ -0,0 +1,36 @@ +# Overall design + +The initial focus here is updating the [ESP](https://en.wikipedia.org/wiki/EFI_system_partition), but the overall design of bootupd contains a lot of abstraction to support different "components". + +## Ideal case + +In the ideal case, an OS builder uses `bootupd install` to install all bootloader data, +and thereafter it is fully (exclusively) managed by bootupd. It would e.g. be a bug/error +for an administrator to manually invoke `grub2-install` e.g. again. + +In other words, an end user system would simply invoke `bootupd update` as desired. + +However, we're not in that ideal case. Thus bootupd has the concept of "adoption" where +we start tracking the installed state as we find it. + +## Handling adoption + +For Fedora CoreOS, currently the `EFI/fedora/grub.cfg` file is created outside of the ostree inside `create_disk.sh`. So we aren't including any updates for it in the OSTree. + +This type of problem is exactly what bootupd should be solving. + +However, we need to be very cautious in handling this because we basically can't +assume we own all of the state. We shouldn't touch any files that we +don't know about. + +## Upgrade edges + +We don't necessarily want to update the bootloader data, even if a new update happens to be provided. +For example, Fedora does "mass rebuilds" usually once a release, but it's not strictly necessary +to update users' bootloaders then. + +A common policy in fact might be "only update bootloader for security issue or if it's strictly necessary". + +A "strictly necessary" upgrade would be one like the GRUB BLS parsing support. + +There is not yet any support for upgrade edges in the code apart from a stub structure. diff --git a/bootupd/README-devel.md b/bootupd/README-devel.md new file mode 100644 index 000000000..dba1a33f6 --- /dev/null +++ b/bootupd/README-devel.md @@ -0,0 +1,78 @@ +# Developing bootupd + +Currently the focus is Fedora CoreOS. + +You can use the normal Rust tools to build and run the unit tests: + +`cargo build` and `cargo test` + +For real e2e testing, use e.g. +``` +export COSA_DIR=/path/to/fcos +cosa build-fast +kola run -E $(pwd) --qemu-image fastbuild-fedora-coreos-bootupd-qemu.qcow2 --qemu-firmware uefi ext.bootupd.* +``` + +See also [the coreos-assembler docs](https://coreos.github.io/coreos-assembler/working/#using-overrides). + +## Building With Containers + +Many folks use a pet container or toolbox to do development on immutable, partially mutabable, or non-Linux OS's. For those who don't use a pet/toolbox and you'd prefer not to modify your host system for development you can use the `build-in-container` make target to execute building inside a container. + +``` +$ make build-in-container +podman build -t bootupd-build -f Dockerfile.build +STEP 1: FROM registry.fedoraproject.org/fedora:latest +STEP 2: VOLUME /srv/bootupd +--> Using cache a033bf0e43d560e72d7187459d7fad65ab30a1d01c576e8257194d82836472f7 +STEP 3: WORKDIR /srv/bootupd +--> Using cache 756114416fb4a68e72b68a2097c57d9cb94c830f5b351401319baeafa062695e +STEP 4: RUN dnf update -y && dnf install -y make cargo rust glib2-devel openssl-devel ostree-devel +--> Using cache a8e2b525ff0701f735e01bb5703c63bb0e67683625093d34be34bf1123a7f954 +STEP 5: COMMIT bootupd-build +--> a8e2b525ff0 +a8e2b525ff0701f735e01bb5703c63bb0e67683625093d34be34bf1123a7f954 +podman run -ti --rm -v .:/srv/bootupd:z localhost/bootupd-build make +cargo build --release + Updating git repository `https://gitlab.com/cgwalters/ostree-rs` + Updating crates.io index +[...] +$ ls target/release/bootupd +target/release/bootupd +$ +``` + +## Integrating bootupd into a distribution/OS + +Today, bootupd only really works on systems that use RPMs and ostree. +(Which usually means rpm-ostree, but not strictly necessarily) + +Many bootupd developers (and current CI flows) target Fedora CoreOS +and derivatives, so it can be used as a "reference" for integration. + +There's two parts to integration: + +### Generating an update payload + +Bootupd's concept of an "update payload" needs to be generated as +part of an OS image (e.g. ostree commit). +A good reference for this is +https://github.com/coreos/fedora-coreos-config/blob/88af117d1d2c5e828e5e039adfa03c7cc66fc733/manifests/bootupd.yaml#L12 + +Specifically, you'll need to invoke +`bootupctl backend generate-update-metadata /` as part of update payload generation. +This scrapes metadata (e.g. RPM versions) about shim/grub and puts them along with +their component files in `/usr/lib/bootupd/updates/`. + +### Installing to generated disk images + +In order to correctly manage updates, bootupd also needs to be responsible +for laying out files in initial disk images. A good reference for this is +https://github.com/coreos/coreos-assembler/blob/93efb63dcbd63dc04a782e2c6c617ae0cd4a51c8/src/create_disk.sh#L401 + +Specifically, you'll need to invoke +`/usr/bin/bootupctl backend install --src-root /path/to/ostree/deploy /sysroot` +where the first path is an ostree deployment root, and the second is the physical +root partition. + +This will e.g. inject the initial files into the mounted EFI system partition. diff --git a/bootupd/README.md b/bootupd/README.md new file mode 100644 index 000000000..22a483c4e --- /dev/null +++ b/bootupd/README.md @@ -0,0 +1,142 @@ +# bootupd: Distribution-independent updates for bootloaders + +Today many Linux systems handle updates for bootloader data +in an inconsistent and ad-hoc way. For example, on +Fedora and Debian, a package manager update will update UEFI +binaries in `/boot/efi`, but not the BIOS MBR data. + +Transactional/"image" update systems like [OSTree](https://github.com/ostreedev/ostree/) +and dual-partition systems like the Container Linux update system +are more consistent: they normally cover kernel/userspace but not anything +related to bootloaders. + +The reason for this is straightforward: performing bootloader +updates in an "A/B" fashion requires completely separate nontrivial +logic from managing the kernel and root filesystem. Today OSTree e.g. +makes the choice that it does not update `/boot/efi` (and also doesn't +update the BIOS MBR). + +The goal of this project is to be a cross-distribution, +OS update system agnostic tool to manage updates for things like: + +- `/boot/efi` +- x86 BIOS MBR +- Other architecture bootloaders + +This project originated in [this Fedora CoreOS github issue](https://github.com/coreos/fedora-coreos-tracker/issues/510). + +The scope is otherwise limited; for example, bootupd will not +manage anything related to the kernel such as kernel arguments; +that's for tools like `grubby` and `ostree`. + +## Status + +bootupd supports updating GRUB and shim for UEFI firmware on +x86_64 and aarch64, and GRUB for BIOS firmware on x86_64. +The project is [deployed in Fedora CoreOS](https://docs.fedoraproject.org/en-US/fedora-coreos/bootloader-updates/) and derivatives, +and is also used by the new [`bootc install`](https://github.com/containers/bootc/#using-bootc-install) +functionality. The bootupd CLI should be considered stable. + +bootupd does not yet perform updates in a way that is safe +against a power failure at the wrong moment, or +against a buggy bootloader update that fails to boot +the system. + +Therefore, by default, bootupd updates the bootloader only when manually instructed to do so. + +## Relationship to other projects + +### dbxtool + +[dbxtool](https://github.com/rhboot/dbxtool) manages updates +to the Secure Boot database - `bootupd` will likely need to +perform any updates to the `shimx64.efi` binary +*before* `dbxtool.service` starts. But otherwise they are independent. + +### fwupd + +bootupd could be compared to [fwupd](https://github.com/fwupd/fwupd/) which is +a project that exists today to update hardware device firmware - things not managed +by e.g. `apt/zypper/yum/rpm-ostree update` today. + +fwupd comes as a UEFI binary today, so bootupd *could* take care of updating `fwupd` +but today fwupd handles that itself. So it's likely that bootupd would only take +care of GRUB and shim. See discussion in [this issue](https://github.com/coreos/bootupd/issues/1). + +### systemd bootctl + +[systemd bootctl](https://man7.org/linux/man-pages/man1/bootctl.1.html) can update itself; +this project would probably just proxy that if we detect systemd-boot is in use. + +## Other goals + +One idea is that bootupd could help support [redundant bootable disks](https://github.com/coreos/fedora-coreos-tracker/issues/581). +For various reasons it doesn't really work to try to use RAID1 for an entire disk; the ESP must be handled +specially. `bootupd` could learn how to synchronize multiple EFI system partitions from a primary. + +## More details on rationale and integration + +A notable problem today for [rpm-ostree](https://github.com/coreos/rpm-ostree/) based +systems is that `rpm -q shim-x64` is misleading because it's not actually +updated in place. + +Particularly [this commit][1] makes things clear - the data +from the RPM goes into `/usr` (part of the OSTree), so it doesn't touch `/boot/efi`. +But that commit didn't change how the RPM database works (and more generally it +would be technically complex for rpm-ostree to change how the RPM database works today). + +What we ultimately want is that `rpm -q shim-x64` returns "not installed" - because +it's not managed by RPM or by ostree. Instead one would purely use `bootupctl` to manage it. +However, it might still be *built* as an RPM, just not installed that way. The RPM version numbers would be used +for the bootupd version associated with the payload, and ultimately we'd teach `rpm-ostree compose tree` +how to separately download bootloaders and pass them to `bootupctl backend`. + +[1]: https://github.com/coreos/rpm-ostree/pull/969/commits/dc0e8db5bd92e1f478a0763d1a02b48e57022b59 + + +## Questions and answers + +- Why is bootupd not part of ostree? + +A key advertised feature of ostree is that updates are truly transactional. +There's even a [a test case](https://blog.verbum.org/2020/12/01/committed-to-the-integrity-of-your-root-filesystem/) +that validates forcibly pulling the power during OS updates. A simple +way to look at this is that on an ostree-based system there is no need +to have a "please don't power off your computer" screen. This in turn +helps administrators to confidently enable automatic updates. + +Doing that for the bootloader (i.e. bootupd's domain) is an *entirely* separate problem. +There have been some ideas around how we could make the bootloaders +use an A/B type scheme (or at least be more resilient), and perhaps in the future bootupd will +use some of those. + +These updates hence carry different levels of risk. In many cases +actually it's OK if the bootloader lags behind; we don't need to update +every time. + +But out of conservatism currently today for e.g. Fedora CoreOS, bootupd is disabled +by default. On the other hand, if your OS update mechanism isn't transactional, +then you may want to enable bootupd by default. + +- Is bootupd a daemon? + +It was never a daemon. The name was intended to be "bootloader-upDater" not +"bootloader-updater-Daemon". The choice of a "d" suffix is in retrospect +probably too confusing. + +bootupd used to have an internally-facing `bootupd.service` and +`bootupd.socket` systemd units that acted as a locking mechanism. The service +would *very quickly* auto exit. There was nothing long-running, so it was not +really a daemon. + +bootupd now uses `systemd-run` instead to guarantee the following: + +- It provides a robust natural "locking" mechanism. +- It ensures that critical logging metadata always consistently ends up in the + systemd journal, not e.g. a transient client SSH connection. +- It benefits from the sandboxing options available for systemd units, and + while bootupd is obviously privileged we can still make use of some of this. +- If we want a non-CLI API (whether that's DBus or Cap'n Proto or varlink or + something else), we will create an independent daemon with a stable API for + this specific need. + diff --git a/bootupd/ci/Containerfile.c9s b/bootupd/ci/Containerfile.c9s new file mode 100644 index 000000000..c1b2f035e --- /dev/null +++ b/bootupd/ci/Containerfile.c9s @@ -0,0 +1,13 @@ +# This container build is just a demo effectively; it shows how one might +# build bootc in a container flow, using Fedora ELN as the target. +FROM quay.io/centos/centos:stream9 as build +RUN dnf -y install dnf-utils zstd && dnf builddep -y rust-bootupd +COPY . /build +WORKDIR /build +# See https://www.reddit.com/r/rust/comments/126xeyx/exploring_the_problem_of_faster_cargo_docker/ +# We aren't using the full recommendations there, just the simple bits. +RUN --mount=type=cache,target=/build/target --mount=type=cache,target=/var/roothome make && make bin-archive && mkdir -p /out && cp target/bootupd.tar.zst /out + +FROM quay.io/centos-bootc/centos-bootc-dev:stream9 +COPY --from=build /out/bootupd.tar.zst /tmp +RUN tar -C / --zstd -xvf /tmp/bootupd.tar.zst && rm -rvf /tmp/* diff --git a/bootupd/ci/build-test.sh b/bootupd/ci/build-test.sh new file mode 100755 index 000000000..77b5e2430 --- /dev/null +++ b/bootupd/ci/build-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -xeuo pipefail +test -n "${COSA_DIR:-}" +make +cosa build-fast +kola run -E $(pwd) --qemu-image fastbuild-*-qemu.qcow2 --qemu-firmware uefi ext.bootupd.'*' diff --git a/bootupd/ci/prepare-release.sh b/bootupd/ci/prepare-release.sh new file mode 100755 index 000000000..7a678976e --- /dev/null +++ b/bootupd/ci/prepare-release.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Prepare a release +set -euo pipefail +cargo publish --dry-run +name=$(cargo read-manifest | jq -r .name) +version=$(cargo read-manifest | jq -r .version) +commit=$(git rev-parse HEAD) + +# Generate a vendor tarball of sources to attach to a release +# in order to support offline builds. +vendor_dest=target/${name}-${version}-vendor.tar.gz +cargo vendor-filterer --prefix=vendor --format=tar.gz "${vendor_dest}" + +echo "Prepared ${version} at commit ${commit}" diff --git a/bootupd/ci/prow/Dockerfile b/bootupd/ci/prow/Dockerfile new file mode 100644 index 000000000..717b5038a --- /dev/null +++ b/bootupd/ci/prow/Dockerfile @@ -0,0 +1,21 @@ +FROM quay.io/coreos-assembler/fcos-buildroot:testing-devel as builder +WORKDIR /src +COPY . . +RUN make && make install DESTDIR=/cosa/component-install +RUN make -C tests/kolainst install DESTDIR=/cosa/component-tests +# Uncomment this to fake a build to test the code below +# RUN mkdir -p /cosa/component-install/usr/bin && echo foo > /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 000000000..83618464b --- /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 100644 index 000000000..a234f3609 --- /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 100644 index 000000000..53c8fdd32 --- /dev/null +++ b/bootupd/contrib/packaging/bootupd.spec @@ -0,0 +1,59 @@ +%bcond_without check +%global __cargo_skip_build 0 + +%global crate bootupd + +Name: bootupd +Version: 0.2.9 +Release: 1%{?dist} +Summary: Bootloader updater + +License: ASL 2.0 +URL: https://crates.io/crates/bootupd +Source0: https://github.com/coreos/bootupd/releases/download/v%{version}/bootupd-%{version}.tar.zstd +Source1: https://github.com/coreos/bootupd/releases/download/v%{version}/bootupd-%{version}-vendor.tar.zstd + +# For now, see upstream +# See https://github.com/coreos/fedora-coreos-tracker/issues/1716 +%if 0%{?fedora} || 0%{?rhel} >= 10 +ExcludeArch: %{ix86} +%endif +BuildRequires: make +BuildRequires: cargo +# For autosetup -Sgit +BuildRequires: git +BuildRequires: openssl-devel +BuildRequires: systemd-devel + +%description +%{summary} + +%files +%license LICENSE +%doc README.md +%{_bindir}/bootupctl +%{_libexecdir}/bootupd +%{_prefix}/lib/bootupd/grub2-static/ + +%prep +%autosetup -n %{crate}-%{version} -p1 -Sgit +tar -xv -f %{SOURCE1} +mkdir -p .cargo +cat >.cargo/config << EOF +[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = "vendor" +EOF + +%build +cargo build --release + +%install +%make_install INSTALL="install -p -c" +make install-grub-static DESTDIR=%{?buildroot} INSTALL="%{__install} -p" + +%changelog +* Tue Oct 18 2022 Colin Walters - 0.2.8-3 +- Dummy changelog \ No newline at end of file diff --git a/bootupd/doc/dependency_decisions.yml b/bootupd/doc/dependency_decisions.yml new file mode 100644 index 000000000..7da6010a2 --- /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 100644 index 000000000..df7c63705 --- /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 100644 index 000000000..379c472c4 --- /dev/null +++ b/bootupd/src/backend/statefile.rs @@ -0,0 +1,107 @@ +//! On-disk saved state. + +use crate::model::SavedState; +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, + 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, + 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)] + 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 100644 index 000000000..96d007416 --- /dev/null +++ b/bootupd/src/bios.rs @@ -0,0 +1,232 @@ +use std::io::prelude::*; +use std::path::Path; +use std::process::Command; + +use crate::component::*; +use crate::model::*; +use crate::packagesystem; +use anyhow::{bail, Result}; + +use crate::util; +use serde::{Deserialize, Serialize}; + +// grub2-install file path +pub(crate) const GRUB_BIN: &str = "usr/sbin/grub2-install"; + +#[derive(Serialize, Deserialize, Debug)] +struct BlockDevice { + path: String, + pttype: Option, + parttypename: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Devices { + blockdevices: Vec, +} + +#[derive(Default)] +pub(crate) struct Bios {} + +impl Bios { + // get target device for running update + fn get_device(&self) -> Result { + let mut cmd: Command; + #[cfg(target_arch = "x86_64")] + { + // find /boot partition + cmd = Command::new("findmnt"); + cmd.arg("--noheadings") + .arg("--output") + .arg("SOURCE") + .arg("/boot"); + let partition = util::cmd_output(&mut cmd)?; + + // lsblk to find parent device + cmd = Command::new("lsblk"); + cmd.arg("--paths") + .arg("--noheadings") + .arg("--output") + .arg("PKNAME") + .arg(partition.trim()); + } + + #[cfg(target_arch = "powerpc64")] + { + // get PowerPC-PReP-boot partition + cmd = Command::new("realpath"); + cmd.arg("/dev/disk/by-partlabel/PowerPC-PReP-boot"); + } + + let device = util::cmd_output(&mut cmd)?; + Ok(device) + } + + // Run grub2-install + fn run_grub_install(&self, dest_root: &str, device: &str) -> Result<()> { + 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")] + 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(()) + } + + // check bios_boot partition on gpt type disk + fn get_bios_boot_partition(&self) -> Result> { + let target = self.get_device()?; + // lsblk to list children with bios_boot + let output = Command::new("lsblk") + .args([ + "--json", + "--output", + "PATH,PTTYPE,PARTTYPENAME", + target.trim(), + ]) + .output()?; + if !output.status.success() { + std::io::stderr().write_all(&output.stderr)?; + bail!("Failed to run lsblk"); + } + + let output = String::from_utf8(output.stdout)?; + // Parse the JSON string into the `Devices` struct + let Ok(devices) = serde_json::from_str::(&output) else { + bail!("Could not deserialize JSON output from lsblk"); + }; + + // Find the device with the parttypename "BIOS boot" + for device in devices.blockdevices { + if let Some(parttypename) = &device.parttypename { + if parttypename == "BIOS boot" && device.pttype.as_deref() == Some("gpt") { + return Ok(Some(device.path)); + } + } + } + Ok(None) + } +} + +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) -> Result> { + #[cfg(target_arch = "x86_64")] + if crate::efi::is_efi_booted()? && self.get_bios_boot_partition()?.is_none() { + log::debug!("Skip BIOS adopt"); + return Ok(None); + } + crate::component::query_adopt_state() + } + + fn adopt_update(&self, _: &openat::Dir, update: &ContentMetadata) -> Result { + let Some(meta) = self.query_adopt()? else { + anyhow::bail!("Failed to find adoptable system") + }; + + let device = self.get_device()?; + let device = device.trim(); + self.run_grub_install("/", device)?; + Ok(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, sysroot: &openat::Dir, _: &InstalledContent) -> Result { + let updatemeta = self.query_update(sysroot)?.expect("update available"); + let device = self.get_device()?; + let device = device.trim(); + self.run_grub_install("/", device)?; + + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_lsblk_output() { + let data = include_str!("../tests/fixtures/example-lsblk-output.json"); + let devices: Devices = serde_json::from_str(&data).expect("JSON was not well-formatted"); + assert_eq!(devices.blockdevices.len(), 7); + assert_eq!(devices.blockdevices[0].path, "/dev/sr0"); + assert!(devices.blockdevices[0].pttype.is_none()); + assert!(devices.blockdevices[0].parttypename.is_none()); + } +} diff --git a/bootupd/src/bootupd.rs b/bootupd/src/bootupd.rs new file mode 100644 index 000000000..4d0932ce7 --- /dev/null +++ b/bootupd/src/bootupd.rs @@ -0,0 +1,498 @@ +#[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"))] +use crate::efi; +use crate::model::{ComponentStatus, ComponentUpdatable, ContentMetadata, SavedState, Status}; +use crate::util; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::path::Path; + +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 self_meta = crate::packagesystem::query_files("/", ["/usr/bin/bootupctl"])?; + state.static_configs = Some(self_meta); + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64" + ))] + 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(()) +} + +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(target_arch = "aarch64")] + 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) -> 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 = openat::Dir::open("/")?; + 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 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(&state_guard.sysroot, &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) -> Result { + let sysroot = openat::Dir::open("/")?; + 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 mut state_guard = + SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; + + let inst = component + .adopt_update(&state_guard.sysroot, &update) + .context("Failed adopt and update")?; + state.installed.insert(component.name().into(), inst); + + state_guard.update_state(&state)?; + Ok(update) +} + +/// 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, component) in known_components { + if let Some(adopt_ver) = component.query_adopt()? { + 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"))] + { + let boot_method = if efi::is_efi_booted()? { "EFI" } else { "BIOS" }; + println!("Boot method: {}", boot_method); + } + + Ok(()) +} + +pub(crate) fn client_run_update() -> Result<()> { + crate::try_fail_point!("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)? { + 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 { + let r: ContentMetadata = adopt_and_update(name)?; + 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() -> Result<()> { + let status: Status = status()?; + if status.adoptable.is_empty() { + println!("No components are adoptable."); + } else { + for (name, _) in status.adoptable.iter() { + let r: ContentMetadata = adopt_and_update(name)?; + 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(()) +} + +#[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(); + } +} diff --git a/bootupd/src/cli/bootupctl.rs b/bootupd/src/cli/bootupctl.rs new file mode 100644 index 000000000..48beb64e2 --- /dev/null +++ b/bootupd/src/cli/bootupctl.rs @@ -0,0 +1,175 @@ +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", + "--property", + "PrivateNetwork=yes", + "--property", + "ProtectHome=yes", + "--property", + "MountFlags=slave", + "--pipe", +]; + +/// `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 { + // TODO re-enable this + /// Return the log-level set via command-line flags. + #[allow(dead_code)] + 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, + #[clap(name = "validate", about = "Validate system state")] + Validate, +} + +#[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, +} + +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 => Self::run_adopt_and_update(), + 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) + } + } + } + + /// Runner for `status` verb. + fn run_status(opts: StatusOpts) -> Result<()> { + 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() -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_adopt_and_update() + } + + /// Runner for `validate` verb. + fn run_validate() -> Result<()> { + ensure_running_in_systemd()?; + bootupd::client_run_validate() + } +} + +/// 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(std::env::args()) + .exec(); + // If we got here, it's always an error + return Err(r.into()); + } + Ok(()) +} diff --git a/bootupd/src/cli/bootupd.rs b/bootupd/src/cli/bootupd.rs new file mode 100644 index 000000000..6e1d2abee --- /dev/null +++ b/bootupd/src/cli/bootupd.rs @@ -0,0 +1,127 @@ +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 { + // TODO re-enable this + /// Return the log-level set via command-line flags. + #[allow(dead_code)] + 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 100644 index 000000000..d4fd28af0 --- /dev/null +++ b/bootupd/src/cli/mod.rs @@ -0,0 +1,118 @@ +//! Command-line interface (CLI) logic. + +use std::ffi::OsString; + +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: impl IntoIterator) -> Self + where + T: Into + Clone, + { + let args = args + .into_iter() + .map(|s| Into::::into(s)) + .collect::>(); + 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(), + } + } + + // TODO re-enable this + /// Return the log-level set via command-line flags. + #[allow(dead_code)] + 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 100644 index 000000000..dfff20d5e --- /dev/null +++ b/bootupd/src/component.rs @@ -0,0 +1,216 @@ +/* + * 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::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) -> Result>; + + /// Given an adoptable system and an update, perform the update. + fn adopt_update( + &self, + sysroot: &openat::Dir, + update: &ContentMetadata, + ) -> 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, + sysroot: &openat::Dir, + 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"))] + #[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"))] +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"))] +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 100644 index 000000000..9977acef6 --- /dev/null +++ b/bootupd/src/coreos.rs @@ -0,0 +1,117 @@ +//! 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" + }"##; + + #[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(()) + } + + #[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(()) + } + + #[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 100644 index 000000000..389de5811 --- /dev/null +++ b/bootupd/src/efi.rs @@ -0,0 +1,695 @@ +/* + * 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 cap_std::fs::Dir; +use cap_std_ext::cap_std; +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::filetree; +use crate::model::*; +use crate::ostreeutil; +use crate::util::CommandRunExt; +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"]; + +/// 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"; + +/// The ESP partition label on Fedora CoreOS derivatives +pub(crate) const COREOS_ESP_PART_LABEL: &str = "EFI-SYSTEM"; +pub(crate) const ANACONDA_ESP_PART_LABEL: &str = "EFI\\x20System\\x20Partition"; + +/// 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 { + fn esp_path(&self) -> Result { + self.ensure_mounted_esp(Path::new("/")) + .map(|v| v.join("EFI")) + } + + fn open_esp_optional(&self) -> Result> { + if !is_efi_booted()? && self.get_esp_device().is_none() { + log::debug!("Skip EFI"); + return Ok(None); + } + let sysroot = openat::Dir::open("/")?; + let esp = sysroot.sub_dir_optional(&self.esp_path()?)?; + Ok(esp) + } + + fn open_esp(&self) -> Result { + self.ensure_mounted_esp(Path::new("/"))?; + let sysroot = openat::Dir::open("/")?; + let esp = sysroot.sub_dir(&self.esp_path()?)?; + Ok(esp) + } + + fn get_esp_device(&self) -> Option { + let esp_devices = [COREOS_ESP_PART_LABEL, ANACONDA_ESP_PART_LABEL] + .into_iter() + .map(|p| Path::new("/dev/disk/by-partlabel/").join(p)); + let mut esp_device = None; + for path in esp_devices { + if path.exists() { + esp_device = Some(path); + break; + } + } + return esp_device; + } + + pub(crate) fn ensure_mounted_esp(&self, root: &Path) -> Result { + let mut mountpoint = self.mountpoint.borrow_mut(); + if let Some(mountpoint) = mountpoint.as_deref() { + return Ok(mountpoint.to_owned()); + } + for &mnt in ESP_MOUNTS { + let mnt = root.join(mnt); + if !mnt.exists() { + continue; + } + let st = + rustix::fs::statfs(&mnt).with_context(|| format!("statfs failed for {mnt:?}"))?; + if st.f_type != libc::MSDOS_SUPER_MAGIC { + continue; + } + log::debug!("Reusing existing {mnt:?}"); + return Ok(mnt); + } + + let esp_device = self + .get_esp_device() + .ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?; + for &mnt in ESP_MOUNTS.iter() { + let mnt = root.join(mnt); + if !mnt.exists() { + continue; + } + let status = std::process::Command::new("mount") + .arg(&esp_device) + .arg(&mnt) + .status()?; + if !status.success() { + anyhow::bail!("Failed to mount {:?}", esp_device); + } + log::debug!("Mounted at {mnt:?}"); + *mountpoint = Some(mnt); + break; + } + Ok(mountpoint.as_deref().unwrap().to_owned()) + } + + fn unmount(&self) -> Result<()> { + if let Some(mount) = self.mountpoint.borrow_mut().take() { + let status = Command::new("umount").arg(&mount).status()?; + if !status.success() { + anyhow::bail!("Failed to unmount {mount:?}: {status:?}"); + } + 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(); + return Ok(re.replace_all(&content, "").to_string()); + } + // 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) -> Result> { + let esp = self.open_esp_optional()?; + if esp.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() + } + + /// Given an adoptable system and an update, perform the update. + fn adopt_update( + &self, + sysroot: &openat::Dir, + updatemeta: &ContentMetadata, + ) -> Result { + let Some(meta) = self.query_adopt()? else { + anyhow::bail!("Failed to find adoptable system") + }; + + let esp = self.open_esp()?; + validate_esp(&esp)?; + let updated = sysroot + .sub_dir(&component_updatedirname(self)) + .context("opening update dir")?; + let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; + // For adoption, we should only touch files that we know about. + let diff = updatef.relative_diff_to(&esp)?; + log::trace!("applying adoption diff: {}", &diff); + filetree::apply_diff(&updated, &esp, &diff, None).context("applying filesystem changes")?; + Ok(InstalledContent { + meta: updatemeta.clone(), + filetree: Some(updatef), + adopted_from: Some(meta.version), + }) + } + + // TODO: Remove dest_root; it was never actually used + 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 destdir = &self.ensure_mounted_esp(Path::new(dest_root))?; + + let destd = &openat::Dir::open(destdir) + .with_context(|| format!("opening dest dir {}", destdir.display()))?; + validate_esp(destd)?; + + // TODO - add some sort of API that allows directly setting the working + // directory to a file descriptor. + let r = std::process::Command::new("cp") + .args(["-rp", "--reflink=auto"]) + .arg(&srcdir_name) + .arg(destdir) + .current_dir(format!("/proc/self/fd/{}", src_root.as_raw_fd())) + .status()?; + if !r.success() { + anyhow::bail!("Failed to copy"); + } + 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, + sysroot: &openat::Dir, + current: &InstalledContent, + ) -> Result { + let currentf = current + .filetree + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + let updatemeta = self.query_update(sysroot)?.expect("update available"); + let updated = sysroot + .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)?; + self.ensure_mounted_esp(Path::new("/"))?; + let destdir = self.open_esp().context("opening EFI dir")?; + validate_esp(&destdir)?; + log::trace!("applying diff: {}", &diff); + filetree::apply_diff(&updated, &destdir, &diff, None) + .context("applying filesystem changes")?; + let adopted_from = None; + Ok(InstalledContent { + meta: updatemeta, + filetree: Some(updatef), + adopted_from, + }) + } + + fn generate_update_metadata(&self, sysroot_path: &str) -> Result { + let ostreebootdir = Path::new(sysroot_path).join(ostreeutil::BOOT_PREFIX); + let dest_efidir = component_updatedir(sysroot_path, self); + + 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); + } + + // 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)?; + let files = crate::util::filenames(&efidir)?.into_iter().map(|mut f| { + f.insert_str(0, "/boot/efi/EFI/"); + f + }); + + let meta = packagesystem::query_files(sysroot_path, files)?; + write_update_metadata(sysroot_path, self, &meta)?; + Ok(meta) + } + + fn query_update(&self, sysroot: &openat::Dir) -> Result> { + get_component_update(sysroot, self) + } + + fn validate(&self, current: &InstalledContent) -> Result { + if !is_efi_booted()? && self.get_esp_device().is_none() { + return Ok(ValidationResult::Skip); + } + let currentf = current + .filetree + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + self.ensure_mounted_esp(Path::new("/"))?; + let efidir = self.open_esp()?; + let diff = currentf.relative_diff_to(&efidir)?; + let mut errs = Vec::new(); + 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); + 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(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 output = Command::new(EFIBOOTMGR) + .args(["-b", entry.id.as_str(), "-B"]) + .output()?; + let st = output.status; + if !st.success() { + std::io::copy( + &mut std::io::Cursor::new(output.stderr), + &mut std::io::stderr().lock(), + )?; + anyhow::bail!("Failed to invoke {EFIBOOTMGR}: {st:?}"); + } + } + } + + 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 st = Command::new(EFIBOOTMGR) + .args([ + "--create", + "--disk", + device, + "--part", + partition_number.as_str(), + "--loader", + loader.as_str(), + "--label", + target, + ]) + .status()?; + if !st.success() { + anyhow::bail!("Failed to invoke {EFIBOOTMGR}") + } + anyhow::Ok(()) +} + +#[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) +} + +#[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.remove_file("etc/system-release")?; + let name = get_product_name(&tmpd)?; + assert!(name.len() > 0); + } + Ok(()) + } +} diff --git a/bootupd/src/failpoints.rs b/bootupd/src/failpoints.rs new file mode 100644 index 000000000..689457522 --- /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 once it merges +/// copy from +#[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 100644 index 000000000..80b942b34 --- /dev/null +++ b/bootupd/src/filesystem.rs @@ -0,0 +1,47 @@ +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::unix::process::CommandExt; +use std::process::Command; + +use anyhow::{Context, Result}; +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 = unsafe { + Command::new("findmnt") + .args(["-J", "-v", "--output=SOURCE,FSTYPE,OPTIONS,UUID", path]) + .pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into)) + .output()? + }; + let st = o.status; + if !st.success() { + let _ = std::io::stderr().write_all(&o.stderr)?; + anyhow::bail!("findmnt failed: {st:?}"); + } + let o: Findmnt = serde_json::from_reader(std::io::Cursor::new(&o.stdout)) + .context("Parsing findmnt output")?; + 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 100644 index 000000000..819d0fcfb --- /dev/null +++ b/bootupd/src/filetree.rs @@ -0,0 +1,707 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +use anyhow::{bail, Context, Result}; +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +use camino::{Utf8Path, Utf8PathBuf}; +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +use openat_ext::OpenatDirExt; +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +use openssl::hash::{Hasher, MessageDigest}; +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"))] +use std::os::unix::io::AsRawFd; +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// The prefix we apply to our temporary files. +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +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"))] +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"))] + 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"))] + 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"))] + 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"))] + 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"))] + 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"))] + 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"))] +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"))] +pub(crate) struct ApplyUpdateOptions { + pub(crate) skip_removals: bool, + pub(crate) skip_sync: bool, +} + +// syncfs() is a Linux-specific system call, which doesn't seem +// to be bound in nix today. I found https://github.com/XuShaohua/nc +// but that's a nontrivial dependency with not a lot of code review. +// Let's just fork off a helper process for now. +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +pub(crate) fn syncfs(d: &openat::Dir) -> Result<()> { + use rustix::fs::{Mode, OFlags}; + let d = unsafe { BorrowedFd::borrow_raw(d.as_raw_fd()) }; + let oflags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::DIRECTORY; + let d = rustix::fs::openat(d, ".", oflags, Mode::empty())?; + rustix::fs::syncfs(d).map_err(Into::into) +} + +/// Copy from src to dst at root dir +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn copy_dir(root: &openat::Dir, src: &str, dst: &str) -> Result<()> { + let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) }; + let r = unsafe { + Command::new("cp") + .args(["-a"]) + .arg(src) + .arg(dst) + .pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into)) + .status()? + }; + if !r.success() { + anyhow::bail!("Failed to copy {src} to {dst}"); + } + 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"))] +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"))] +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 + if !destdir.exists(&first_dir_tmp)? { + copy_dir(destdir, first_dir.as_str(), &first_dir_tmp)?; + updates.insert(first_dir, first_dir_tmp); + } + } else { + path_tmp = path.to_path_buf(); + } + destdir + .remove_file(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)?; + } + 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 { + syncfs(destdir)?; + } + + // 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 { + syncfs(destdir)?; + } + 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(()) + } + #[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); + } + { + a.remove_file(testfile)?; + 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)?, false); + 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/grub2/README.md b/bootupd/src/grub2/README.md new file mode 100644 index 000000000..79d9b8891 --- /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/README.md b/bootupd/src/grub2/configs.d/README.md new file mode 100644 index 000000000..a278f521d --- /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 100644 index 000000000..3d552c307 --- /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-post.cfg b/bootupd/src/grub2/grub-static-post.cfg new file mode 100644 index 000000000..e426e3907 --- /dev/null +++ b/bootupd/src/grub2/grub-static-post.cfg @@ -0,0 +1,17 @@ +if [ x$feature_timeout_style = xy ] ; then + set timeout_style=menu + set timeout=1 +# Fallback normal timeout code in case the timeout_style feature is +# unavailable. +else + set timeout=1 +fi + +# Import user defined configuration +# tracker: https://github.com/coreos/fedora-coreos-tracker/issues/805 +if [ -f $prefix/user.cfg ]; then + source $prefix/user.cfg +fi + +blscfg + diff --git a/bootupd/src/grub2/grub-static-pre.cfg b/bootupd/src/grub2/grub-static-pre.cfg new file mode 100644 index 000000000..d4a81d883 --- /dev/null +++ b/bootupd/src/grub2/grub-static-pre.cfg @@ -0,0 +1,66 @@ +# 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 + +if [ x"${feature_menuentry_id}" = xy ]; then + menuentry_id_option="--id" +else + menuentry_id_option="" +fi + +function load_video { + if [ x$feature_all_video_module = xy ]; then + insmod all_video + else + insmod efi_gop + insmod efi_uga + insmod ieee1275_fb + insmod vbe + insmod vga + insmod video_bochs + insmod video_cirrus + fi +} + +# Other package code will be injected from here diff --git a/bootupd/src/grubconfigs.rs b/bootupd/src/grubconfigs.rs new file mode 100644 index 000000000..09aeebfb3 --- /dev/null +++ b/bootupd/src/grubconfigs.rs @@ -0,0 +1,129 @@ +use std::fmt::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use fn_error_context::context; +use openat_ext::OpenatDirExt; + +/// The subdirectory of /boot we use +const GRUB2DIR: &str = "grub2"; +const CONFIGDIR: &str = "/usr/lib/bootupd/grub2-static"; +const DROPINDIR: &str = "configs.d"; + +/// 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 = std::fs::read_to_string(Path::new(CONFIGDIR).join("grub-static-pre.cfg"))?; + + 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, "source $prefix/{name}")?; + dropindir + .copy_file_at(name, bootdir, format!("{GRUB2DIR}/{name}")) + .with_context(|| format!("Copying {name}"))?; + println!("Installed {name}"); + } + + { + let post = std::fs::read_to_string(Path::new(CONFIGDIR).join("grub-static-post.cfg"))?; + config.push_str(post.as_str()); + } + + bootdir + .write_file_contents(format!("{GRUB2DIR}/grub.cfg"), 0o644, config.as_bytes()) + .context("Copying grub-static.cfg")?; + println!("Installed: grub.cfg"); + + 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 = format!("{GRUB2DIR}/bootuuid.cfg"); + bootdir + .write_file_contents(&uuid_path, 0o644, grub2_uuid_contents) + .context("Writing bootuuid.cfg")?; + Some(uuid_path) + } else { + None + }; + + 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 { + // SAFETY: we always have a filename + let filename = Path::new(&uuid_path).file_name().unwrap(); + let target = &vendor.join(filename); + bootdir + .copy_file_at(uuid_path, &efidir, target) + .context("Writing bootuuid.cfg to efi dir")?; + } + } + } + + Ok(()) +} + +#[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(()) + } +} diff --git a/bootupd/src/lib.rs b/bootupd/src/lib.rs new file mode 100644 index 000000000..c4cefd157 --- /dev/null +++ b/bootupd/src/lib.rs @@ -0,0 +1,55 @@ +/*! +**Boot**loader **upd**ater. + +This is an early prototype hidden/not-yet-standardized mechanism +which just updates EFI for now (x86_64/aarch64 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)] + +mod backend; +#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] +mod bios; +mod bootupd; +mod cli; +mod component; +mod coreos; +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +mod efi; +mod failpoints; +mod filesystem; +mod filetree; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64" +))] +mod grubconfigs; +mod model; +mod model_legacy; +mod ostreeutil; +mod packagesystem; +mod sha512string; +mod util; + +pub const BACKEND_NAME: &str = "bootupd"; +pub const CLIENT_NAME: &str = "bootupctl"; + +pub fn run(args: impl IntoIterator) -> anyhow::Result<()> +where + T: Into + Clone, +{ + let _scenario = fail::FailScenario::setup(); + let cli_opts = cli::MultiCall::from_args(args); + + // Dispatch CLI subcommand. + cli_opts.run() +} diff --git a/bootupd/src/model.rs b/bootupd/src/model.rs new file mode 100644 index 000000000..86b866a95 --- /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 100644 index 000000000..0487d2dcc --- /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 100644 index 000000000..e38a9d9fd --- /dev/null +++ b/bootupd/src/ostreeutil.rs @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::path::Path; + +/// https://github.com/coreos/rpm-ostree/pull/969/commits/dc0e8db5bd92e1f478a0763d1a02b48e57022b59 +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +pub(crate) const BOOT_PREFIX: &str = "usr/lib/ostree-boot"; + +pub(crate) fn rpm_cmd>(sysroot: P) -> std::process::Command { + let sysroot = sysroot.as_ref(); + let dbpath = sysroot.join("usr/share/rpm"); + let dbpath_arg = { + let mut s = std::ffi::OsString::new(); + s.push("--dbpath="); + s.push(dbpath.as_os_str()); + s + }; + let mut c = std::process::Command::new("rpm"); + c.arg(&dbpath_arg); + c +} diff --git a/bootupd/src/packagesystem.rs b/bootupd/src/packagesystem.rs new file mode 100644 index 000000000..2536a93cc --- /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 100644 index 000000000..eb8e6c14d --- /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 100644 index 000000000..0635ef41e --- /dev/null +++ b/bootupd/src/util.rs @@ -0,0 +1,98 @@ +use std::collections::HashSet; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use openat_ext::OpenatDirExt; + +pub(crate) trait CommandRunExt { + fn run(&mut self) -> Result<()>; +} + +impl CommandRunExt for Command { + fn run(&mut self) -> Result<()> { + let r = self.status()?; + if !r.success() { + bail!("Child [{:?}] exited: {}", self, r); + } + Ok(()) + } +} + +/// 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)) +} 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 000000000..a35775334 --- /dev/null +++ b/bootupd/tests/e2e-update/e2e-update-in-vm.sh @@ -0,0 +1,98 @@ +#!/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: .*" +ok adopt-and-update + +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 000000000..5fc19e878 --- /dev/null +++ b/bootupd/tests/e2e-update/e2e-update.sh @@ -0,0 +1,153 @@ +#!/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}) +} + +create_manifest_fork() { + if test ! -f src/config/bootupd-fork; then + echo "NOTICE: overriding src/config in ${COSA_DIR}" + sleep 2 + runv rm -rf src/config.bootupd-testing-old + runv mv src/config src/config.orig + runv git clone src/config.orig src/config + touch src/config/bootupd-fork + # This will fall over if the upstream manifest gains `packages:` + cat >> src/config/manifest.yaml << EOF +packages: + - test-bootupd-payload +EOF + echo "forked src/config" + else + fatal "already forked manifest" + fi +} + +undo_manifest_fork() { + test -d src/config.orig + assert_file_has_content src/config/manifest.yaml test-bootupd-payload + if test -f src/config/bootupd-fork; then + runv rm src/config -rf + else + # Keep this around just in case + runv mv src/config{,.bootupd-testing-old} + fi + runv mv src/config.orig src/config + test ! -f src/config/bootupd-fork + echo "undo src/config fork OK" +} + +if test -z "${e2e_skip_build:-}"; then + echo "Building starting image" + rm -f ${overrides}/rpm/*.rpm + # Version from F39 GA + add_override grub2-2.06-100.fc39 + runv cosa build + prev_image=$(runv cosa meta --image-path qemu) + create_manifest_fork + rm -f ${overrides}/rpm/*.rpm + echo "Building update ostree" + # Version queued in current updates + add_override grub2-2.06-123.fc40 + mv ${test_tmpdir}/yumrepo/packages/$(arch)/*.rpm ${overrides}/rpm/ + # Only build ostree update + runv cosa build ostree + undo_manifest_fork +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 100644 index 000000000..5a6f3c22f --- /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 100644 index 000000000..f0aac3e0d --- /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 100644 index 000000000..85a0ae647 --- /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 100644 index 000000000..467e4b058 --- /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 100644 index 000000000..3df41595f --- /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 100644 index 000000000..cc19f4613 --- /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/test-bootupd b/bootupd/tests/kola/test-bootupd new file mode 100755 index 000000000..315656cc4 --- /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 100644 index 000000000..1b74efc88 --- /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/xtask/.gitignore b/bootupd/xtask/.gitignore new file mode 100644 index 000000000..4906db3f4 --- /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 100644 index 000000000..aea18c755 --- /dev/null +++ b/bootupd/xtask/Cargo.toml @@ -0,0 +1,14 @@ +[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" +tempfile = "3.3" +xshell = { version = "0.2" } diff --git a/bootupd/xtask/src/main.rs b/bootupd/xtask/src/main.rs new file mode 100644 index 000000000..09b2c2a2e --- /dev/null +++ b/bootupd/xtask/src/main.rs @@ -0,0 +1,201 @@ +use std::fs::File; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::process::{Command, Stdio}; + +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"; + +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. +#[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::NaiveDateTime::from_timestamp_opt(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, +} + +#[context("Packaging")] +fn impl_package(sh: &Shell) -> Result { + let v = gitrev(sh)?; + let timestamp = git_timestamp(sh)?; + // We always inject the timestamp first to ensure that newer is better. + let v = format!("{timestamp}.{v}"); + println!("Using version {v}"); + let namev = format!("{NAME}-{v}"); + let target = get_target_dir()?; + let p = target.join(format!("{namev}.tar.zstd")); + let o = File::create(&p).context("Creating output file")?; + let prefix = format!("{namev}/"); + let st = Command::new("git") + .args([ + "archive", + "--format=tar", + "--prefix", + prefix.as_str(), + "HEAD", + ]) + .stdout(Stdio::from(o)) + .status() + .context("Executing git archive")?; + if !st.success() { + anyhow::bail!("Failed to run {st:?}"); + } + Ok(Package { + version: v, + srcpath: p, + }) +} + +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 srpm = impl_srpm(sh)?; + println!("Generated: {srpm}"); + Ok(()) +} + +fn print_help(_sh: &Shell) -> Result<()> { + eprintln!( + "Tasks: + - vendor +" + ); + Ok(()) +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index c50c250a4..fba7e2f54 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -18,6 +18,7 @@ anstream = "0.6.13" anstyle = "1.0.6" anyhow = { workspace = true } bootc-utils = { path = "../utils" } +bootupd = { path = "../bootupd" } camino = { workspace = true, features = ["serde1"] } ostree-ext = { version = "0.15.0" } chrono = { workspace = true, features = ["serde"] } diff --git a/lib/src/bootloader.rs b/lib/src/bootloader.rs index e90b365ef..71b82a683 100644 --- a/lib/src/bootloader.rs +++ b/lib/src/bootloader.rs @@ -44,12 +44,12 @@ pub(crate) fn install_via_bootupd( let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); let devpath = get_bootupd_device(device)?; - let args = ["backend", "install", "--write-uuid"] + let args = ["internals", "bootupd", "install", "--write-uuid"] .into_iter() .chain(verbose) .chain(bootupd_opts.iter().copied().flatten()) .chain(["--device", devpath.as_str(), rootfs.as_str()]); - Task::new("Running bootupctl to install bootloader", "bootupctl") + Task::new("Running bootupctl to install bootloader", "bootc") .args(args) .verbose() .run() diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 5e19d400e..8c98011aa 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -290,6 +290,14 @@ pub(crate) enum InternalsOpts { #[allow(dead_code)] late_dir: Option, }, + Bootupd { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + Bootupctl { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, FixupEtcFstab, /// Should only be used by `make update-generated` PrintJsonSchema, @@ -794,17 +802,21 @@ async fn usroverlay() -> Result<()> { .into()); } -/// Perform process global initialization. This should be called as early as possible -/// in the standard `main` function. -pub fn global_init() -> Result<()> { - // In some cases we re-exec with a temporary binary, - // so ensure that the syslog identifier is set. - let name = "bootc"; +// Set the global process name +fn set_process_name(name: &str) { ostree::glib::set_prgname(name.into()); if let Err(e) = rustix::thread::set_name(&CString::new(name).unwrap()) { // This shouldn't ever happen eprintln!("failed to set name: {e}"); } +} + +/// Perform process global initialization. This should be called as early as possible +/// in the standard `main` function. +pub fn global_init() -> Result<()> { + // In some cases we re-exec with a temporary binary, + // so ensure that the syslog identifier is set. + set_process_name("bootc"); let am_root = rustix::process::getuid().is_root(); // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common) // bombing out if it is unset. @@ -840,11 +852,24 @@ impl Opt { let first: OsString = first.into(); let argv0 = first.to_str().and_then(|s| s.rsplit_once('/')).map(|s| s.1); tracing::debug!("argv0={argv0:?}"); - if matches!(argv0, Some(InternalsOpts::GENERATOR_BIN)) { - let base_args = ["bootc", "internals", "systemd-generator"] - .into_iter() - .map(OsString::from); - return Opt::parse_from(base_args.chain(args.map(|i| i.into()))); + if let Some(argv0) = argv0 { + match argv0 { + InternalsOpts::GENERATOR_BIN => { + let base_args = ["bootc", "internals", "systemd-generator"] + .into_iter() + .map(OsString::from); + return Opt::parse_from(base_args.chain(args.map(|i| i.into()))); + } + bootupd::BACKEND_NAME | bootupd::CLIENT_NAME => { + let base_args = ["bootc", "internals", argv0] + .into_iter() + .map(OsString::from); + return Opt::parse_from(base_args.chain(args.map(|i| i.into()))); + } + _ => { + // Fallthrough + } + } } Some(first) } else { @@ -931,6 +956,12 @@ async fn run_from_opt(opt: Opt) -> Result<()> { let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?; crate::generator::generator(root, unit_dir) } + InternalsOpts::Bootupd { args } => { + bootupd::run(std::iter::once(bootupd::BACKEND_NAME.into()).chain(args)) + } + InternalsOpts::Bootupctl { args } => { + bootupd::run(std::iter::once(bootupd::CLIENT_NAME.into()).chain(args)) + } InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root), InternalsOpts::PrintJsonSchema => { let schema = schema_for!(crate::spec::Host);