├── .cargo └── config.toml ├── .cci.jenkinsfile ├── .copr └── Makefile ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ └── release-checklist.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── cross.yml │ └── rust.yml ├── .gitignore ├── COPYRIGHT ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── Makefile ├── README-design.md ├── README-devel.md ├── README.md ├── ci ├── build-test.sh ├── prepare-release.sh └── prow │ ├── Dockerfile │ └── fcos-e2e.sh ├── code-of-conduct.md ├── contrib └── packaging │ └── bootupd.spec ├── doc └── dependency_decisions.yml ├── src ├── backend │ ├── mod.rs │ └── statefile.rs ├── bios.rs ├── blockdev.rs ├── bootupd.rs ├── cli │ ├── bootupctl.rs │ ├── bootupd.rs │ └── mod.rs ├── component.rs ├── coreos.rs ├── efi.rs ├── failpoints.rs ├── filesystem.rs ├── filetree.rs ├── grub2 │ ├── README.md │ ├── configs.d │ │ ├── 01_users.cfg │ │ ├── 10_blscfg.cfg │ │ ├── 14_menu_show_once.cfg │ │ ├── 30_uefi-firmware.cfg │ │ ├── 41_custom.cfg │ │ └── README.md │ ├── grub-static-efi.cfg │ └── grub-static-pre.cfg ├── grubconfigs.rs ├── main.rs ├── model.rs ├── model_legacy.rs ├── ostreeutil.rs ├── packagesystem.rs ├── sha512string.rs └── util.rs ├── systemd └── bootloader-update.service ├── tests ├── e2e-update │ ├── e2e-update-in-vm.sh │ ├── e2e-update.sh │ └── testrpmbuild.sh ├── fixtures │ ├── example-lsblk-output.json │ ├── example-state-v0-legacy.json │ ├── example-state-v0.json │ └── example-status-v0.json ├── kola │ ├── data │ │ └── libtest.sh │ └── test-bootupd └── kolainst │ └── Makefile └── xtask ├── .gitignore ├── Cargo.toml └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --manifest-path ./xtask/Cargo.toml --" 3 | -------------------------------------------------------------------------------- /.cci.jenkinsfile: -------------------------------------------------------------------------------- 1 | // Documentation: https://github.com/coreos/coreos-ci/blob/main/README-upstream-ci.md 2 | 3 | properties([ 4 | // abort previous runs when a PR is updated to save resources 5 | disableConcurrentBuilds(abortPrevious: true) 6 | ]) 7 | 8 | stage("Build") { 9 | parallel build: { 10 | def n = 5 11 | buildPod(runAsUser: 0, memory: "2Gi", cpu: "${n}") { 12 | checkout scm 13 | stage("Core build") { 14 | shwrap(""" 15 | make -j ${n} 16 | """) 17 | } 18 | stage("Unit tests") { 19 | shwrap(""" 20 | dnf install -y grub2-tools-minimal 21 | cargo test 22 | """) 23 | } 24 | shwrap(""" 25 | make install install-grub-static install-systemd-unit DESTDIR=\$(pwd)/insttree/ 26 | tar -c -C insttree/ -zvf insttree.tar.gz . 27 | """) 28 | stash includes: 'insttree.tar.gz', name: 'build' 29 | } 30 | }, 31 | codestyle: { 32 | buildPod { 33 | checkout scm 34 | shwrap("cargo fmt -- --check") 35 | } 36 | } 37 | } 38 | 39 | // Build FCOS and do a kola basic run 40 | // FIXME update to main branch once https://github.com/coreos/fedora-coreos-config/pull/595 merges 41 | cosaPod(runAsUser: 0, memory: "4608Mi", cpu: "4") { 42 | stage("Build FCOS") { 43 | checkout scm 44 | unstash 'build' 45 | // Note that like {rpm-,}ostree we want to install to both / and overrides/rootfs 46 | // because bootupd is used both during the `rpm-ostree compose tree` as well as 47 | // inside the target operating system. 48 | shwrap(""" 49 | mkdir insttree 50 | tar -C insttree -xzvf insttree.tar.gz 51 | rsync -rlv insttree/ / 52 | coreos-assembler init --force https://github.com/coreos/fedora-coreos-config 53 | mkdir -p overrides/rootfs 54 | mv insttree/* overrides/rootfs/ 55 | rmdir insttree 56 | cosa fetch 57 | cosa build 58 | """) 59 | } 60 | // The e2e-adopt test will use the ostree commit we just generated above 61 | // but a static qemu base image. 62 | try { 63 | // Now a test that upgrades using bootupd 64 | stage("e2e upgrade test") { 65 | shwrap(""" 66 | git config --global --add safe.directory "\$(pwd)" 67 | env COSA_DIR=${env.WORKSPACE} ./tests/e2e-update/e2e-update.sh 68 | """) 69 | } 70 | stage("Kola testing") { 71 | // The previous e2e leaves things only having built an ostree update 72 | shwrap("cosa build") 73 | // bootupd really can't break upgrades for the OS 74 | kola(cosaDir: "${env.WORKSPACE}", extraArgs: "ext.*bootupd*", skipUpgrade: true, skipBasicScenarios: true) 75 | } 76 | } finally { 77 | archiveArtifacts allowEmptyArchive: true, artifacts: 'tmp/console.txt' 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.copr/Makefile: -------------------------------------------------------------------------------- 1 | srpm: 2 | dnf -y install cargo git openssl-devel 3 | # similar to https://github.com/actions/checkout/issues/760, but for COPR 4 | git config --global --add safe.directory '*' 5 | cargo install cargo-vendor-filterer 6 | cargo xtask package-srpm 7 | mv target/*.src.rpm $$outdir 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | .cosa 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/release-checklist.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | The release process follows the usual PR-and-review flow, allowing an external reviewer to have a final check before publishing. 4 | 5 | In order to ease downstream packaging of Rust binaries, an archive of vendored dependencies is also provided (only relevant for offline builds). 6 | 7 | ## Requirements 8 | 9 | This guide requires: 10 | 11 | * A web browser (and network connectivity) 12 | * `git` 13 | * [GPG setup][GPG setup] and personal key for signing 14 | * [git-evtag](https://github.com/cgwalters/git-evtag/) 15 | * `cargo` (suggested: latest stable toolchain from [rustup][rustup]) 16 | * `cargo-release` (suggested: `cargo install -f cargo-release`) 17 | * `cargo vendor-filterer` (suggested: `cargo install -f cargo-vendor-filterer`) 18 | * A verified account on crates.io 19 | * Write access to this GitHub project 20 | * Upload access to this project on GitHub, crates.io 21 | * Membership in the [Fedora CoreOS Crates Owners group](https://github.com/orgs/coreos/teams/fedora-coreos-crates-owners/members) 22 | 23 | ## Release checklist 24 | 25 | - Prepare local branch+commit 26 | - [ ] `git checkout -b release` 27 | - [ ] Bump the version number in `Cargo.toml`. Usually you just want to bump the patch. 28 | - [ ] Run `cargo build` to ensure `Cargo.lock` would be updated 29 | - [ ] Commit changes `git commit -a -m 'Release x.y.z'`; include some useful brief changelog. 30 | 31 | - Prepare the release 32 | - [ ] Run `./ci/prepare-release.sh` 33 | 34 | - Validate that `origin` points to the canonical upstream repository and not your fork: 35 | `git remote show origin` should not be `github.com/$yourusername/$project` but should 36 | be under the organization ownership. The remote `yourname` should be for your fork. 37 | 38 | - open and merge a PR for this release: 39 | - [ ] `git push --set-upstream origin release` 40 | - [ ] open a web browser and create a PR for the branch above 41 | - [ ] make sure the resulting PR contains the commit 42 | - [ ] in the PR body, write a short changelog with relevant changes since last release 43 | - [ ] get the PR reviewed, approved and merged 44 | 45 | - publish the artifacts (tag and crate): 46 | - [ ] `git fetch origin && git checkout ${RELEASE_COMMIT}` 47 | - [ ] verify `Cargo.toml` has the expected version 48 | - [ ] `git-evtag sign v${RELEASE_VER}` 49 | - [ ] `git push --tags origin v${RELEASE_VER}` 50 | - [ ] `cargo publish` 51 | 52 | - publish this release on GitHub: 53 | - [ ] 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 54 | - [ ] write a short changelog with `git shortlog $last_tag..` (i.e. re-use the PR content). See previous releases for format, for example [`v0.2.25`](https://hackmd.io/@hhei/SkYe0AtMye) 55 | - [ ] upload `target/${PROJECT}-${RELEASE_VER}-vendor.tar.gz` 56 | - [ ] record digests of local artifacts: 57 | - `sha256sum target/package/${PROJECT}-${RELEASE_VER}.crate` 58 | - `sha256sum target/${PROJECT}-${RELEASE_VER}-vendor.tar.gz` 59 | - [ ] publish release 60 | 61 | - clean up: 62 | - [ ] `git push origin :release` 63 | - [ ] `cargo clean` 64 | - [ ] `git checkout main` 65 | 66 | - Fedora packaging: 67 | - [ ] update the `rust-bootupd` spec file in [Fedora](https://src.fedoraproject.org/rpms/rust-bootupd) 68 | - bump the `Version` 69 | - remove any patches obsoleted by the new release 70 | - [ ] run `spectool -g -S rust-bootupd.spec` 71 | - [ ] run `kinit your_fas_account@FEDORAPROJECT.ORG` 72 | - [ ] run `fedpkg new-sources ` 73 | - [ ] PR the changes in [Fedora](https://src.fedoraproject.org/rpms/rust-bootupd) 74 | - [ ] once the PR merges to rawhide, merge rawhide into the other relevant branches (e.g. f35) then push those, for example: 75 | ```bash 76 | git checkout rawhide 77 | git pull --ff-only 78 | git checkout f35 79 | git merge --ff-only rawhide 80 | git push origin f35 81 | ``` 82 | - [ ] on each of those branches run `fedpkg build` 83 | - [ ] once the builds have finished, submit them to [bodhi](https://bodhi.fedoraproject.org/updates/new), filling in: 84 | - `rust-bootupd` for `Packages` 85 | - selecting the build(s) that just completed, except for the rawhide one (which gets submitted automatically) 86 | - writing brief release notes like "New upstream release; see release notes at `link to GitHub release`" 87 | - leave `Update name` blank 88 | - `Type`, `Severity` and `Suggestion` can be left as `unspecified` unless it is a security release. In that case select `security` with the appropriate severity. 89 | - `Stable karma` and `Unstable` karma can be set to `2` and `-1`, respectively. 90 | - [ ] [submit a fast-track](https://github.com/coreos/fedora-coreos-config/actions/workflows/add-override.yml) for FCOS testing-devel 91 | - [ ] [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) 92 | 93 | - RHCOS packaging: 94 | - [ ] update the `rust-bootupd` spec file 95 | - bump the `Version` 96 | - switch the `Release` back to `1%{?dist}` 97 | - remove any patches obsoleted by the new release 98 | - update changelog 99 | - [ ] run `spectool -g -S rust-bootupd.spec` 100 | - [ ] run `kinit your_account@REDHAT.COM` 101 | - [ ] run `rhpkg new-sources ` 102 | - [ ] PR the changes 103 | - [ ] get the PR reviewed and merge it 104 | - [ ] update your local repo and run `rhpkg build` 105 | 106 | CentOS Stream 9 packaging: 107 | - [ ] to be written 108 | 109 | [rustup]: https://rustup.rs/ 110 | [crates-io]: https://crates.io/ 111 | [GPG setup]: https://docs.github.com/en/github/authenticating-to-github/managing-commit-signature-verification 112 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Maintained in https://github.com/coreos/repo-templates 2 | # Do not edit downstream. 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | labels: ["skip-notes"] 11 | open-pull-requests-limit: 3 12 | - package-ecosystem: cargo 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | open-pull-requests-limit: 10 17 | labels: 18 | - area/dependencies 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | actions: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | workflow_dispatch: {} 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | CARGO_TERM_COLOR: always 19 | 20 | jobs: 21 | c9s-bootc-e2e: 22 | strategy: 23 | matrix: 24 | runner: 25 | - ubuntu-24.04 26 | - ubuntu-24.04-arm 27 | 28 | runs-on: [ "${{ matrix.runner }}" ] 29 | 30 | steps: 31 | - name: Get a newer podman for heredoc support (from debian testing) 32 | run: | 33 | set -eux 34 | echo 'deb [trusted=yes] https://ftp.debian.org/debian/ testing main' | sudo tee /etc/apt/sources.list.d/testing.list 35 | sudo apt update 36 | sudo apt install -y crun/testing podman/testing skopeo/testing 37 | 38 | - uses: actions/checkout@v4 39 | 40 | - name: Install podman 41 | if: ( matrix.runner == 'ubuntu-24.04-arm' ) 42 | run: | 43 | sudo apt update -y 44 | sudo apt install -y podman 45 | 46 | - name: build 47 | run: sudo podman build -t localhost/bootupd:latest -f Dockerfile . 48 | 49 | - name: bootupctl status in container 50 | run: | 51 | set -xeuo pipefail 52 | arch="$(uname --machine)" 53 | if [[ "${arch}" == "x86_64" ]]; then 54 | components_text='Available components: BIOS EFI' 55 | components_json='{"components":["BIOS","EFI"]}' 56 | else 57 | # Assume aarch64 for now 58 | components_text='Available components: EFI' 59 | components_json='{"components":["EFI"]}' 60 | fi 61 | output=$(sudo podman run --rm -ti localhost/bootupd:latest bootupctl status | tr -d '\r') 62 | [ "${components_text}" == "${output}" ] 63 | output=$(sudo podman run --rm -ti localhost/bootupd:latest bootupctl status --json) 64 | [ "${components_json}" == "${output}" ] 65 | 66 | - name: bootc install to disk 67 | run: | 68 | set -xeuo pipefail 69 | sudo truncate -s 10G myimage.raw 70 | sudo podman run --rm --privileged -v .:/target --pid=host --security-opt label=disable \ 71 | -v /var/lib/containers:/var/lib/containers \ 72 | -v /dev:/dev \ 73 | localhost/bootupd:latest bootc install to-disk --skip-fetch-check \ 74 | --disable-selinux --generic-image --via-loopback /target/myimage.raw 75 | # Verify we installed grub.cfg and shim on the disk 76 | sudo losetup -P -f myimage.raw 77 | device=$(losetup -a myimage.raw --output NAME -n) 78 | esp_part=$(sudo sfdisk -l -J "${device}" | jq -r '.partitiontable.partitions[] | select(.type == "C12A7328-F81F-11D2-BA4B-00A0C93EC93B").node') 79 | sudo mount "${esp_part}" /mnt/ 80 | arch="$(uname --machine)" 81 | if [[ "${arch}" == "x86_64" ]]; then 82 | shim="shimx64.efi" 83 | else 84 | # Assume aarch64 for now 85 | shim="shimaa64.efi" 86 | fi 87 | sudo ls /mnt/EFI/centos/{grub.cfg,${shim}} 88 | sudo umount /mnt 89 | sudo losetup -D "${device}" 90 | sudo rm -f myimage.raw 91 | 92 | - name: bootc install to filesystem 93 | run: | 94 | set -xeuo pipefail 95 | sudo podman run --rm -ti --privileged -v /:/target --pid=host --security-opt label=disable \ 96 | -v /dev:/dev -v /var/lib/containers:/var/lib/containers \ 97 | localhost/bootupd:latest bootc install to-filesystem --skip-fetch-check \ 98 | --acknowledge-destructive \ 99 | --disable-selinux --replace=alongside /target 100 | # Verify we injected static configs 101 | jq -re '.["static-configs"].version' /boot/bootupd-state.json 102 | -------------------------------------------------------------------------------- /.github/workflows/cross.yml: -------------------------------------------------------------------------------- 1 | name: Cross build 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | actions: read 7 | 8 | jobs: 9 | crossarch-check: 10 | runs-on: ubuntu-22.04 11 | name: Build on ${{ matrix.arch }} 12 | 13 | strategy: 14 | matrix: 15 | include: 16 | - arch: s390x 17 | distro: ubuntu_latest 18 | - arch: ppc64le 19 | distro: ubuntu_latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | set-safe-directory: true 25 | 26 | - uses: uraimo/run-on-arch-action@v3.0.0 27 | name: Build 28 | id: build 29 | with: 30 | arch: ${{ matrix.arch }} 31 | distro: ${{ matrix.distro }} 32 | 33 | githubToken: ${{ github.token }} 34 | 35 | run: | 36 | set -xeu 37 | apt update -y 38 | apt install -y gcc make curl libssl-dev pkg-config 39 | # Install Rust 1.84.1 40 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.84.1 41 | source $HOME/.cargo/env 42 | rustc --version 43 | cargo check 44 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | # Maintained in https://github.com/coreos/repo-templates 2 | # Do not edit downstream. 3 | 4 | name: Rust 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | permissions: 11 | contents: read 12 | 13 | # don't waste job slots on superseded code 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | env: 19 | CARGO_TERM_COLOR: always 20 | # Pinned toolchain for linting 21 | ACTIONS_LINTS_TOOLCHAIN: 1.84.1 22 | 23 | jobs: 24 | tests-stable: 25 | name: Tests, stable toolchain 26 | runs-on: ubuntu-latest 27 | container: quay.io/coreos-assembler/fcos-buildroot:testing-devel 28 | steps: 29 | - name: Check out repository 30 | uses: actions/checkout@v4 31 | - name: Install toolchain 32 | uses: dtolnay/rust-toolchain@v1 33 | with: 34 | toolchain: stable 35 | - name: Install grub2 36 | run: | 37 | set -xeu 38 | dnf install -y grub2-tools-minimal 39 | - name: Cache build artifacts 40 | uses: Swatinem/rust-cache@v2 41 | - name: cargo build 42 | run: cargo build --all-targets 43 | - name: cargo test 44 | run: cargo test --all-targets 45 | tests-release-stable: 46 | name: Tests (release), stable toolchain 47 | runs-on: ubuntu-latest 48 | container: quay.io/coreos-assembler/fcos-buildroot:testing-devel 49 | steps: 50 | - name: Check out repository 51 | uses: actions/checkout@v4 52 | - name: Install toolchain 53 | uses: dtolnay/rust-toolchain@v1 54 | with: 55 | toolchain: stable 56 | - name: Install grub2 57 | run: | 58 | set -xeu 59 | dnf install -y grub2-tools-minimal 60 | - name: Cache build artifacts 61 | uses: Swatinem/rust-cache@v2 62 | - name: cargo build (release) 63 | run: cargo build --all-targets --release 64 | - name: cargo test (release) 65 | run: cargo test --all-targets --release 66 | tests-release-msrv: 67 | name: Tests (release), minimum supported toolchain 68 | runs-on: ubuntu-latest 69 | container: quay.io/coreos-assembler/fcos-buildroot:testing-devel 70 | steps: 71 | - name: Check out repository 72 | uses: actions/checkout@v4 73 | - name: Detect crate MSRV 74 | run: | 75 | msrv=$(cargo metadata --format-version 1 --no-deps | \ 76 | jq -r '.packages[0].rust_version') 77 | echo "Crate MSRV: $msrv" 78 | echo "MSRV=$msrv" >> $GITHUB_ENV 79 | - name: Install toolchain 80 | uses: dtolnay/rust-toolchain@v1 81 | with: 82 | toolchain: ${{ env.MSRV }} 83 | - name: Cache build artifacts 84 | uses: Swatinem/rust-cache@v2 85 | - name: Install grub2 86 | run: | 87 | set -xeu 88 | dnf install -y grub2-tools-minimal 89 | - name: cargo build (release) 90 | run: cargo build --all-targets --release 91 | - name: cargo test (release) 92 | run: cargo test --all-targets --release 93 | linting: 94 | name: Lints, pinned toolchain 95 | runs-on: ubuntu-latest 96 | container: quay.io/coreos-assembler/fcos-buildroot:testing-devel 97 | steps: 98 | - name: Check out repository 99 | uses: actions/checkout@v4 100 | - name: Install toolchain 101 | uses: dtolnay/rust-toolchain@v1 102 | with: 103 | toolchain: ${{ env.ACTIONS_LINTS_TOOLCHAIN }} 104 | components: rustfmt, clippy 105 | - name: Cache build artifacts 106 | uses: Swatinem/rust-cache@v2 107 | - name: cargo fmt (check) 108 | run: cargo fmt -- --check -l 109 | - name: cargo clippy (warnings) 110 | run: cargo clippy --all-targets -- -D warnings 111 | tests-other-channels: 112 | name: Tests, unstable toolchain 113 | runs-on: ubuntu-latest 114 | container: quay.io/coreos-assembler/fcos-buildroot:testing-devel 115 | continue-on-error: true 116 | strategy: 117 | matrix: 118 | channel: [beta, nightly] 119 | steps: 120 | - name: Check out repository 121 | uses: actions/checkout@v4 122 | - name: Install toolchain 123 | uses: dtolnay/rust-toolchain@v1 124 | with: 125 | toolchain: ${{ matrix.channel }} 126 | - name: Install grub2 127 | run: | 128 | set -xeu 129 | dnf install -y grub2-tools-minimal 130 | - name: Cache build artifacts 131 | uses: Swatinem/rust-cache@v2 132 | - name: cargo build 133 | run: cargo build --all-targets 134 | - name: cargo test 135 | run: cargo test --all-targets 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | fastbuild*.qcow2 3 | _kola_temp 4 | .cosa 5 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: bootupd 3 | Source: https://www.github.com/coreos/bootupd 4 | 5 | Files: * 6 | Copyright: 2020 Red Hat, Inc. 7 | License: Apache-2.0 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bootupd" 3 | description = "Bootloader updater" 4 | license = "Apache-2.0" 5 | version = "0.2.27" 6 | authors = ["Colin Walters "] 7 | edition = "2021" 8 | rust-version = "1.84.1" 9 | homepage = "https://github.com/coreos/bootupd" 10 | 11 | include = ["src", "LICENSE", "Makefile", "systemd"] 12 | 13 | # See https://github.com/coreos/cargo-vendor-filterer 14 | [package.metadata.vendor-filter] 15 | platforms = ["*-unknown-linux-gnu"] 16 | tier = "2" 17 | 18 | [[bin]] 19 | name = "bootupd" 20 | path = "src/main.rs" 21 | 22 | [dependencies] 23 | anyhow = "1.0" 24 | bincode = "1.3.2" 25 | bootc-blockdev = { git = "https://github.com/containers/bootc", rev = "v1.2.0" } 26 | bootc-utils = { git = "https://github.com/containers/bootc", rev = "v1.2.0" } 27 | cap-std-ext = "4.0.6" 28 | camino = "1.1.9" 29 | chrono = { version = "0.4.41", features = ["serde"] } 30 | clap = { version = "4.5", default-features = false, features = ["cargo", "derive", "std", "help", "usage", "suggestions"] } 31 | env_logger = "0.11" 32 | fail = { version = "0.5", features = ["failpoints"] } 33 | fn-error-context = "0.2.1" 34 | fs2 = "0.4.3" 35 | hex = "0.4.3" 36 | libc = "^0.2" 37 | libsystemd = ">= 0.3, < 0.8" 38 | log = "^0.4" 39 | openat = "0.1.20" 40 | openat-ext = ">= 0.2.2, < 0.3.0" 41 | openssl = "^0.10" 42 | os-release = "0.1.0" 43 | regex = "1.11.1" 44 | rustix = { version = "1.0.7", features = ["process", "fs"] } 45 | serde = { version = "^1.0", features = ["derive"] } 46 | serde_json = "^1.0" 47 | tempfile = "^3.20" 48 | widestring = "1.2.0" 49 | walkdir = "2.3.2" 50 | signal-hook-registry = "1.4.2" 51 | 52 | [profile.release] 53 | # We assume we're being delivered via e.g. RPM which supports split debuginfo 54 | debug = true 55 | 56 | [package.metadata.release] 57 | disable-publish = true 58 | disable-push = true 59 | post-release-commit-message = "cargo: development version bump" 60 | pre-release-commit-message = "cargo: bootupd release {{version}}" 61 | sign-commit = true 62 | sign-tag = true 63 | tag-message = "bootupd {{version}}" 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build from the current git into a c9s-bootc container image. 2 | # Use e.g. --build-arg=base=quay.io/fedora/fedora-bootc:41 to target 3 | # Fedora or another base image instead. 4 | # 5 | ARG base=quay.io/centos-bootc/centos-bootc:stream9 6 | 7 | FROM $base as build 8 | # This installs our package dependencies, and we want to cache it independently of the rest. 9 | # Basically we don't want changing a .rs file to blow out the cache of packages. 10 | RUN < /cosa/component-install/usr/bin/foo 8 | 9 | FROM quay.io/coreos-assembler/coreos-assembler:latest 10 | WORKDIR /srv 11 | # Install our built binaries as overrides for the target build 12 | COPY --from=builder /cosa/component-install/ /srv/overrides/rootfs/ 13 | # Copy and install tests too 14 | COPY --from=builder /cosa/component-tests /srv/tmp/component-tests 15 | # And fix permissions 16 | RUN sudo chown -R builder: /srv/* 17 | # Install tests 18 | USER root 19 | RUN rsync -rlv /srv/tmp/component-tests/ / && rm -rf /srv/tmp/component-tests 20 | USER builder 21 | COPY --from=builder /src/ci/prow/fcos-e2e.sh /usr/bin/fcos-e2e 22 | -------------------------------------------------------------------------------- /ci/prow/fcos-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | # Prow jobs don't support adding emptydir today 5 | export COSA_SKIP_OVERLAY=1 6 | cosa init --force https://github.com/coreos/fedora-coreos-config/ 7 | cosa fetch 8 | cosa build 9 | cosa kola run --qemu-firmware uefi 'ext.bootupd.*' 10 | -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | ## CoreOS Community Code of Conduct 2 | 3 | ### Contributor Code of Conduct 4 | 5 | As contributors and maintainers of this project, and in the interest of 6 | fostering an open and welcoming community, we pledge to respect all people who 7 | contribute through reporting issues, posting feature requests, updating 8 | documentation, submitting pull requests or patches, and other activities. 9 | 10 | We are committed to making participation in this project a harassment-free 11 | experience for everyone, regardless of level of experience, gender, gender 12 | identity and expression, sexual orientation, disability, personal appearance, 13 | body size, race, ethnicity, age, religion, or nationality. 14 | 15 | Examples of unacceptable behavior by participants include: 16 | 17 | * The use of sexualized language or imagery 18 | * Personal attacks 19 | * Trolling or insulting/derogatory comments 20 | * Public or private harassment 21 | * Publishing others' private information, such as physical or electronic addresses, without explicit permission 22 | * Other unethical or unprofessional conduct. 23 | 24 | Project maintainers have the right and responsibility to remove, edit, or 25 | reject comments, commits, code, wiki edits, issues, and other contributions 26 | that are not aligned to this Code of Conduct. By adopting this Code of Conduct, 27 | project maintainers commit themselves to fairly and consistently applying these 28 | principles to every aspect of managing this project. Project maintainers who do 29 | not follow or enforce the Code of Conduct may be permanently removed from the 30 | project team. 31 | 32 | This code of conduct applies both within project spaces and in public spaces 33 | when an individual is representing the project or its community. 34 | 35 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 36 | reported by contacting a project maintainer, Brandon Philips 37 | , and/or Rithu John . 38 | 39 | This Code of Conduct is adapted from the Contributor Covenant 40 | (http://contributor-covenant.org), version 1.2.0, available at 41 | http://contributor-covenant.org/version/1/2/0/ 42 | 43 | ### CoreOS Events Code of Conduct 44 | 45 | CoreOS events are working conferences intended for professional networking and 46 | collaboration in the CoreOS community. Attendees are expected to behave 47 | according to professional standards and in accordance with their employer’s 48 | policies on appropriate workplace behavior. 49 | 50 | While at CoreOS events or related social networking opportunities, attendees 51 | should not engage in discriminatory or offensive speech or actions including 52 | but not limited to gender, sexuality, race, age, disability, or religion. 53 | Speakers should be especially aware of these concerns. 54 | 55 | CoreOS does not condone any statements by speakers contrary to these standards. 56 | CoreOS reserves the right to deny entrance and/or eject from an event (without 57 | refund) any individual found to be engaging in discriminatory or offensive 58 | speech or actions. 59 | 60 | Please bring any concerns to the immediate attention of designated on-site 61 | staff, Brandon Philips , and/or Rithu John . 62 | -------------------------------------------------------------------------------- /contrib/packaging/bootupd.spec: -------------------------------------------------------------------------------- 1 | %bcond_without check 2 | 3 | %global crate bootupd 4 | 5 | Name: rust-%{crate} 6 | Version: 0.2.9 7 | Release: 1%{?dist} 8 | Summary: Bootloader updater 9 | 10 | License: Apache-2.0 11 | URL: https://github.com/coreos/bootupd 12 | Source0: %{url}/releases/download/v%{version}/bootupd-%{version}.tar.zstd 13 | Source1: %{url}/releases/download/v%{version}/bootupd-%{version}-vendor.tar.zstd 14 | %if 0%{?fedora} || 0%{?rhel} >= 10 15 | ExcludeArch: %{ix86} 16 | %endif 17 | 18 | BuildRequires: git-core 19 | # For now, see upstream 20 | BuildRequires: make 21 | BuildRequires: openssl-devel 22 | %if 0%{?rhel} 23 | BuildRequires: rust-toolset 24 | %else 25 | BuildRequires: cargo-rpm-macros >= 25 26 | %endif 27 | BuildRequires: systemd 28 | 29 | %global _description %{expand: 30 | Bootloader updater} 31 | %description %{_description} 32 | 33 | %package -n %{crate} 34 | Summary: %{summary} 35 | # Apache-2.0 36 | # Apache-2.0 OR BSL-1.0 37 | # Apache-2.0 OR MIT 38 | # Apache-2.0 WITH LLVM-exception 39 | # Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT 40 | # BSD-3-Clause 41 | # MIT 42 | # MIT OR Apache-2.0 43 | # Unlicense OR MIT 44 | License: Apache-2.0 AND (Apache-2.0 WITH LLVM-exception) AND BSD-3-Clause AND MIT AND (Apache-2.0 OR BSL-1.0) AND (Apache-2.0 OR MIT) AND (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) AND (Unlicense OR MIT) 45 | %{?systemd_requires} 46 | 47 | %description -n %{crate} %{_description} 48 | 49 | %files -n %{crate} 50 | %license LICENSE 51 | %license LICENSE.dependencies 52 | %license cargo-vendor.txt 53 | %doc README.md 54 | %{_bindir}/bootupctl 55 | %{_libexecdir}/bootupd 56 | %{_prefix}/lib/bootupd/grub2-static/ 57 | %{_unitdir}/bootloader-update.service 58 | 59 | %prep 60 | %autosetup -n %{crate}-%{version} -p1 -Sgit -a1 61 | # Default -v vendor config doesn't support non-crates.io deps (i.e. git) 62 | cp .cargo/vendor-config.toml . 63 | %cargo_prep -N 64 | cat vendor-config.toml >> .cargo/config.toml 65 | rm vendor-config.toml 66 | 67 | %build 68 | %cargo_build 69 | %cargo_vendor_manifest 70 | # https://pagure.io/fedora-rust/rust-packaging/issue/33 71 | sed -i -e '/https:\/\//d' cargo-vendor.txt 72 | %cargo_license_summary 73 | %{cargo_license} > LICENSE.dependencies 74 | 75 | %install 76 | %make_install INSTALL="install -p -c" 77 | %{__make} install-grub-static DESTDIR=%{?buildroot} INSTALL="%{__install} -p" 78 | %{__make} install-systemd-unit DESTDIR=%{?buildroot} INSTALL="%{__install} -p" 79 | 80 | %changelog 81 | * Tue Oct 18 2022 Colin Walters - 0.2.8-3 82 | - Dummy changelog 83 | -------------------------------------------------------------------------------- /doc/dependency_decisions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - - :permit 3 | - MIT OR Apache-2.0 4 | - :who: 5 | :why: 6 | :versions: [] 7 | :when: 2021-02-03 19:31:28.263225624 Z 8 | - - :permit 9 | - Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT 10 | - :who: 11 | :why: 12 | :versions: [] 13 | :when: 2021-02-03 19:31:42.436851761 Z 14 | - - :permit 15 | - MIT 16 | - :who: 17 | :why: 18 | :versions: [] 19 | :when: 2021-02-03 19:31:54.278056841 Z 20 | - - :permit 21 | - Apache 2.0 22 | - :who: 23 | :why: 24 | :versions: [] 25 | :when: 2021-02-03 19:32:08.538863728 Z 26 | - - :permit 27 | - Apache-2.0 OR BSL-1.0 28 | - :who: 29 | :why: 30 | :versions: [] 31 | :when: 2021-02-03 19:32:17.034417362 Z 32 | - - :permit 33 | - New BSD 34 | - :who: 35 | :why: 36 | :versions: [] 37 | :when: 2021-02-03 19:33:02.120977990 Z 38 | -------------------------------------------------------------------------------- /src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | //! Internal logic for bootloader and system state manipulation. 2 | 3 | mod statefile; 4 | -------------------------------------------------------------------------------- /src/backend/statefile.rs: -------------------------------------------------------------------------------- 1 | //! On-disk saved state. 2 | 3 | use crate::model::SavedState; 4 | use anyhow::{bail, Context, Result}; 5 | use fn_error_context::context; 6 | use fs2::FileExt; 7 | use openat_ext::OpenatDirExt; 8 | use std::fs::File; 9 | use std::io::prelude::*; 10 | use std::path::Path; 11 | 12 | /// Suppress SIGTERM while active 13 | // TODO: In theory we could record if we got SIGTERM and exit 14 | // on drop, but in practice we don't care since we're going to exit anyways. 15 | #[derive(Debug)] 16 | struct SignalTerminationGuard(signal_hook_registry::SigId); 17 | 18 | impl SignalTerminationGuard { 19 | pub(crate) fn new() -> Result { 20 | let signal = unsafe { signal_hook_registry::register(libc::SIGTERM, || {})? }; 21 | Ok(Self(signal)) 22 | } 23 | } 24 | 25 | impl Drop for SignalTerminationGuard { 26 | fn drop(&mut self) { 27 | signal_hook_registry::unregister(self.0); 28 | } 29 | } 30 | 31 | impl SavedState { 32 | /// System-wide bootupd write lock (relative to sysroot). 33 | const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock"; 34 | /// Top-level directory for statefile (relative to sysroot). 35 | pub(crate) const STATEFILE_DIR: &'static str = "boot"; 36 | /// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, stored in `/boot`. 37 | pub(crate) const STATEFILE_NAME: &'static str = "bootupd-state.json"; 38 | 39 | /// Try to acquire a system-wide lock to ensure non-conflicting state updates. 40 | /// 41 | /// While ordinarily the daemon runs as a systemd unit (which implicitly 42 | /// ensures a single instance) this is a double check against other 43 | /// execution paths. 44 | pub(crate) fn acquire_write_lock(sysroot: openat::Dir) -> Result { 45 | let lockfile = sysroot.write_file(Self::WRITE_LOCK_PATH, 0o644)?; 46 | lockfile.lock_exclusive()?; 47 | let guard = StateLockGuard { 48 | sysroot, 49 | termguard: Some(SignalTerminationGuard::new()?), 50 | lockfile: Some(lockfile), 51 | }; 52 | Ok(guard) 53 | } 54 | 55 | /// Use this for cases when the target root isn't booted, which is 56 | /// offline installs. 57 | pub(crate) fn unlocked(sysroot: openat::Dir) -> Result { 58 | Ok(StateLockGuard { 59 | sysroot, 60 | termguard: None, 61 | lockfile: None, 62 | }) 63 | } 64 | 65 | /// Load the JSON file containing on-disk state. 66 | #[context("Loading saved state")] 67 | pub(crate) fn load_from_disk(root_path: impl AsRef) -> Result> { 68 | let root_path = root_path.as_ref(); 69 | let sysroot = openat::Dir::open(root_path) 70 | .with_context(|| format!("opening sysroot '{}'", root_path.display()))?; 71 | 72 | let statefile_path = Path::new(Self::STATEFILE_DIR).join(Self::STATEFILE_NAME); 73 | let saved_state = if let Some(statusf) = sysroot.open_file_optional(&statefile_path)? { 74 | let mut bufr = std::io::BufReader::new(statusf); 75 | let mut s = String::new(); 76 | bufr.read_to_string(&mut s)?; 77 | let state: serde_json::Result = serde_json::from_str(s.as_str()); 78 | let r = match state { 79 | Ok(s) => s, 80 | Err(orig_err) => { 81 | let state: serde_json::Result = 82 | serde_json::from_str(s.as_str()); 83 | match state { 84 | Ok(s) => s.upconvert(), 85 | Err(_) => { 86 | return Err(orig_err.into()); 87 | } 88 | } 89 | } 90 | }; 91 | Some(r) 92 | } else { 93 | None 94 | }; 95 | Ok(saved_state) 96 | } 97 | 98 | /// Check whether statefile exists. 99 | pub(crate) fn ensure_not_present(root_path: impl AsRef) -> Result<()> { 100 | let statepath = Path::new(root_path.as_ref()) 101 | .join(Self::STATEFILE_DIR) 102 | .join(Self::STATEFILE_NAME); 103 | if statepath.exists() { 104 | bail!("{} already exists", statepath.display()); 105 | } 106 | Ok(()) 107 | } 108 | } 109 | 110 | /// Write-lock guard for statefile, protecting against concurrent state updates. 111 | #[derive(Debug)] 112 | pub(crate) struct StateLockGuard { 113 | pub(crate) sysroot: openat::Dir, 114 | #[allow(dead_code)] 115 | termguard: Option, 116 | #[allow(dead_code)] 117 | lockfile: Option, 118 | } 119 | 120 | impl StateLockGuard { 121 | /// Atomically replace the on-disk state with a new version. 122 | pub(crate) fn update_state(&mut self, state: &SavedState) -> Result<()> { 123 | let subdir = self.sysroot.sub_dir(SavedState::STATEFILE_DIR)?; 124 | subdir.write_file_with_sync(SavedState::STATEFILE_NAME, 0o644, |w| -> Result<()> { 125 | serde_json::to_writer(w, state)?; 126 | Ok(()) 127 | })?; 128 | Ok(()) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/bios.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | #[cfg(target_arch = "powerpc64")] 3 | use std::borrow::Cow; 4 | use std::io::prelude::*; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use crate::blockdev; 9 | use crate::bootupd::RootContext; 10 | use crate::component::*; 11 | use crate::model::*; 12 | use crate::packagesystem; 13 | 14 | // grub2-install file path 15 | pub(crate) const GRUB_BIN: &str = "usr/sbin/grub2-install"; 16 | 17 | #[cfg(target_arch = "powerpc64")] 18 | fn target_device(device: &str) -> Result> { 19 | const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; 20 | /// We make a best-effort to support MBR partitioning too. 21 | const PREPBOOT_MBR_TYPE: &str = "41"; 22 | 23 | // Here we use lsblk to see if the device has any partitions at all 24 | let dev = bootc_blockdev::list_dev(device.into())?; 25 | if dev.children.is_none() { 26 | return Ok(device.into()); 27 | }; 28 | // If it does, directly call `sfdisk` and bypass lsblk because inside a container 29 | // we may not have all the cached udev state (that I think is in /run). 30 | let device = bootc_blockdev::partitions_of(device.into())?; 31 | let prepdev = device 32 | .partitions 33 | .iter() 34 | .find(|p| matches!(p.parttype.as_str(), PREPBOOT_GUID | PREPBOOT_MBR_TYPE)) 35 | .ok_or_else(|| { 36 | anyhow::anyhow!("Failed to find PReP partition with GUID {PREPBOOT_GUID}") 37 | })?; 38 | Ok(prepdev.path().as_str().to_owned().into()) 39 | } 40 | 41 | #[derive(Default)] 42 | pub(crate) struct Bios {} 43 | 44 | impl Bios { 45 | // Return `true` if grub2-modules installed 46 | fn check_grub_modules(&self) -> Result { 47 | let usr_path = Path::new("/usr/lib/grub"); 48 | #[cfg(target_arch = "x86_64")] 49 | { 50 | usr_path.join("i386-pc").try_exists().map_err(Into::into) 51 | } 52 | #[cfg(target_arch = "powerpc64")] 53 | { 54 | usr_path 55 | .join("powerpc-ieee1275") 56 | .try_exists() 57 | .map_err(Into::into) 58 | } 59 | } 60 | 61 | // Run grub2-install 62 | fn run_grub_install(&self, dest_root: &str, device: &str) -> Result<()> { 63 | if !self.check_grub_modules()? { 64 | bail!("Failed to find grub2-modules"); 65 | } 66 | let grub_install = Path::new("/").join(GRUB_BIN); 67 | if !grub_install.exists() { 68 | bail!("Failed to find {:?}", grub_install); 69 | } 70 | 71 | let mut cmd = Command::new(grub_install); 72 | let boot_dir = Path::new(dest_root).join("boot"); 73 | // We forcibly inject mdraid1x because it's needed by CoreOS's default of "install raw disk image" 74 | // We also add part_gpt because in some cases probing of the partition map can fail such 75 | // as in a container, but we always use GPT. 76 | #[cfg(target_arch = "x86_64")] 77 | cmd.args(["--target", "i386-pc"]) 78 | .args(["--boot-directory", boot_dir.to_str().unwrap()]) 79 | .args(["--modules", "mdraid1x part_gpt"]) 80 | .arg(device); 81 | 82 | #[cfg(target_arch = "powerpc64")] 83 | { 84 | let device = target_device(device)?; 85 | cmd.args(&["--target", "powerpc-ieee1275"]) 86 | .args(&["--boot-directory", boot_dir.to_str().unwrap()]) 87 | .arg("--no-nvram") 88 | .arg(&*device); 89 | } 90 | 91 | let cmdout = cmd.output()?; 92 | if !cmdout.status.success() { 93 | std::io::stderr().write_all(&cmdout.stderr)?; 94 | bail!("Failed to run {:?}", cmd); 95 | } 96 | Ok(()) 97 | } 98 | 99 | // check bios_boot partition on gpt type disk 100 | #[allow(dead_code)] 101 | fn get_bios_boot_partition(&self) -> Option { 102 | match blockdev::get_single_device("/") { 103 | Ok(device) => { 104 | let bios_boot_part = 105 | blockdev::get_bios_boot_partition(&device).expect("get bios_boot part"); 106 | return bios_boot_part; 107 | } 108 | Err(e) => log::warn!("Get error: {}", e), 109 | } 110 | log::debug!("Not found any bios_boot partition"); 111 | None 112 | } 113 | } 114 | 115 | impl Component for Bios { 116 | fn name(&self) -> &'static str { 117 | "BIOS" 118 | } 119 | 120 | fn install( 121 | &self, 122 | src_root: &openat::Dir, 123 | dest_root: &str, 124 | device: &str, 125 | _update_firmware: bool, 126 | ) -> Result { 127 | let Some(meta) = get_component_update(src_root, self)? else { 128 | anyhow::bail!("No update metadata for component {} found", self.name()); 129 | }; 130 | 131 | self.run_grub_install(dest_root, device)?; 132 | Ok(InstalledContent { 133 | meta, 134 | filetree: None, 135 | adopted_from: None, 136 | }) 137 | } 138 | 139 | fn generate_update_metadata(&self, sysroot_path: &str) -> Result { 140 | let grub_install = Path::new(sysroot_path).join(GRUB_BIN); 141 | if !grub_install.exists() { 142 | bail!("Failed to find {:?}", grub_install); 143 | } 144 | 145 | // Query the rpm database and list the package and build times for /usr/sbin/grub2-install 146 | let meta = packagesystem::query_files(sysroot_path, [&grub_install])?; 147 | write_update_metadata(sysroot_path, self, &meta)?; 148 | Ok(meta) 149 | } 150 | 151 | fn query_adopt(&self, devices: &Option>) -> Result> { 152 | #[cfg(target_arch = "x86_64")] 153 | if crate::efi::is_efi_booted()? && devices.is_none() { 154 | log::debug!("Skip BIOS adopt"); 155 | return Ok(None); 156 | } 157 | crate::component::query_adopt_state() 158 | } 159 | 160 | fn adopt_update( 161 | &self, 162 | rootcxt: &RootContext, 163 | update: &ContentMetadata, 164 | ) -> Result { 165 | let bios_devices = blockdev::find_colocated_bios_boot(&rootcxt.devices)?; 166 | let Some(meta) = self.query_adopt(&bios_devices)? else { 167 | anyhow::bail!("Failed to find adoptable system") 168 | }; 169 | 170 | let mut parent_devices = rootcxt.devices.iter(); 171 | let Some(parent) = parent_devices.next() else { 172 | anyhow::bail!("Failed to find parent device"); 173 | }; 174 | 175 | if let Some(next) = parent_devices.next() { 176 | anyhow::bail!( 177 | "Found multiple parent devices {parent} and {next}; not currently supported" 178 | ); 179 | } 180 | self.run_grub_install(rootcxt.path.as_str(), &parent)?; 181 | log::debug!("Installed grub modules on {parent}"); 182 | Ok(InstalledContent { 183 | meta: update.clone(), 184 | filetree: None, 185 | adopted_from: Some(meta.version), 186 | }) 187 | } 188 | 189 | fn query_update(&self, sysroot: &openat::Dir) -> Result> { 190 | get_component_update(sysroot, self) 191 | } 192 | 193 | fn run_update(&self, rootcxt: &RootContext, _: &InstalledContent) -> Result { 194 | let updatemeta = self 195 | .query_update(&rootcxt.sysroot)? 196 | .expect("update available"); 197 | 198 | let mut parent_devices = rootcxt.devices.iter(); 199 | let Some(parent) = parent_devices.next() else { 200 | anyhow::bail!("Failed to find parent device"); 201 | }; 202 | 203 | if let Some(next) = parent_devices.next() { 204 | anyhow::bail!( 205 | "Found multiple parent devices {parent} and {next}; not currently supported" 206 | ); 207 | } 208 | 209 | self.run_grub_install(rootcxt.path.as_str(), &parent)?; 210 | log::debug!("Install grub modules on {parent}"); 211 | 212 | let adopted_from = None; 213 | Ok(InstalledContent { 214 | meta: updatemeta, 215 | filetree: None, 216 | adopted_from, 217 | }) 218 | } 219 | 220 | fn validate(&self, _: &InstalledContent) -> Result { 221 | Ok(ValidationResult::Skip) 222 | } 223 | 224 | fn get_efi_vendor(&self, _: &openat::Dir) -> Result> { 225 | Ok(None) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/blockdev.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8Path; 2 | use std::path::Path; 3 | 4 | use anyhow::{bail, Context, Result}; 5 | use bootc_blockdev::PartitionTable; 6 | use fn_error_context::context; 7 | 8 | #[context("get parent devices from mount point boot")] 9 | pub fn get_devices>(target_root: P) -> Result> { 10 | let target_root = target_root.as_ref(); 11 | let bootdir = target_root.join("boot"); 12 | if !bootdir.exists() { 13 | bail!("{} does not exist", bootdir.display()); 14 | } 15 | let bootdir = openat::Dir::open(&bootdir)?; 16 | // Run findmnt to get the source path of mount point boot 17 | let fsinfo = crate::filesystem::inspect_filesystem(&bootdir, ".")?; 18 | // Find the parent devices of the source path 19 | let parent_devices = bootc_blockdev::find_parent_devices(&fsinfo.source) 20 | .with_context(|| format!("while looking for backing devices of {}", fsinfo.source))?; 21 | log::debug!("Find parent devices: {parent_devices:?}"); 22 | Ok(parent_devices) 23 | } 24 | 25 | // Get single device for the target root 26 | #[allow(dead_code)] 27 | pub fn get_single_device>(target_root: P) -> Result { 28 | let mut devices = get_devices(&target_root)?.into_iter(); 29 | let Some(parent) = devices.next() else { 30 | anyhow::bail!("Failed to find parent device"); 31 | }; 32 | 33 | if let Some(next) = devices.next() { 34 | anyhow::bail!("Found multiple parent devices {parent} and {next}; not currently supported"); 35 | } 36 | Ok(parent) 37 | } 38 | 39 | /// Find esp partition on the same device 40 | /// using sfdisk to get partitiontable 41 | pub fn get_esp_partition(device: &str) -> Result> { 42 | const ESP_TYPE_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; 43 | let device_info: PartitionTable = bootc_blockdev::partitions_of(Utf8Path::new(device))?; 44 | let esp = device_info 45 | .partitions 46 | .into_iter() 47 | .find(|p| p.parttype.as_str() == ESP_TYPE_GUID); 48 | if let Some(esp) = esp { 49 | return Ok(Some(esp.node)); 50 | } 51 | Ok(None) 52 | } 53 | 54 | /// Find all ESP partitions on the devices 55 | pub fn find_colocated_esps(devices: &Vec) -> Result>> { 56 | // look for all ESPs on those devices 57 | let mut esps = Vec::new(); 58 | for device in devices { 59 | if let Some(esp) = get_esp_partition(&device)? { 60 | esps.push(esp) 61 | } 62 | } 63 | if esps.is_empty() { 64 | return Ok(None); 65 | } 66 | log::debug!("Found esp partitions: {esps:?}"); 67 | Ok(Some(esps)) 68 | } 69 | 70 | /// Find bios_boot partition on the same device 71 | pub fn get_bios_boot_partition(device: &str) -> Result> { 72 | const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6E6F-744E-656564454649"; 73 | let device_info = bootc_blockdev::partitions_of(Utf8Path::new(device))?; 74 | let bios_boot = device_info 75 | .partitions 76 | .into_iter() 77 | .find(|p| p.parttype.as_str() == BIOS_BOOT_TYPE_GUID); 78 | if let Some(bios_boot) = bios_boot { 79 | return Ok(Some(bios_boot.node)); 80 | } 81 | Ok(None) 82 | } 83 | 84 | /// Find all bios_boot partitions on the devices 85 | pub fn find_colocated_bios_boot(devices: &Vec) -> Result>> { 86 | // look for all bios_boot parts on those devices 87 | let mut bios_boots = Vec::new(); 88 | for device in devices { 89 | if let Some(bios) = get_bios_boot_partition(&device)? { 90 | bios_boots.push(bios) 91 | } 92 | } 93 | if bios_boots.is_empty() { 94 | return Ok(None); 95 | } 96 | log::debug!("Found bios_boot partitions: {bios_boots:?}"); 97 | Ok(Some(bios_boots)) 98 | } 99 | -------------------------------------------------------------------------------- /src/bootupd.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] 2 | use crate::bios; 3 | use crate::component; 4 | use crate::component::{Component, ValidationResult}; 5 | use crate::coreos; 6 | #[cfg(any( 7 | target_arch = "x86_64", 8 | target_arch = "aarch64", 9 | target_arch = "riscv64" 10 | ))] 11 | use crate::efi; 12 | use crate::model::{ComponentStatus, ComponentUpdatable, ContentMetadata, SavedState, Status}; 13 | use crate::util; 14 | use anyhow::{anyhow, Context, Result}; 15 | use camino::{Utf8Path, Utf8PathBuf}; 16 | use clap::crate_version; 17 | use fn_error_context::context; 18 | use libc::mode_t; 19 | use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWUSR}; 20 | use openat_ext::OpenatDirExt; 21 | use serde::{Deserialize, Serialize}; 22 | use std::borrow::Cow; 23 | use std::collections::BTreeMap; 24 | use std::fs::{self, File}; 25 | use std::io::{BufRead, BufReader, BufWriter, Write}; 26 | use std::path::{Path, PathBuf}; 27 | 28 | pub(crate) enum ConfigMode { 29 | None, 30 | Static, 31 | WithUUID, 32 | } 33 | 34 | impl ConfigMode { 35 | pub(crate) fn enabled_with_uuid(&self) -> Option { 36 | match self { 37 | ConfigMode::None => None, 38 | ConfigMode::Static => Some(false), 39 | ConfigMode::WithUUID => Some(true), 40 | } 41 | } 42 | } 43 | 44 | pub(crate) fn install( 45 | source_root: &str, 46 | dest_root: &str, 47 | device: Option<&str>, 48 | configs: ConfigMode, 49 | update_firmware: bool, 50 | target_components: Option<&[String]>, 51 | auto_components: bool, 52 | ) -> Result<()> { 53 | // TODO: Change this to an Option<&str>; though this probably balloons into having 54 | // DeviceComponent and FileBasedComponent 55 | let device = device.unwrap_or(""); 56 | let source_root = openat::Dir::open(source_root).context("Opening source root")?; 57 | SavedState::ensure_not_present(dest_root) 58 | .context("failed to install, invalid re-install attempted")?; 59 | 60 | let all_components = get_components_impl(auto_components); 61 | if all_components.is_empty() { 62 | println!("No components available for this platform."); 63 | return Ok(()); 64 | } 65 | let target_components = if let Some(target_components) = target_components { 66 | // Checked by CLI parser 67 | assert!(!auto_components); 68 | target_components 69 | .iter() 70 | .map(|name| { 71 | all_components 72 | .get(name.as_str()) 73 | .ok_or_else(|| anyhow!("Unknown component: {name}")) 74 | }) 75 | .collect::>>()? 76 | } else { 77 | all_components.values().collect() 78 | }; 79 | 80 | if target_components.is_empty() && !auto_components { 81 | anyhow::bail!("No components specified"); 82 | } 83 | 84 | let mut state = SavedState::default(); 85 | let mut installed_efi_vendor = None; 86 | for &component in target_components.iter() { 87 | // skip for BIOS if device is empty 88 | if component.name() == "BIOS" && device.is_empty() { 89 | println!( 90 | "Skip installing component {} without target device", 91 | component.name() 92 | ); 93 | continue; 94 | } 95 | 96 | let meta = component 97 | .install(&source_root, dest_root, device, update_firmware) 98 | .with_context(|| format!("installing component {}", component.name()))?; 99 | log::info!("Installed {} {}", component.name(), meta.meta.version); 100 | state.installed.insert(component.name().into(), meta); 101 | // Yes this is a hack...the Component thing just turns out to be too generic. 102 | if let Some(vendor) = component.get_efi_vendor(&source_root)? { 103 | assert!(installed_efi_vendor.is_none()); 104 | installed_efi_vendor = Some(vendor); 105 | } 106 | } 107 | let sysroot = &openat::Dir::open(dest_root)?; 108 | 109 | match configs.enabled_with_uuid() { 110 | Some(uuid) => { 111 | let self_bin_meta = 112 | std::fs::metadata("/proc/self/exe").context("Querying self meta")?; 113 | let self_meta = ContentMetadata { 114 | timestamp: self_bin_meta.modified()?.into(), 115 | version: crate_version!().into(), 116 | }; 117 | state.static_configs = Some(self_meta); 118 | #[cfg(any( 119 | target_arch = "x86_64", 120 | target_arch = "aarch64", 121 | target_arch = "powerpc64", 122 | target_arch = "riscv64" 123 | ))] 124 | crate::grubconfigs::install(sysroot, installed_efi_vendor.as_deref(), uuid)?; 125 | // On other architectures, assume that there's nothing to do. 126 | } 127 | None => {} 128 | } 129 | 130 | // Unmount the ESP, etc. 131 | drop(target_components); 132 | 133 | let mut state_guard = 134 | SavedState::unlocked(sysroot.try_clone()?).context("failed to acquire write lock")?; 135 | state_guard 136 | .update_state(&state) 137 | .context("failed to update state")?; 138 | 139 | Ok(()) 140 | } 141 | 142 | type Components = BTreeMap<&'static str, Box>; 143 | 144 | #[allow(clippy::box_default)] 145 | /// Return the set of known components; if `auto` is specified then the system 146 | /// filters to the target booted state. 147 | pub(crate) fn get_components_impl(auto: bool) -> Components { 148 | let mut components = BTreeMap::new(); 149 | 150 | fn insert_component(components: &mut Components, component: Box) { 151 | components.insert(component.name(), component); 152 | } 153 | 154 | #[cfg(target_arch = "x86_64")] 155 | { 156 | if auto { 157 | let is_efi_booted = crate::efi::is_efi_booted().unwrap(); 158 | log::info!( 159 | "System boot method: {}", 160 | if is_efi_booted { "EFI" } else { "BIOS" } 161 | ); 162 | if is_efi_booted { 163 | insert_component(&mut components, Box::new(efi::Efi::default())); 164 | } else { 165 | insert_component(&mut components, Box::new(bios::Bios::default())); 166 | } 167 | } else { 168 | insert_component(&mut components, Box::new(bios::Bios::default())); 169 | insert_component(&mut components, Box::new(efi::Efi::default())); 170 | } 171 | } 172 | #[cfg(any(target_arch = "aarch64", target_arch = "riscv64"))] 173 | insert_component(&mut components, Box::new(efi::Efi::default())); 174 | 175 | #[cfg(target_arch = "powerpc64")] 176 | insert_component(&mut components, Box::new(bios::Bios::default())); 177 | 178 | components 179 | } 180 | 181 | pub(crate) fn get_components() -> Components { 182 | get_components_impl(false) 183 | } 184 | 185 | pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> { 186 | // create bootupd update dir which will save component metadata files for both components 187 | let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR); 188 | std::fs::create_dir_all(&updates_dir) 189 | .with_context(|| format!("Failed to create updates dir {:?}", &updates_dir))?; 190 | for component in get_components().values() { 191 | let v = component.generate_update_metadata(sysroot_path)?; 192 | println!( 193 | "Generated update layout for {}: {}", 194 | component.name(), 195 | v.version, 196 | ); 197 | } 198 | 199 | Ok(()) 200 | } 201 | 202 | /// Return value from daemon → client for component update 203 | #[derive(Serialize, Deserialize, Debug)] 204 | #[serde(rename_all = "kebab-case")] 205 | pub(crate) enum ComponentUpdateResult { 206 | AtLatestVersion, 207 | Updated { 208 | previous: ContentMetadata, 209 | interrupted: Option, 210 | new: ContentMetadata, 211 | }, 212 | } 213 | 214 | fn ensure_writable_boot() -> Result<()> { 215 | util::ensure_writable_mount("/boot") 216 | } 217 | 218 | /// daemon implementation of component update 219 | pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result { 220 | let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); 221 | let component = component::new_from_name(name)?; 222 | let inst = if let Some(inst) = state.installed.get(name) { 223 | inst.clone() 224 | } else { 225 | anyhow::bail!("Component {} is not installed", name); 226 | }; 227 | let sysroot = &rootcxt.sysroot; 228 | let update = component.query_update(sysroot)?; 229 | let update = match update.as_ref() { 230 | Some(p) if inst.meta.can_upgrade_to(p) => p, 231 | _ => return Ok(ComponentUpdateResult::AtLatestVersion), 232 | }; 233 | 234 | ensure_writable_boot()?; 235 | 236 | let mut pending_container = state.pending.take().unwrap_or_default(); 237 | let interrupted = pending_container.get(component.name()).cloned(); 238 | pending_container.insert(component.name().into(), update.clone()); 239 | let sysroot = sysroot.try_clone()?; 240 | let mut state_guard = 241 | SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; 242 | state_guard 243 | .update_state(&state) 244 | .context("Failed to update state")?; 245 | 246 | let newinst = component 247 | .run_update(rootcxt, &inst) 248 | .with_context(|| format!("Failed to update {}", component.name()))?; 249 | state.installed.insert(component.name().into(), newinst); 250 | pending_container.remove(component.name()); 251 | state_guard.update_state(&state)?; 252 | 253 | Ok(ComponentUpdateResult::Updated { 254 | previous: inst.meta, 255 | interrupted, 256 | new: update.clone(), 257 | }) 258 | } 259 | 260 | /// daemon implementation of component adoption 261 | pub(crate) fn adopt_and_update(name: &str, rootcxt: &RootContext) -> Result { 262 | let sysroot = &rootcxt.sysroot; 263 | let mut state = SavedState::load_from_disk("/")?.unwrap_or_default(); 264 | let component = component::new_from_name(name)?; 265 | if state.installed.contains_key(name) { 266 | anyhow::bail!("Component {} is already installed", name); 267 | }; 268 | 269 | ensure_writable_boot()?; 270 | 271 | let Some(update) = component.query_update(sysroot)? else { 272 | anyhow::bail!("Component {} has no available update", name); 273 | }; 274 | let sysroot = sysroot.try_clone()?; 275 | let mut state_guard = 276 | SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?; 277 | 278 | let inst = component 279 | .adopt_update(&rootcxt, &update) 280 | .context("Failed adopt and update")?; 281 | state.installed.insert(component.name().into(), inst); 282 | 283 | state_guard.update_state(&state)?; 284 | Ok(update) 285 | } 286 | 287 | /// daemon implementation of component validate 288 | pub(crate) fn validate(name: &str) -> Result { 289 | let state = SavedState::load_from_disk("/")?.unwrap_or_default(); 290 | let component = component::new_from_name(name)?; 291 | let Some(inst) = state.installed.get(name) else { 292 | anyhow::bail!("Component {} is not installed", name); 293 | }; 294 | component.validate(inst) 295 | } 296 | 297 | pub(crate) fn status() -> Result { 298 | let mut ret: Status = Default::default(); 299 | let mut known_components = get_components(); 300 | let sysroot = openat::Dir::open("/")?; 301 | let state = SavedState::load_from_disk("/")?; 302 | if let Some(state) = state { 303 | for (name, ic) in state.installed.iter() { 304 | log::trace!("Gathering status for installed component: {}", name); 305 | let component = known_components 306 | .remove(name.as_str()) 307 | .ok_or_else(|| anyhow!("Unknown component installed: {}", name))?; 308 | let component = component.as_ref(); 309 | let interrupted = state.pending.as_ref().and_then(|p| p.get(name.as_str())); 310 | let update = component.query_update(&sysroot)?; 311 | let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref()); 312 | let adopted_from = ic.adopted_from.clone(); 313 | ret.components.insert( 314 | name.to_string(), 315 | ComponentStatus { 316 | installed: ic.meta.clone(), 317 | interrupted: interrupted.cloned(), 318 | update, 319 | updatable, 320 | adopted_from, 321 | }, 322 | ); 323 | } 324 | } else { 325 | log::trace!("No saved state"); 326 | } 327 | 328 | // Process the remaining components not installed 329 | log::trace!("Remaining known components: {}", known_components.len()); 330 | for (name, _) in known_components { 331 | // To determine if not-installed components can be adopted: 332 | // 333 | // `query_adopt_state()` checks for existing installation state, 334 | // such as a `version` in `/sysroot/.coreos-aleph-version.json`, 335 | // or the presence of `/ostree/deploy`. 336 | // 337 | // `component.query_adopt()` performs additional checks, 338 | // including hardware/device requirements. 339 | // For example, it will skip BIOS adoption if the system is booted via EFI 340 | // and lacks a BIOS_BOOT partition. 341 | // 342 | // Once a component is determined to be adoptable, it is added to the 343 | // adoptable list, and adoption proceeds automatically. 344 | // 345 | // Therefore, calling `query_adopt_state()` alone is sufficient. 346 | if let Some(adopt_ver) = crate::component::query_adopt_state()? { 347 | ret.adoptable.insert(name.to_string(), adopt_ver); 348 | } else { 349 | log::trace!("Not adoptable: {}", name); 350 | } 351 | } 352 | 353 | Ok(ret) 354 | } 355 | 356 | pub(crate) fn print_status_avail(status: &Status) -> Result<()> { 357 | let mut avail = Vec::new(); 358 | for (name, component) in status.components.iter() { 359 | if let ComponentUpdatable::Upgradable = component.updatable { 360 | avail.push(name.as_str()); 361 | } 362 | } 363 | for (name, adoptable) in status.adoptable.iter() { 364 | if adoptable.confident { 365 | avail.push(name.as_str()); 366 | } 367 | } 368 | if !avail.is_empty() { 369 | println!("Updates available: {}", avail.join(" ")); 370 | } 371 | Ok(()) 372 | } 373 | 374 | pub(crate) fn print_status(status: &Status) -> Result<()> { 375 | if status.components.is_empty() { 376 | println!("No components installed."); 377 | } 378 | for (name, component) in status.components.iter() { 379 | println!("Component {}", name); 380 | println!(" Installed: {}", component.installed.version); 381 | 382 | if let Some(i) = component.interrupted.as_ref() { 383 | println!( 384 | " WARNING: Previous update to {} was interrupted", 385 | i.version 386 | ); 387 | } 388 | let msg = match component.updatable { 389 | ComponentUpdatable::NoUpdateAvailable => Cow::Borrowed("No update found"), 390 | ComponentUpdatable::AtLatestVersion => Cow::Borrowed("At latest version"), 391 | ComponentUpdatable::WouldDowngrade => Cow::Borrowed("Ignoring downgrade"), 392 | ComponentUpdatable::Upgradable => Cow::Owned(format!( 393 | "Available: {}", 394 | component.update.as_ref().expect("update").version 395 | )), 396 | }; 397 | println!(" Update: {}", msg); 398 | } 399 | 400 | if status.adoptable.is_empty() { 401 | println!("No components are adoptable."); 402 | } 403 | for (name, adopt) in status.adoptable.iter() { 404 | let ver = &adopt.version.version; 405 | if adopt.confident { 406 | println!("Detected: {}: {}", name, ver); 407 | } else { 408 | println!("Adoptable: {}: {}", name, ver); 409 | } 410 | } 411 | 412 | if let Some(coreos_aleph) = coreos::get_aleph_version(Path::new("/"))? { 413 | println!("CoreOS aleph version: {}", coreos_aleph.aleph.version); 414 | } 415 | 416 | #[cfg(any( 417 | target_arch = "x86_64", 418 | target_arch = "aarch64", 419 | target_arch = "riscv64" 420 | ))] 421 | { 422 | let boot_method = if efi::is_efi_booted()? { "EFI" } else { "BIOS" }; 423 | println!("Boot method: {}", boot_method); 424 | } 425 | 426 | Ok(()) 427 | } 428 | 429 | pub struct RootContext { 430 | pub sysroot: openat::Dir, 431 | pub path: Utf8PathBuf, 432 | pub devices: Vec, 433 | } 434 | 435 | impl RootContext { 436 | fn new(sysroot: openat::Dir, path: &str, devices: Vec) -> Self { 437 | Self { 438 | sysroot, 439 | path: Utf8Path::new(path).into(), 440 | devices, 441 | } 442 | } 443 | } 444 | 445 | /// Initialize parent devices to prepare the update 446 | fn prep_before_update() -> Result { 447 | let path = "/"; 448 | let sysroot = openat::Dir::open(path).context("Opening root dir")?; 449 | let devices = crate::blockdev::get_devices(path).context("get parent devices")?; 450 | Ok(RootContext::new(sysroot, path, devices)) 451 | } 452 | 453 | pub(crate) fn client_run_update() -> Result<()> { 454 | crate::try_fail_point!("update"); 455 | let rootcxt = prep_before_update()?; 456 | let status: Status = status()?; 457 | if status.components.is_empty() && status.adoptable.is_empty() { 458 | println!("No components installed."); 459 | return Ok(()); 460 | } 461 | let mut updated = false; 462 | for (name, cstatus) in status.components.iter() { 463 | match cstatus.updatable { 464 | ComponentUpdatable::Upgradable => {} 465 | _ => continue, 466 | }; 467 | match update(name, &rootcxt)? { 468 | ComponentUpdateResult::AtLatestVersion => { 469 | // Shouldn't happen unless we raced with another client 470 | eprintln!( 471 | "warning: Expected update for {}, raced with a different client?", 472 | name 473 | ); 474 | continue; 475 | } 476 | ComponentUpdateResult::Updated { 477 | previous, 478 | interrupted, 479 | new, 480 | } => { 481 | if let Some(i) = interrupted { 482 | eprintln!( 483 | "warning: Continued from previous interrupted update: {}", 484 | i.version, 485 | ); 486 | } 487 | println!("Previous {}: {}", name, previous.version); 488 | println!("Updated {}: {}", name, new.version); 489 | } 490 | } 491 | updated = true; 492 | } 493 | for (name, adoptable) in status.adoptable.iter() { 494 | if adoptable.confident { 495 | let r: ContentMetadata = adopt_and_update(name, &rootcxt)?; 496 | println!("Adopted and updated: {}: {}", name, r.version); 497 | updated = true; 498 | } else { 499 | println!("Component {} requires explicit adopt-and-update", name); 500 | } 501 | } 502 | if !updated { 503 | println!("No update available for any component."); 504 | } 505 | Ok(()) 506 | } 507 | 508 | pub(crate) fn client_run_adopt_and_update() -> Result<()> { 509 | let rootcxt = prep_before_update()?; 510 | let status: Status = status()?; 511 | if status.adoptable.is_empty() { 512 | println!("No components are adoptable."); 513 | } else { 514 | for (name, _) in status.adoptable.iter() { 515 | let r: ContentMetadata = adopt_and_update(name, &rootcxt)?; 516 | println!("Adopted and updated: {}: {}", name, r.version); 517 | } 518 | } 519 | Ok(()) 520 | } 521 | 522 | pub(crate) fn client_run_validate() -> Result<()> { 523 | let status: Status = status()?; 524 | if status.components.is_empty() { 525 | println!("No components installed."); 526 | return Ok(()); 527 | } 528 | let mut caught_validation_error = false; 529 | for (name, _) in status.components.iter() { 530 | match validate(name)? { 531 | ValidationResult::Valid => { 532 | println!("Validated: {}", name); 533 | } 534 | ValidationResult::Skip => { 535 | println!("Skipped: {}", name); 536 | } 537 | ValidationResult::Errors(errs) => { 538 | for err in errs { 539 | eprintln!("{}", err); 540 | } 541 | caught_validation_error = true; 542 | } 543 | } 544 | } 545 | if caught_validation_error { 546 | anyhow::bail!("Caught validation errors"); 547 | } 548 | Ok(()) 549 | } 550 | 551 | #[context("Migrating to a static GRUB config")] 552 | pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> { 553 | // Did we already complete the migration? 554 | let mut cmd = std::process::Command::new("ostree"); 555 | let result = cmd 556 | .args([ 557 | "config", 558 | "--repo=/sysroot/ostree/repo", 559 | "get", 560 | "sysroot.bootloader", 561 | ]) 562 | .output() 563 | .context("Querying ostree sysroot.bootloader")?; 564 | if !result.status.success() { 565 | // ostree will exit with a non zero return code if the key does not exists 566 | println!("ostree repo 'sysroot.bootloader' config option is not set yet"); 567 | } else { 568 | let res = String::from_utf8(result.stdout) 569 | .with_context(|| "decoding as UTF-8 output of ostree command")?; 570 | let bootloader = res.trim_end(); 571 | if bootloader == "none" { 572 | println!("Already using a static GRUB config"); 573 | return Ok(()); 574 | } 575 | println!( 576 | "ostree repo 'sysroot.bootloader' config option is currently set to: '{}'", 577 | bootloader 578 | ); 579 | } 580 | 581 | // Remount /boot read write just for this unit (we are called in a slave mount namespace by systemd) 582 | ensure_writable_boot()?; 583 | 584 | let grub_config_dir = PathBuf::from("/boot/grub2"); 585 | let dirfd = openat::Dir::open(&grub_config_dir).context("Opening /boot/grub2")?; 586 | 587 | // We mark the bootloader as BLS capable to disable the ostree-grub2 logic. 588 | // We can do that as we know that we are run after the bootloader has been 589 | // updated and all recent GRUB2 versions support reading BLS configs. 590 | // Ignore errors as this is not critical. This is a safety net if a user 591 | // manually overwrites the (soon) static GRUB config by calling `grub2-mkconfig`. 592 | // We need this until we can rely on ostree-grub2 being removed from the image. 593 | println!("Marking bootloader as BLS capable..."); 594 | _ = File::create("/boot/grub2/.grub2-blscfg-supported"); 595 | 596 | // Migrate /boot/grub2/grub.cfg to a static GRUB config if it is a symlink 597 | let grub_config_filename = PathBuf::from("/boot/grub2/grub.cfg"); 598 | match dirfd.read_link("grub.cfg") { 599 | Err(_) => { 600 | println!( 601 | "'{}' is not a symlink, nothing to migrate", 602 | grub_config_filename.display() 603 | ); 604 | } 605 | Ok(path) => { 606 | println!("Migrating to a static GRUB config..."); 607 | 608 | // Resolve symlink location 609 | let mut current_config = grub_config_dir.clone(); 610 | current_config.push(path); 611 | 612 | // Backup the current GRUB config which is hopefully working right now 613 | let backup_config = PathBuf::from("/boot/grub2/grub.cfg.backup"); 614 | println!( 615 | "Creating a backup of the current GRUB config '{}' in '{}'...", 616 | current_config.display(), 617 | backup_config.display() 618 | ); 619 | fs::copy(¤t_config, &backup_config).context("Failed to backup GRUB config")?; 620 | 621 | // Read the current config, strip the ostree generated GRUB entries and 622 | // write the result to a temporary file 623 | println!("Stripping ostree generated entries from GRUB config..."); 624 | let current_config_file = 625 | File::open(current_config).context("Could not open current GRUB config")?; 626 | let stripped_config = String::from("grub.cfg.stripped"); 627 | // mode = -rw-r--r-- (644) 628 | let mut writer = BufWriter::new( 629 | dirfd 630 | .write_file( 631 | &stripped_config, 632 | (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) as mode_t, 633 | ) 634 | .context("Failed to open temporary GRUB config")?, 635 | ); 636 | let mut skip = false; 637 | for line in BufReader::new(current_config_file).lines() { 638 | let line = line.context("Failed to read line from GRUB config")?; 639 | if line == "### END /etc/grub.d/15_ostree ###" { 640 | skip = false; 641 | } 642 | if skip { 643 | continue; 644 | } 645 | if line == "### BEGIN /etc/grub.d/15_ostree ###" { 646 | skip = true; 647 | } 648 | writer 649 | .write_all(&line.as_bytes()) 650 | .context("Failed to write stripped GRUB config")?; 651 | writer 652 | .write_all(b"\n") 653 | .context("Failed to write stripped GRUB config")?; 654 | } 655 | writer 656 | .flush() 657 | .context("Failed to write stripped GRUB config")?; 658 | 659 | // Sync changes to the filesystem (ignore failures) 660 | let _ = dirfd.syncfs(); 661 | 662 | // Atomically exchange the configs 663 | dirfd 664 | .local_exchange(&stripped_config, "grub.cfg") 665 | .context("Failed to exchange symlink with current GRUB config")?; 666 | 667 | // Sync changes to the filesystem (ignore failures) 668 | let _ = dirfd.syncfs(); 669 | 670 | println!("GRUB config symlink successfully replaced with the current config"); 671 | 672 | // Remove the now unused symlink (optional cleanup, ignore any failures) 673 | _ = dirfd.remove_file(&stripped_config); 674 | } 675 | }; 676 | 677 | println!("Setting 'sysroot.bootloader' to 'none' in ostree repo config..."); 678 | let status = std::process::Command::new("ostree") 679 | .args([ 680 | "config", 681 | "--repo=/sysroot/ostree/repo", 682 | "set", 683 | "sysroot.bootloader", 684 | "none", 685 | ]) 686 | .status()?; 687 | if !status.success() { 688 | anyhow::bail!("Failed to set 'sysroot.bootloader' to 'none' in ostree repo config"); 689 | } 690 | 691 | println!("Static GRUB config migration completed successfully"); 692 | Ok(()) 693 | } 694 | 695 | #[cfg(test)] 696 | mod tests { 697 | use super::*; 698 | 699 | #[test] 700 | fn test_failpoint_update() { 701 | let guard = fail::FailScenario::setup(); 702 | fail::cfg("update", "return").unwrap(); 703 | let r = client_run_update(); 704 | assert_eq!(r.is_err(), true); 705 | guard.teardown(); 706 | } 707 | } 708 | -------------------------------------------------------------------------------- /src/cli/bootupctl.rs: -------------------------------------------------------------------------------- 1 | use crate::bootupd; 2 | use anyhow::Result; 3 | use clap::Parser; 4 | use log::LevelFilter; 5 | 6 | use std::os::unix::process::CommandExt; 7 | use std::process::{Command, Stdio}; 8 | 9 | static SYSTEMD_ARGS_BOOTUPD: &[&str] = &["--unit", "bootupd", "--pipe"]; 10 | 11 | /// Keep these properties (isolation/runtime state) in sync with 12 | /// the systemd units in contrib/packaging/*.service 13 | static SYSTEMD_PROPERTIES: &[&str] = &[ 14 | "PrivateNetwork=yes", 15 | "ProtectHome=yes", 16 | // While only our main process during update catches SIGTERM, we don't 17 | // want systemd to send it to other processes. 18 | "KillMode=mixed", 19 | "MountFlags=slave", 20 | ]; 21 | 22 | /// `bootupctl` sub-commands. 23 | #[derive(Debug, Parser)] 24 | #[clap(name = "bootupctl", about = "Bootupd client application", version)] 25 | pub struct CtlCommand { 26 | /// Verbosity level (higher is more verbose). 27 | #[clap(short = 'v', action = clap::ArgAction::Count, global = true)] 28 | verbosity: u8, 29 | 30 | /// CLI sub-command. 31 | #[clap(subcommand)] 32 | pub cmd: CtlVerb, 33 | } 34 | 35 | impl CtlCommand { 36 | /// Return the log-level set via command-line flags. 37 | pub(crate) fn loglevel(&self) -> LevelFilter { 38 | match self.verbosity { 39 | 0 => LevelFilter::Warn, 40 | 1 => LevelFilter::Info, 41 | 2 => LevelFilter::Debug, 42 | _ => LevelFilter::Trace, 43 | } 44 | } 45 | } 46 | 47 | /// CLI sub-commands. 48 | #[derive(Debug, Parser)] 49 | pub enum CtlVerb { 50 | // FIXME(lucab): drop this after refreshing 51 | // https://github.com/coreos/fedora-coreos-config/pull/595 52 | #[clap(name = "backend", hide = true, subcommand)] 53 | Backend(CtlBackend), 54 | #[clap(name = "status", about = "Show components status")] 55 | Status(StatusOpts), 56 | #[clap(name = "update", about = "Update all components")] 57 | Update, 58 | #[clap(name = "adopt-and-update", about = "Update all adoptable components")] 59 | AdoptAndUpdate, 60 | #[clap(name = "validate", about = "Validate system state")] 61 | Validate, 62 | #[clap( 63 | name = "migrate-static-grub-config", 64 | hide = true, 65 | about = "Migrate a system to a static GRUB config" 66 | )] 67 | MigrateStaticGrubConfig, 68 | } 69 | 70 | #[derive(Debug, Parser)] 71 | pub enum CtlBackend { 72 | #[clap(name = "generate-update-metadata", hide = true)] 73 | Generate(super::bootupd::GenerateOpts), 74 | #[clap(name = "install", hide = true)] 75 | Install(super::bootupd::InstallOpts), 76 | } 77 | 78 | #[derive(Debug, Parser)] 79 | pub struct StatusOpts { 80 | /// If there are updates available, output `Updates available: ` to standard output; 81 | /// otherwise output nothing. Avoid parsing this, just check whether or not 82 | /// the output is empty. 83 | #[clap(long, action)] 84 | print_if_available: bool, 85 | 86 | /// Output JSON 87 | #[clap(long, action)] 88 | json: bool, 89 | } 90 | 91 | impl CtlCommand { 92 | /// Run CLI application. 93 | pub fn run(self) -> Result<()> { 94 | match self.cmd { 95 | CtlVerb::Status(opts) => Self::run_status(opts), 96 | CtlVerb::Update => Self::run_update(), 97 | CtlVerb::AdoptAndUpdate => Self::run_adopt_and_update(), 98 | CtlVerb::Validate => Self::run_validate(), 99 | CtlVerb::Backend(CtlBackend::Generate(opts)) => { 100 | super::bootupd::DCommand::run_generate_meta(opts) 101 | } 102 | CtlVerb::Backend(CtlBackend::Install(opts)) => { 103 | super::bootupd::DCommand::run_install(opts) 104 | } 105 | CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(), 106 | } 107 | } 108 | 109 | /// Runner for `status` verb. 110 | fn run_status(opts: StatusOpts) -> Result<()> { 111 | if crate::util::running_in_container() { 112 | return run_status_in_container(opts.json); 113 | } 114 | ensure_running_in_systemd()?; 115 | let r = bootupd::status()?; 116 | if opts.json { 117 | let stdout = std::io::stdout(); 118 | let mut stdout = stdout.lock(); 119 | serde_json::to_writer_pretty(&mut stdout, &r)?; 120 | } else if opts.print_if_available { 121 | bootupd::print_status_avail(&r)?; 122 | } else { 123 | bootupd::print_status(&r)?; 124 | } 125 | 126 | Ok(()) 127 | } 128 | 129 | /// Runner for `update` verb. 130 | fn run_update() -> Result<()> { 131 | ensure_running_in_systemd()?; 132 | bootupd::client_run_update() 133 | } 134 | 135 | /// Runner for `update` verb. 136 | fn run_adopt_and_update() -> Result<()> { 137 | ensure_running_in_systemd()?; 138 | bootupd::client_run_adopt_and_update() 139 | } 140 | 141 | /// Runner for `validate` verb. 142 | fn run_validate() -> Result<()> { 143 | ensure_running_in_systemd()?; 144 | bootupd::client_run_validate() 145 | } 146 | 147 | /// Runner for `migrate-static-grub-config` verb. 148 | fn run_migrate_static_grub_config() -> Result<()> { 149 | ensure_running_in_systemd()?; 150 | bootupd::client_run_migrate_static_grub_config() 151 | } 152 | } 153 | 154 | /// Checks if the current process is (apparently at least) 155 | /// running under systemd. 156 | fn running_in_systemd() -> bool { 157 | std::env::var_os("INVOCATION_ID").is_some() 158 | } 159 | 160 | /// Require root permission 161 | fn require_root_permission() -> Result<()> { 162 | if !rustix::process::getuid().is_root() { 163 | anyhow::bail!("This command requires root privileges") 164 | } 165 | Ok(()) 166 | } 167 | 168 | /// Detect if we're running in systemd; if we're not, we re-exec ourselves via 169 | /// systemd-run. Then we can just directly run code in what is now the daemon. 170 | fn ensure_running_in_systemd() -> Result<()> { 171 | require_root_permission()?; 172 | let running_in_systemd = running_in_systemd(); 173 | if !running_in_systemd { 174 | // Clear any failure status that may have happened previously 175 | let _r = Command::new("systemctl") 176 | .arg("reset-failed") 177 | .arg("bootupd.service") 178 | .stdout(Stdio::null()) 179 | .stderr(Stdio::null()) 180 | .spawn()? 181 | .wait()?; 182 | let r = Command::new("systemd-run") 183 | .args(SYSTEMD_ARGS_BOOTUPD) 184 | .args( 185 | SYSTEMD_PROPERTIES 186 | .into_iter() 187 | .flat_map(|&v| ["--property", v]), 188 | ) 189 | .args(std::env::args()) 190 | .exec(); 191 | // If we got here, it's always an error 192 | return Err(r.into()); 193 | } 194 | Ok(()) 195 | } 196 | 197 | /// If running in container, just print the available payloads 198 | fn run_status_in_container(json_format: bool) -> Result<()> { 199 | let all_components = crate::bootupd::get_components(); 200 | if all_components.is_empty() { 201 | return Ok(()); 202 | } 203 | let avail: Vec<_> = all_components.keys().cloned().collect(); 204 | if json_format { 205 | let stdout = std::io::stdout(); 206 | let mut stdout = stdout.lock(); 207 | let output: serde_json::Value = serde_json::json!({ 208 | "components": avail 209 | }); 210 | serde_json::to_writer(&mut stdout, &output)?; 211 | } else { 212 | println!("Available components: {}", avail.join(" ")); 213 | } 214 | Ok(()) 215 | } 216 | -------------------------------------------------------------------------------- /src/cli/bootupd.rs: -------------------------------------------------------------------------------- 1 | use crate::bootupd::{self, ConfigMode}; 2 | use anyhow::{Context, Result}; 3 | use clap::Parser; 4 | use log::LevelFilter; 5 | 6 | /// `bootupd` sub-commands. 7 | #[derive(Debug, Parser)] 8 | #[clap(name = "bootupd", about = "Bootupd backend commands", version)] 9 | pub struct DCommand { 10 | /// Verbosity level (higher is more verbose). 11 | #[clap(short = 'v', action = clap::ArgAction::Count, global = true)] 12 | verbosity: u8, 13 | 14 | /// CLI sub-command. 15 | #[clap(subcommand)] 16 | pub cmd: DVerb, 17 | } 18 | 19 | impl DCommand { 20 | /// Return the log-level set via command-line flags. 21 | pub(crate) fn loglevel(&self) -> LevelFilter { 22 | match self.verbosity { 23 | 0 => LevelFilter::Warn, 24 | 1 => LevelFilter::Info, 25 | 2 => LevelFilter::Debug, 26 | _ => LevelFilter::Trace, 27 | } 28 | } 29 | } 30 | 31 | /// CLI sub-commands. 32 | #[derive(Debug, Parser)] 33 | pub enum DVerb { 34 | #[clap(name = "generate-update-metadata", about = "Generate metadata")] 35 | GenerateUpdateMetadata(GenerateOpts), 36 | #[clap(name = "install", about = "Install components")] 37 | Install(InstallOpts), 38 | } 39 | 40 | #[derive(Debug, Parser)] 41 | pub struct InstallOpts { 42 | /// Source root 43 | #[clap(long, value_parser, default_value_t = String::from("/"))] 44 | src_root: String, 45 | /// Target root 46 | #[clap(value_parser)] 47 | dest_root: String, 48 | 49 | /// Target device, used by bios bootloader installation 50 | #[clap(long)] 51 | device: Option, 52 | 53 | /// Enable installation of the built-in static config files 54 | #[clap(long)] 55 | with_static_configs: bool, 56 | 57 | /// Implies `--with-static-configs`. When present, this also writes a 58 | /// file with the UUID of the target filesystems. 59 | #[clap(long)] 60 | write_uuid: bool, 61 | 62 | /// On EFI systems, invoke `efibootmgr` to update the firmware. 63 | #[clap(long)] 64 | update_firmware: bool, 65 | 66 | #[clap(long = "component", conflicts_with = "auto")] 67 | /// Only install these components 68 | components: Option>, 69 | 70 | /// Automatically choose components based on booted host state. 71 | /// 72 | /// For example on x86_64, if the host system is booted via EFI, 73 | /// then only enable installation to the ESP. 74 | #[clap(long)] 75 | auto: bool, 76 | } 77 | 78 | #[derive(Debug, Parser)] 79 | pub struct GenerateOpts { 80 | /// Physical root mountpoint 81 | #[clap(value_parser)] 82 | sysroot: Option, 83 | } 84 | 85 | impl DCommand { 86 | /// Run CLI application. 87 | pub fn run(self) -> Result<()> { 88 | match self.cmd { 89 | DVerb::Install(opts) => Self::run_install(opts), 90 | DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), 91 | } 92 | } 93 | 94 | /// Runner for `generate-install-metadata` verb. 95 | pub(crate) fn run_generate_meta(opts: GenerateOpts) -> Result<()> { 96 | let sysroot = opts.sysroot.as_deref().unwrap_or("/"); 97 | if sysroot != "/" { 98 | anyhow::bail!("Using a non-default sysroot is not supported: {}", sysroot); 99 | } 100 | bootupd::generate_update_metadata(sysroot).context("generating metadata failed")?; 101 | Ok(()) 102 | } 103 | 104 | /// Runner for `install` verb. 105 | pub(crate) fn run_install(opts: InstallOpts) -> Result<()> { 106 | let configmode = if opts.write_uuid { 107 | ConfigMode::WithUUID 108 | } else if opts.with_static_configs { 109 | ConfigMode::Static 110 | } else { 111 | ConfigMode::None 112 | }; 113 | bootupd::install( 114 | &opts.src_root, 115 | &opts.dest_root, 116 | opts.device.as_deref(), 117 | configmode, 118 | opts.update_firmware, 119 | opts.components.as_deref(), 120 | opts.auto, 121 | ) 122 | .context("boot data installation failed")?; 123 | Ok(()) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | //! Command-line interface (CLI) logic. 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use log::LevelFilter; 6 | mod bootupctl; 7 | mod bootupd; 8 | 9 | /// Top-level multicall CLI. 10 | #[derive(Debug, Parser)] 11 | pub enum MultiCall { 12 | Ctl(bootupctl::CtlCommand), 13 | D(bootupd::DCommand), 14 | } 15 | 16 | impl MultiCall { 17 | pub fn from_args(args: Vec) -> Self { 18 | use std::os::unix::ffi::OsStrExt; 19 | 20 | // This is a multicall binary, dispatched based on the introspected 21 | // filename found in argv[0]. 22 | let exe_name = { 23 | let arg0 = args.get(0).cloned().unwrap_or_default(); 24 | let exe_path = std::path::PathBuf::from(arg0); 25 | exe_path.file_name().unwrap_or_default().to_os_string() 26 | }; 27 | #[allow(clippy::wildcard_in_or_patterns)] 28 | match exe_name.as_bytes() { 29 | b"bootupctl" => MultiCall::Ctl(bootupctl::CtlCommand::parse_from(args)), 30 | b"bootupd" | _ => MultiCall::D(bootupd::DCommand::parse_from(args)), 31 | } 32 | } 33 | 34 | pub fn run(self) -> Result<()> { 35 | match self { 36 | MultiCall::Ctl(ctl_cmd) => ctl_cmd.run(), 37 | MultiCall::D(d_cmd) => d_cmd.run(), 38 | } 39 | } 40 | 41 | /// Return the log-level set via command-line flags. 42 | pub fn loglevel(&self) -> LevelFilter { 43 | match self { 44 | MultiCall::Ctl(cmd) => cmd.loglevel(), 45 | MultiCall::D(cmd) => cmd.loglevel(), 46 | } 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | 54 | #[test] 55 | fn clap_apps() { 56 | use clap::CommandFactory; 57 | bootupctl::CtlCommand::command().debug_assert(); 58 | bootupd::DCommand::command().debug_assert(); 59 | } 60 | 61 | #[test] 62 | fn test_multicall_dispatch() { 63 | { 64 | let d_argv = vec![ 65 | "/usr/bin/bootupd".to_string(), 66 | "generate-update-metadata".to_string(), 67 | ]; 68 | let cli = MultiCall::from_args(d_argv); 69 | match cli { 70 | MultiCall::Ctl(cmd) => panic!("{:?}", cmd), 71 | MultiCall::D(_) => {} 72 | }; 73 | } 74 | { 75 | let ctl_argv = vec!["/usr/bin/bootupctl".to_string(), "validate".to_string()]; 76 | let cli = MultiCall::from_args(ctl_argv); 77 | match cli { 78 | MultiCall::Ctl(_) => {} 79 | MultiCall::D(cmd) => panic!("{:?}", cmd), 80 | }; 81 | } 82 | { 83 | let ctl_argv = vec!["/bin-mount/bootupctl".to_string(), "validate".to_string()]; 84 | let cli = MultiCall::from_args(ctl_argv); 85 | match cli { 86 | MultiCall::Ctl(_) => {} 87 | MultiCall::D(cmd) => panic!("{:?}", cmd), 88 | }; 89 | } 90 | } 91 | 92 | #[test] 93 | fn test_verbosity() { 94 | let default = MultiCall::from_args(vec![ 95 | "bootupd".to_string(), 96 | "generate-update-metadata".to_string(), 97 | ]); 98 | assert_eq!(default.loglevel(), LevelFilter::Warn); 99 | 100 | let info = MultiCall::from_args(vec![ 101 | "bootupd".to_string(), 102 | "generate-update-metadata".to_string(), 103 | "-v".to_string(), 104 | ]); 105 | assert_eq!(info.loglevel(), LevelFilter::Info); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/component.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Red Hat, Inc. 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | use anyhow::{Context, Result}; 8 | use fn_error_context::context; 9 | use openat_ext::OpenatDirExt; 10 | use serde::{Deserialize, Serialize}; 11 | use std::path::{Path, PathBuf}; 12 | 13 | use crate::{bootupd::RootContext, model::*}; 14 | 15 | #[derive(Serialize, Deserialize, Debug)] 16 | #[serde(rename_all = "kebab-case")] 17 | pub(crate) enum ValidationResult { 18 | Valid, 19 | Skip, 20 | Errors(Vec), 21 | } 22 | 23 | /// A component along with a possible update 24 | pub(crate) trait Component { 25 | /// Returns the name of the component; this will be used for serialization 26 | /// and should remain stable. 27 | fn name(&self) -> &'static str; 28 | 29 | /// In an operating system whose initially booted disk image is not 30 | /// using bootupd, detect whether it looks like the component exists 31 | /// and "synthesize" content metadata from it. 32 | fn query_adopt(&self, devices: &Option>) -> Result>; 33 | 34 | /// Given an adoptable system and an update, perform the update. 35 | fn adopt_update( 36 | &self, 37 | rootcxt: &RootContext, 38 | update: &ContentMetadata, 39 | ) -> Result; 40 | 41 | /// Implementation of `bootupd install` for a given component. This should 42 | /// gather data (or run binaries) from the source root, and install them 43 | /// into the target root. It is expected that sub-partitions (e.g. the ESP) 44 | /// are mounted at the expected place. For operations that require a block device instead 45 | /// of a filesystem root, the component should query the mount point to 46 | /// determine the block device. 47 | /// This will be run during a disk image build process. 48 | fn install( 49 | &self, 50 | src_root: &openat::Dir, 51 | dest_root: &str, 52 | device: &str, 53 | update_firmware: bool, 54 | ) -> Result; 55 | 56 | /// Implementation of `bootupd generate-update-metadata` for a given component. 57 | /// This expects to be run during an "image update build" process. For CoreOS 58 | /// this is an `rpm-ostree compose tree` for example. For a dual-partition 59 | /// style updater, this would be run as part of a postprocessing step 60 | /// while the filesystem for the partition is mounted. 61 | fn generate_update_metadata(&self, sysroot: &str) -> Result; 62 | 63 | /// Used on the client to query for an update cached in the current booted OS. 64 | fn query_update(&self, sysroot: &openat::Dir) -> Result>; 65 | 66 | /// Used on the client to run an update. 67 | fn run_update( 68 | &self, 69 | rootcxt: &RootContext, 70 | current: &InstalledContent, 71 | ) -> Result; 72 | 73 | /// Used on the client to validate an installed version. 74 | fn validate(&self, current: &InstalledContent) -> Result; 75 | 76 | /// Locating efi vendor dir 77 | fn get_efi_vendor(&self, sysroot: &openat::Dir) -> Result>; 78 | } 79 | 80 | /// Given a component name, create an implementation. 81 | pub(crate) fn new_from_name(name: &str) -> Result> { 82 | let r: Box = match name { 83 | #[cfg(any( 84 | target_arch = "x86_64", 85 | target_arch = "aarch64", 86 | target_arch = "riscv64" 87 | ))] 88 | #[allow(clippy::box_default)] 89 | "EFI" => Box::new(crate::efi::Efi::default()), 90 | #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] 91 | #[allow(clippy::box_default)] 92 | "BIOS" => Box::new(crate::bios::Bios::default()), 93 | _ => anyhow::bail!("No component {}", name), 94 | }; 95 | Ok(r) 96 | } 97 | 98 | /// Returns the path to the payload directory for an available update for 99 | /// a component. 100 | #[cfg(any( 101 | target_arch = "x86_64", 102 | target_arch = "aarch64", 103 | target_arch = "riscv64" 104 | ))] 105 | pub(crate) fn component_updatedirname(component: &dyn Component) -> PathBuf { 106 | Path::new(BOOTUPD_UPDATES_DIR).join(component.name()) 107 | } 108 | 109 | /// Returns the path to the payload directory for an available update for 110 | /// a component. 111 | #[cfg(any( 112 | target_arch = "x86_64", 113 | target_arch = "aarch64", 114 | target_arch = "riscv64" 115 | ))] 116 | pub(crate) fn component_updatedir(sysroot: &str, component: &dyn Component) -> PathBuf { 117 | Path::new(sysroot).join(component_updatedirname(component)) 118 | } 119 | 120 | /// Returns the name of the JSON file containing a component's available update metadata installed 121 | /// into the booted operating system root. 122 | fn component_update_data_name(component: &dyn Component) -> PathBuf { 123 | Path::new(&format!("{}.json", component.name())).into() 124 | } 125 | 126 | /// Helper method for writing an update file 127 | pub(crate) fn write_update_metadata( 128 | sysroot: &str, 129 | component: &dyn Component, 130 | meta: &ContentMetadata, 131 | ) -> Result<()> { 132 | let sysroot = openat::Dir::open(sysroot)?; 133 | let dir = sysroot.sub_dir(BOOTUPD_UPDATES_DIR)?; 134 | let name = component_update_data_name(component); 135 | dir.write_file_with(name, 0o644, |w| -> Result<_> { 136 | Ok(serde_json::to_writer(w, &meta)?) 137 | })?; 138 | Ok(()) 139 | } 140 | 141 | /// Given a component, return metadata on the available update (if any) 142 | #[context("Loading update for component {}", component.name())] 143 | pub(crate) fn get_component_update( 144 | sysroot: &openat::Dir, 145 | component: &dyn Component, 146 | ) -> Result> { 147 | let name = component_update_data_name(component); 148 | let path = Path::new(BOOTUPD_UPDATES_DIR).join(name); 149 | if let Some(f) = sysroot.open_file_optional(&path)? { 150 | let mut f = std::io::BufReader::new(f); 151 | let u = serde_json::from_reader(&mut f) 152 | .with_context(|| format!("failed to parse {:?}", &path))?; 153 | Ok(Some(u)) 154 | } else { 155 | Ok(None) 156 | } 157 | } 158 | 159 | #[context("Querying adoptable state")] 160 | pub(crate) fn query_adopt_state() -> Result> { 161 | // This would be extended with support for other operating systems later 162 | if let Some(coreos_aleph) = crate::coreos::get_aleph_version(Path::new("/"))? { 163 | let meta = ContentMetadata { 164 | timestamp: coreos_aleph.ts, 165 | version: coreos_aleph.aleph.version, 166 | }; 167 | log::trace!("Adoptable: {:?}", &meta); 168 | return Ok(Some(Adoptable { 169 | version: meta, 170 | confident: true, 171 | })); 172 | } else { 173 | log::trace!("No CoreOS aleph detected"); 174 | } 175 | let ostree_deploy_dir = Path::new("/ostree/deploy"); 176 | if ostree_deploy_dir.exists() { 177 | let btime = ostree_deploy_dir.metadata()?.created()?; 178 | let timestamp = chrono::DateTime::from(btime); 179 | let meta = ContentMetadata { 180 | timestamp, 181 | version: "unknown".to_string(), 182 | }; 183 | return Ok(Some(Adoptable { 184 | version: meta, 185 | confident: true, 186 | })); 187 | } 188 | Ok(None) 189 | } 190 | 191 | #[cfg(test)] 192 | mod tests { 193 | use super::*; 194 | 195 | #[test] 196 | fn test_get_efi_vendor() -> Result<()> { 197 | let td = tempfile::tempdir()?; 198 | let tdp = td.path(); 199 | let tdp_updates = tdp.join("usr/lib/bootupd/updates"); 200 | let td = openat::Dir::open(tdp)?; 201 | std::fs::create_dir_all(tdp_updates.join("EFI/BOOT"))?; 202 | std::fs::create_dir_all(tdp_updates.join("EFI/fedora"))?; 203 | std::fs::create_dir_all(tdp_updates.join("EFI/centos"))?; 204 | std::fs::write( 205 | tdp_updates.join("EFI/fedora").join(crate::efi::SHIM), 206 | "shim data", 207 | )?; 208 | std::fs::write( 209 | tdp_updates.join("EFI/centos").join(crate::efi::SHIM), 210 | "shim data", 211 | )?; 212 | 213 | let all_components = crate::bootupd::get_components(); 214 | let target_components: Vec<_> = all_components.values().collect(); 215 | for &component in target_components.iter() { 216 | if component.name() == "BIOS" { 217 | assert_eq!(component.get_efi_vendor(&td)?, None); 218 | } 219 | if component.name() == "EFI" { 220 | let x = component.get_efi_vendor(&td); 221 | assert_eq!(x.is_err(), true); 222 | std::fs::remove_dir_all(tdp_updates.join("EFI/centos"))?; 223 | assert_eq!(component.get_efi_vendor(&td)?, Some("fedora".to_string())); 224 | } 225 | } 226 | Ok(()) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/coreos.rs: -------------------------------------------------------------------------------- 1 | //! Bits specific to Fedora CoreOS (and derivatives). 2 | 3 | /* 4 | * Copyright (C) 2020 Red Hat, Inc. 5 | * 6 | * SPDX-License-Identifier: Apache-2.0 7 | */ 8 | 9 | use anyhow::{Context, Result}; 10 | use chrono::prelude::*; 11 | use serde::{Deserialize, Serialize}; 12 | use std::fs::File; 13 | use std::path::Path; 14 | 15 | #[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)] 16 | #[serde(rename_all = "kebab-case")] 17 | /// See https://github.com/coreos/fedora-coreos-tracker/blob/66d7d00bedd9d5eabc7287b9577f443dcefb7c04/internals/README-internals.md#aleph-version 18 | pub(crate) struct Aleph { 19 | #[serde(alias = "build")] 20 | pub(crate) version: String, 21 | } 22 | 23 | pub(crate) struct AlephWithTimestamp { 24 | pub(crate) aleph: Aleph, 25 | #[allow(dead_code)] 26 | pub(crate) ts: chrono::DateTime, 27 | } 28 | 29 | /// Path to the file, see above 30 | const ALEPH_PATH: &str = "sysroot/.coreos-aleph-version.json"; 31 | 32 | pub(crate) fn get_aleph_version(root: &Path) -> Result> { 33 | let path = &root.join(ALEPH_PATH); 34 | if !path.exists() { 35 | return Ok(None); 36 | } 37 | let statusf = File::open(path).with_context(|| format!("Opening {path:?}"))?; 38 | let meta = statusf.metadata()?; 39 | let bufr = std::io::BufReader::new(statusf); 40 | let aleph: Aleph = serde_json::from_reader(bufr)?; 41 | Ok(Some(AlephWithTimestamp { 42 | aleph, 43 | ts: meta.created()?.into(), 44 | })) 45 | } 46 | 47 | #[cfg(test)] 48 | mod test { 49 | use super::*; 50 | use anyhow::Result; 51 | 52 | const V1_ALEPH_DATA: &str = r##" 53 | { 54 | "version": "32.20201002.dev.2", 55 | "ref": "fedora/x86_64/coreos/testing-devel", 56 | "ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862" 57 | }"##; 58 | 59 | // Waiting on https://github.com/rust-lang/rust/pull/125692 60 | #[cfg(not(target_env = "musl"))] 61 | #[test] 62 | fn test_parse_from_root_empty() -> Result<()> { 63 | // Verify we're a no-op in an empty root 64 | let root: &tempfile::TempDir = &tempfile::tempdir()?; 65 | let root = root.path(); 66 | assert!(get_aleph_version(root).unwrap().is_none()); 67 | Ok(()) 68 | } 69 | 70 | // Waiting on https://github.com/rust-lang/rust/pull/125692 71 | #[cfg(not(target_env = "musl"))] 72 | #[test] 73 | fn test_parse_from_root() -> Result<()> { 74 | let root: &tempfile::TempDir = &tempfile::tempdir()?; 75 | let root = root.path(); 76 | let sysroot = &root.join("sysroot"); 77 | std::fs::create_dir(sysroot).context("Creating sysroot")?; 78 | std::fs::write(root.join(ALEPH_PATH), V1_ALEPH_DATA).context("Writing aleph")?; 79 | let aleph = get_aleph_version(root).unwrap().unwrap(); 80 | assert_eq!(aleph.aleph.version, "32.20201002.dev.2"); 81 | Ok(()) 82 | } 83 | 84 | // Waiting on https://github.com/rust-lang/rust/pull/125692 85 | #[cfg(not(target_env = "musl"))] 86 | #[test] 87 | fn test_parse_from_root_linked() -> Result<()> { 88 | let root: &tempfile::TempDir = &tempfile::tempdir()?; 89 | let root = root.path(); 90 | let sysroot = &root.join("sysroot"); 91 | std::fs::create_dir(sysroot).context("Creating sysroot")?; 92 | let target_name = ".new-ostree-aleph.json"; 93 | let target = &sysroot.join(target_name); 94 | std::fs::write(root.join(target), V1_ALEPH_DATA).context("Writing aleph")?; 95 | std::os::unix::fs::symlink(target_name, root.join(ALEPH_PATH)).context("Symlinking")?; 96 | let aleph = get_aleph_version(root).unwrap().unwrap(); 97 | assert_eq!(aleph.aleph.version, "32.20201002.dev.2"); 98 | Ok(()) 99 | } 100 | 101 | #[test] 102 | fn test_parse_old_aleph() -> Result<()> { 103 | // What the aleph file looked like before we changed it in 104 | // https://github.com/osbuild/osbuild/pull/1475 105 | let alephdata = r##" 106 | { 107 | "build": "32.20201002.dev.2", 108 | "ref": "fedora/x86_64/coreos/testing-devel", 109 | "ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862", 110 | "imgid": "fedora-coreos-32.20201002.dev.2-qemu.x86_64.qcow2" 111 | }"##; 112 | let aleph: Aleph = serde_json::from_str(alephdata)?; 113 | assert_eq!(aleph.version, "32.20201002.dev.2"); 114 | Ok(()) 115 | } 116 | 117 | #[test] 118 | fn test_parse_aleph() -> Result<()> { 119 | let aleph: Aleph = serde_json::from_str(V1_ALEPH_DATA)?; 120 | assert_eq!(aleph.version, "32.20201002.dev.2"); 121 | Ok(()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/failpoints.rs: -------------------------------------------------------------------------------- 1 | //! Wrappers and utilities on top of the `fail` crate. 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | /// TODO: Use https://github.com/tikv/fail-rs/pull/68 once it merges 5 | /// copy from https://github.com/coreos/rpm-ostree/commit/aa8d7fb0ceaabfaf10252180e2ddee049d07aae3#diff-adcc419e139605fae34d17b31418dbaf515af2fe9fb766fcbdb2eaad862b3daa 6 | #[macro_export] 7 | macro_rules! try_fail_point { 8 | ($name:expr) => {{ 9 | if let Some(e) = fail::eval($name, |msg| { 10 | let msg = msg.unwrap_or_else(|| "synthetic failpoint".to_string()); 11 | anyhow::Error::msg(msg) 12 | }) { 13 | return Err(From::from(e)); 14 | } 15 | }}; 16 | ($name:expr, $cond:expr) => {{ 17 | if $cond { 18 | $crate::try_fail_point!($name); 19 | } 20 | }}; 21 | } 22 | -------------------------------------------------------------------------------- /src/filesystem.rs: -------------------------------------------------------------------------------- 1 | use std::os::fd::AsRawFd; 2 | use std::os::unix::process::CommandExt; 3 | use std::process::Command; 4 | 5 | use anyhow::Result; 6 | use bootc_utils::CommandRunExt; 7 | use fn_error_context::context; 8 | use rustix::fd::BorrowedFd; 9 | use serde::Deserialize; 10 | 11 | #[derive(Deserialize, Debug)] 12 | #[serde(rename_all = "kebab-case")] 13 | #[allow(dead_code)] 14 | pub(crate) struct Filesystem { 15 | pub(crate) source: String, 16 | pub(crate) fstype: String, 17 | pub(crate) options: String, 18 | pub(crate) uuid: Option, 19 | } 20 | 21 | #[derive(Deserialize, Debug)] 22 | pub(crate) struct Findmnt { 23 | pub(crate) filesystems: Vec, 24 | } 25 | 26 | #[context("Inspecting filesystem {path:?}")] 27 | pub(crate) fn inspect_filesystem(root: &openat::Dir, path: &str) -> Result { 28 | let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) }; 29 | // SAFETY: This is unsafe just for the pre_exec, when we port to cap-std we can use cap-std-ext 30 | let o: Findmnt = unsafe { 31 | Command::new("findmnt") 32 | .args(["-J", "-v", "--output=SOURCE,FSTYPE,OPTIONS,UUID", path]) 33 | .pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into)) 34 | .run_and_parse_json()? 35 | }; 36 | o.filesystems 37 | .into_iter() 38 | .next() 39 | .ok_or_else(|| anyhow::anyhow!("findmnt returned no data")) 40 | } 41 | -------------------------------------------------------------------------------- /src/grub2/README.md: -------------------------------------------------------------------------------- 1 | # Static GRUB configuration files 2 | 3 | These static files were taken from https://github.com/coreos/coreos-assembler/blob/5824720ec3a9ec291532b23b349b6d8d8b2e9edd/src/grub.cfg 4 | -------------------------------------------------------------------------------- /src/grub2/configs.d/01_users.cfg: -------------------------------------------------------------------------------- 1 | # Keep the comment for grub2-set-password 2 | ### BEGIN /etc/grub.d/01_users ### 3 | if [ -f ${prefix}/user.cfg ]; then 4 | source ${prefix}/user.cfg 5 | if [ -n "${GRUB2_PASSWORD}" ]; then 6 | set superusers="root" 7 | export superusers 8 | password_pbkdf2 root ${GRUB2_PASSWORD} 9 | fi 10 | fi 11 | -------------------------------------------------------------------------------- /src/grub2/configs.d/10_blscfg.cfg: -------------------------------------------------------------------------------- 1 | blscfg 2 | -------------------------------------------------------------------------------- /src/grub2/configs.d/14_menu_show_once.cfg: -------------------------------------------------------------------------------- 1 | # Force the menu to be shown once, with a timeout of ${menu_show_once_timeout} 2 | # if requested by ${menu_show_once_timeout} being set in the env. 3 | if [ "${menu_show_once_timeout}" ]; then 4 | set timeout_style=menu 5 | set timeout="${menu_show_once_timeout}" 6 | unset menu_show_once_timeout 7 | save_env menu_show_once_timeout 8 | fi 9 | -------------------------------------------------------------------------------- /src/grub2/configs.d/30_uefi-firmware.cfg: -------------------------------------------------------------------------------- 1 | if [ "$grub_platform" = "efi" ]; then 2 | menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' { 3 | fwsetup 4 | } 5 | fi 6 | -------------------------------------------------------------------------------- /src/grub2/configs.d/41_custom.cfg: -------------------------------------------------------------------------------- 1 | if [ -f $prefix/custom.cfg ]; then 2 | source $prefix/custom.cfg 3 | fi 4 | -------------------------------------------------------------------------------- /src/grub2/configs.d/README.md: -------------------------------------------------------------------------------- 1 | Add drop-in grub fragments into this directory to have 2 | them be installed into the final config. 3 | 4 | The filenames must end in `.cfg`. 5 | -------------------------------------------------------------------------------- /src/grub2/grub-static-efi.cfg: -------------------------------------------------------------------------------- 1 | if [ -e (md/md-boot) ]; then 2 | # The search command might pick a RAID component rather than the RAID, 3 | # since the /boot RAID currently uses superblock 1.0. See the comment in 4 | # the main grub.cfg. 5 | set prefix=md/md-boot 6 | else 7 | if [ -f ${config_directory}/bootuuid.cfg ]; then 8 | source ${config_directory}/bootuuid.cfg 9 | fi 10 | if [ -n "${BOOT_UUID}" ]; then 11 | search --fs-uuid "${BOOT_UUID}" --set prefix --no-floppy 12 | else 13 | search --label boot --set prefix --no-floppy 14 | fi 15 | fi 16 | if [ -d ($prefix)/grub2 ]; then 17 | set prefix=($prefix)/grub2 18 | configfile $prefix/grub.cfg 19 | else 20 | set prefix=($prefix)/boot/grub2 21 | configfile $prefix/grub.cfg 22 | fi 23 | boot 24 | 25 | -------------------------------------------------------------------------------- /src/grub2/grub-static-pre.cfg: -------------------------------------------------------------------------------- 1 | # This file is copied from https://github.com/coreos/coreos-assembler/blob/0eb25d1c718c88414c0b9aedd19dc56c09afbda8/src/grub.cfg 2 | # Changes: 3 | # - Dropped Ignition glue, that can be injected into platform.cfg 4 | # petitboot doesn't support -e and doesn't support an empty path part 5 | if [ -d (md/md-boot)/grub2 ]; then 6 | # fcct currently creates /boot RAID with superblock 1.0, which allows 7 | # component partitions to be read directly as filesystems. This is 8 | # necessary because transposefs doesn't yet rerun grub2-install on BIOS, 9 | # so GRUB still expects /boot to be a partition on the first disk. 10 | # 11 | # There are two consequences: 12 | # 1. On BIOS and UEFI, the search command might pick an individual RAID 13 | # component, but we want it to use the full RAID in case there are bad 14 | # sectors etc. The undocumented --hint option is supposed to support 15 | # this sort of override, but it doesn't seem to work, so we set $boot 16 | # directly. 17 | # 2. On BIOS, the "normal" module has already been loaded from an 18 | # individual RAID component, and $prefix still points there. We want 19 | # future module loads to come from the RAID, so we reset $prefix. 20 | # (On UEFI, the stub grub.cfg has already set $prefix properly.) 21 | set boot=md/md-boot 22 | set prefix=($boot)/grub2 23 | else 24 | if [ -f ${config_directory}/bootuuid.cfg ]; then 25 | source ${config_directory}/bootuuid.cfg 26 | fi 27 | if [ -n "${BOOT_UUID}" ]; then 28 | search --fs-uuid "${BOOT_UUID}" --set boot --no-floppy 29 | else 30 | search --label boot --set boot --no-floppy 31 | fi 32 | fi 33 | set root=$boot 34 | 35 | if [ -f ${config_directory}/grubenv ]; then 36 | load_env -f ${config_directory}/grubenv 37 | elif [ -s $prefix/grubenv ]; then 38 | load_env 39 | fi 40 | 41 | if [ -f $prefix/console.cfg ]; then 42 | # Source in any GRUB console settings if provided by the user/platform 43 | source $prefix/console.cfg 44 | fi 45 | 46 | menuentry_id_option="--id" 47 | 48 | function load_video { 49 | insmod all_video 50 | } 51 | 52 | set timeout_style=menu 53 | set timeout=1 54 | 55 | # Other package code will be injected from here 56 | -------------------------------------------------------------------------------- /src/grubconfigs.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | use std::os::unix::io::AsRawFd; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::{anyhow, Context, Result}; 6 | use bootc_utils::CommandRunExt; 7 | use fn_error_context::context; 8 | use openat_ext::OpenatDirExt; 9 | 10 | /// The subdirectory of /boot we use 11 | const GRUB2DIR: &str = "grub2"; 12 | const CONFIGDIR: &str = "/usr/lib/bootupd/grub2-static"; 13 | const DROPINDIR: &str = "configs.d"; 14 | const GRUBENV: &str = "grubenv"; 15 | 16 | /// Install the static GRUB config files. 17 | #[context("Installing static GRUB configs")] 18 | pub(crate) fn install( 19 | target_root: &openat::Dir, 20 | installed_efi_vendor: Option<&str>, 21 | write_uuid: bool, 22 | ) -> Result<()> { 23 | let bootdir = &target_root.sub_dir("boot").context("Opening /boot")?; 24 | let boot_is_mount = { 25 | let root_dev = target_root.self_metadata()?.stat().st_dev; 26 | let boot_dev = bootdir.self_metadata()?.stat().st_dev; 27 | log::debug!("root_dev={root_dev} boot_dev={boot_dev}"); 28 | root_dev != boot_dev 29 | }; 30 | 31 | if !bootdir.exists(GRUB2DIR)? { 32 | bootdir.create_dir(GRUB2DIR, 0o700)?; 33 | } 34 | 35 | let mut config = String::from("# Generated by bootupd / do not edit\n\n"); 36 | 37 | let pre = std::fs::read_to_string(Path::new(CONFIGDIR).join("grub-static-pre.cfg"))?; 38 | config.push_str(pre.as_str()); 39 | 40 | let dropindir = openat::Dir::open(&Path::new(CONFIGDIR).join(DROPINDIR))?; 41 | // Sort the files for reproducibility 42 | let mut entries = dropindir 43 | .list_dir(".")? 44 | .map(|e| e.map_err(anyhow::Error::msg)) 45 | .collect::>>()?; 46 | entries.sort_by(|a, b| a.file_name().cmp(b.file_name())); 47 | for ent in entries { 48 | let name = ent.file_name(); 49 | let name = name 50 | .to_str() 51 | .ok_or_else(|| anyhow!("Invalid UTF-8: {name:?}"))?; 52 | if !name.ends_with(".cfg") { 53 | log::debug!("Ignoring {name}"); 54 | continue; 55 | } 56 | writeln!(config, "\n### BEGIN {name} ###")?; 57 | let dropin = std::fs::read_to_string(Path::new(CONFIGDIR).join(DROPINDIR).join(name))?; 58 | config.push_str(dropin.as_str()); 59 | writeln!(config, "### END {name} ###")?; 60 | println!("Added {name}"); 61 | } 62 | 63 | bootdir 64 | .write_file_contents(format!("{GRUB2DIR}/grub.cfg"), 0o644, config.as_bytes()) 65 | .context("Copying grub-static.cfg")?; 66 | println!("Installed: grub.cfg"); 67 | 68 | write_grubenv(&bootdir).context("Create grubenv")?; 69 | 70 | let uuid_path = if write_uuid { 71 | let target_fs = if boot_is_mount { bootdir } else { target_root }; 72 | let bootfs_meta = crate::filesystem::inspect_filesystem(target_fs, ".")?; 73 | let bootfs_uuid = bootfs_meta 74 | .uuid 75 | .ok_or_else(|| anyhow::anyhow!("Failed to find UUID for boot"))?; 76 | let grub2_uuid_contents = format!("set BOOT_UUID=\"{bootfs_uuid}\"\n"); 77 | let uuid_path = format!("{GRUB2DIR}/bootuuid.cfg"); 78 | bootdir 79 | .write_file_contents(&uuid_path, 0o644, grub2_uuid_contents) 80 | .context("Writing bootuuid.cfg")?; 81 | Some(uuid_path) 82 | } else { 83 | None 84 | }; 85 | 86 | if let Some(vendordir) = installed_efi_vendor { 87 | log::debug!("vendordir={:?}", &vendordir); 88 | let vendor = PathBuf::from(vendordir); 89 | let target = &vendor.join("grub.cfg"); 90 | let dest_efidir = target_root 91 | .sub_dir_optional("boot/efi/EFI") 92 | .context("Opening /boot/efi/EFI")?; 93 | if let Some(efidir) = dest_efidir { 94 | efidir 95 | .copy_file(&Path::new(CONFIGDIR).join("grub-static-efi.cfg"), target) 96 | .context("Copying static EFI")?; 97 | println!("Installed: {target:?}"); 98 | if let Some(uuid_path) = uuid_path { 99 | // SAFETY: we always have a filename 100 | let filename = Path::new(&uuid_path).file_name().unwrap(); 101 | let target = &vendor.join(filename); 102 | bootdir 103 | .copy_file_at(uuid_path, &efidir, target) 104 | .context("Writing bootuuid.cfg to efi dir")?; 105 | } 106 | } 107 | } 108 | 109 | Ok(()) 110 | } 111 | 112 | #[context("Create file boot/grub2/grubenv")] 113 | fn write_grubenv(bootdir: &openat::Dir) -> Result<()> { 114 | let grubdir = &bootdir.sub_dir(GRUB2DIR).context("Opening boot/grub2")?; 115 | 116 | if grubdir.exists(GRUBENV)? { 117 | return Ok(()); 118 | } 119 | let editenv = Path::new("/usr/bin/grub2-editenv"); 120 | if !editenv.exists() { 121 | anyhow::bail!("Failed to find {:?}", editenv); 122 | } 123 | 124 | std::process::Command::new(editenv) 125 | .args([GRUBENV, "create"]) 126 | .current_dir(format!("/proc/self/fd/{}", grubdir.as_raw_fd())) 127 | .run_with_cmd_context() 128 | } 129 | 130 | #[cfg(test)] 131 | mod tests { 132 | use super::*; 133 | 134 | #[test] 135 | #[ignore] 136 | fn test_install() -> Result<()> { 137 | env_logger::init(); 138 | let td = tempfile::tempdir()?; 139 | let tdp = td.path(); 140 | let td = openat::Dir::open(tdp)?; 141 | std::fs::create_dir_all(tdp.join("boot/grub2"))?; 142 | std::fs::create_dir_all(tdp.join("boot/efi/EFI/BOOT"))?; 143 | std::fs::create_dir_all(tdp.join("boot/efi/EFI/fedora"))?; 144 | install(&td, Some("fedora"), false).unwrap(); 145 | 146 | assert!(td.exists("boot/grub2/grub.cfg")?); 147 | assert!(td.exists("boot/efi/EFI/fedora/grub.cfg")?); 148 | Ok(()) 149 | } 150 | #[test] 151 | fn test_write_grubenv() -> Result<()> { 152 | let td = tempfile::tempdir()?; 153 | let tdp = td.path(); 154 | std::fs::create_dir_all(tdp.join("boot/grub2"))?; 155 | let td = openat::Dir::open(&tdp.join("boot"))?; 156 | write_grubenv(&td)?; 157 | 158 | assert!(td.exists("grub2/grubenv")?); 159 | Ok(()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | **Boot**loader **upd**ater. 3 | 4 | This is an early prototype hidden/not-yet-standardized mechanism 5 | which just updates EFI for now (x86_64/aarch64/riscv64 only). 6 | 7 | But in the future will hopefully gain some independence from 8 | ostree and also support e.g. updating the MBR etc. 9 | 10 | Refs: 11 | * 12 | !*/ 13 | 14 | #![deny(unused_must_use)] 15 | // The style lints are more annoying than useful 16 | #![allow(clippy::style)] 17 | #![deny(clippy::dbg_macro)] 18 | 19 | mod backend; 20 | #[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))] 21 | mod bios; 22 | mod blockdev; 23 | mod bootupd; 24 | mod cli; 25 | mod component; 26 | mod coreos; 27 | #[cfg(any( 28 | target_arch = "x86_64", 29 | target_arch = "aarch64", 30 | target_arch = "riscv64" 31 | ))] 32 | mod efi; 33 | mod failpoints; 34 | mod filesystem; 35 | mod filetree; 36 | #[cfg(any( 37 | target_arch = "x86_64", 38 | target_arch = "aarch64", 39 | target_arch = "powerpc64", 40 | target_arch = "riscv64" 41 | ))] 42 | mod grubconfigs; 43 | mod model; 44 | mod model_legacy; 45 | mod ostreeutil; 46 | mod packagesystem; 47 | mod sha512string; 48 | mod util; 49 | 50 | use clap::crate_name; 51 | 52 | /// Binary entrypoint, for both daemon and client logic. 53 | fn main() { 54 | let _scenario = fail::FailScenario::setup(); 55 | let exit_code = run_cli(); 56 | std::process::exit(exit_code); 57 | } 58 | 59 | /// CLI logic. 60 | fn run_cli() -> i32 { 61 | // Parse command-line options. 62 | let args: Vec<_> = std::env::args().collect(); 63 | let cli_opts = cli::MultiCall::from_args(args); 64 | 65 | // Setup logging. 66 | env_logger::Builder::from_default_env() 67 | .format_timestamp(None) 68 | .format_module_path(false) 69 | .filter(Some(crate_name!()), cli_opts.loglevel()) 70 | .init(); 71 | 72 | log::trace!("executing cli"); 73 | 74 | // Dispatch CLI subcommand. 75 | match cli_opts.run() { 76 | Ok(_) => libc::EXIT_SUCCESS, 77 | Err(e) => { 78 | // Use the alternative formatter to get everything on a single line... it reads better. 79 | eprintln!("error: {:#}", e); 80 | libc::EXIT_FAILURE 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Red Hat, Inc. 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | use chrono::prelude::*; 8 | use serde::{Deserialize, Serialize}; 9 | use std::collections::BTreeMap; 10 | 11 | /// The directory where updates are stored 12 | pub(crate) const BOOTUPD_UPDATES_DIR: &str = "usr/lib/bootupd/updates"; 13 | 14 | #[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)] 15 | #[serde(rename_all = "kebab-case")] 16 | pub(crate) struct ContentMetadata { 17 | /// The timestamp, which is used to determine update availability 18 | pub(crate) timestamp: DateTime, 19 | /// Human readable version number, like ostree it is not ever parsed, just displayed 20 | pub(crate) version: String, 21 | } 22 | 23 | impl ContentMetadata { 24 | /// Returns `true` if `target` is different and chronologically newer 25 | pub(crate) fn can_upgrade_to(&self, target: &Self) -> bool { 26 | if self.version == target.version { 27 | return false; 28 | } 29 | target.timestamp > self.timestamp 30 | } 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Clone, Debug)] 34 | #[serde(rename_all = "kebab-case")] 35 | pub(crate) struct InstalledContent { 36 | /// Associated metadata 37 | pub(crate) meta: ContentMetadata, 38 | /// Human readable version number, like ostree it is not ever parsed, just displayed 39 | pub(crate) filetree: Option, 40 | /// The version this was originally adopted from 41 | pub(crate) adopted_from: Option, 42 | } 43 | 44 | /// Will be serialized into /boot/bootupd-state.json 45 | #[derive(Serialize, Deserialize, Default, Debug)] 46 | #[serde(rename_all = "kebab-case")] 47 | #[serde(deny_unknown_fields)] 48 | pub(crate) struct SavedState { 49 | /// Maps a component name to its currently installed version 50 | pub(crate) installed: BTreeMap, 51 | /// Maps a component name to an in progress update 52 | pub(crate) pending: Option>, 53 | /// If static bootloader configs are enabled, this contains the version 54 | pub(crate) static_configs: Option, 55 | } 56 | 57 | /// The status of an individual component. 58 | #[derive(Serialize, Deserialize, Debug)] 59 | #[serde(rename_all = "kebab-case")] 60 | pub(crate) enum ComponentUpdatable { 61 | NoUpdateAvailable, 62 | AtLatestVersion, 63 | Upgradable, 64 | WouldDowngrade, 65 | } 66 | 67 | impl ComponentUpdatable { 68 | pub(crate) fn from_metadata(from: &ContentMetadata, to: Option<&ContentMetadata>) -> Self { 69 | match to { 70 | Some(to) => { 71 | if from.version == to.version { 72 | ComponentUpdatable::AtLatestVersion 73 | } else if from.can_upgrade_to(to) { 74 | ComponentUpdatable::Upgradable 75 | } else { 76 | ComponentUpdatable::WouldDowngrade 77 | } 78 | } 79 | None => ComponentUpdatable::NoUpdateAvailable, 80 | } 81 | } 82 | } 83 | 84 | /// The status of an individual component. 85 | #[derive(Serialize, Deserialize, Debug)] 86 | #[serde(rename_all = "kebab-case")] 87 | pub(crate) struct ComponentStatus { 88 | /// Currently installed version 89 | pub(crate) installed: ContentMetadata, 90 | /// In progress update that was interrupted 91 | pub(crate) interrupted: Option, 92 | /// Update in the deployed filesystem tree 93 | pub(crate) update: Option, 94 | /// Is true if the version in `update` is different from `installed` 95 | pub(crate) updatable: ComponentUpdatable, 96 | /// Originally adopted version 97 | pub(crate) adopted_from: Option, 98 | } 99 | 100 | /// Information on a component that can be adopted 101 | #[derive(Serialize, Deserialize, Debug)] 102 | #[serde(rename_all = "kebab-case")] 103 | pub(crate) struct Adoptable { 104 | /// A synthetic version 105 | pub(crate) version: ContentMetadata, 106 | /// True if we are likely to be able to reliably update this system 107 | pub(crate) confident: bool, 108 | } 109 | 110 | /// Representation of bootupd's worldview at a point in time. 111 | /// This is intended to be a stable format that is output by `bootupctl status --json` 112 | /// and parsed by higher level management tools. Transitively then 113 | /// everything referenced from here should also be stable. 114 | #[derive(Serialize, Deserialize, Default, Debug)] 115 | #[serde(rename_all = "kebab-case")] 116 | #[serde(deny_unknown_fields)] 117 | pub(crate) struct Status { 118 | /// Maps a component name to status 119 | pub(crate) components: BTreeMap, 120 | /// Components that appear to be installed, not via bootupd 121 | pub(crate) adoptable: BTreeMap, 122 | } 123 | 124 | #[cfg(test)] 125 | mod test { 126 | use super::*; 127 | use anyhow::Result; 128 | use chrono::Duration; 129 | 130 | #[test] 131 | fn test_meta_compare() { 132 | let t = Utc::now(); 133 | let a = ContentMetadata { 134 | timestamp: t, 135 | version: "v1".into(), 136 | }; 137 | let b = ContentMetadata { 138 | timestamp: t + Duration::try_seconds(1).unwrap(), 139 | version: "v2".into(), 140 | }; 141 | assert!(a.can_upgrade_to(&b)); 142 | assert!(!b.can_upgrade_to(&a)); 143 | } 144 | 145 | /// Validate we're not breaking the serialized format of /boot/bootupd-state.json 146 | #[test] 147 | fn test_deserialize_state() -> Result<()> { 148 | let data = include_str!("../tests/fixtures/example-state-v0.json"); 149 | let state: SavedState = serde_json::from_str(data)?; 150 | let efi = state.installed.get("EFI").expect("EFI"); 151 | assert_eq!( 152 | efi.meta.version, 153 | "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 154 | ); 155 | Ok(()) 156 | } 157 | 158 | /// Validate we're not breaking the serialized format of `bootupctl status --json` 159 | #[test] 160 | fn test_deserialize_status() -> Result<()> { 161 | let data = include_str!("../tests/fixtures/example-status-v0.json"); 162 | let status: Status = serde_json::from_str(data)?; 163 | let efi = status.components.get("EFI").expect("EFI"); 164 | assert_eq!( 165 | efi.installed.version, 166 | "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 167 | ); 168 | Ok(()) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/model_legacy.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Red Hat, Inc. 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | //! Implementation of the original bootupd data format, which is the same 8 | //! as the current one except that the date is defined to be in UTC. 9 | 10 | use crate::model::ContentMetadata as NewContentMetadata; 11 | use crate::model::InstalledContent as NewInstalledContent; 12 | use crate::model::SavedState as NewSavedState; 13 | use chrono::prelude::*; 14 | use serde::{Deserialize, Serialize}; 15 | use std::collections::BTreeMap; 16 | 17 | #[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)] 18 | #[serde(rename_all = "kebab-case")] 19 | pub(crate) struct ContentMetadata01 { 20 | /// The timestamp, which is used to determine update availability 21 | pub(crate) timestamp: NaiveDateTime, 22 | /// Human readable version number, like ostree it is not ever parsed, just displayed 23 | pub(crate) version: String, 24 | } 25 | 26 | #[derive(Serialize, Deserialize, Clone, Debug)] 27 | #[serde(rename_all = "kebab-case")] 28 | pub(crate) struct InstalledContent01 { 29 | /// Associated metadata 30 | pub(crate) meta: ContentMetadata01, 31 | /// File tree 32 | pub(crate) filetree: Option, 33 | } 34 | 35 | /// Will be serialized into /boot/bootupd-state.json 36 | #[derive(Serialize, Deserialize, Default, Debug)] 37 | #[serde(rename_all = "kebab-case")] 38 | #[serde(deny_unknown_fields)] 39 | pub(crate) struct SavedState01 { 40 | /// Maps a component name to its currently installed version 41 | pub(crate) installed: BTreeMap, 42 | /// Maps a component name to an in progress update 43 | pub(crate) pending: Option>, 44 | } 45 | 46 | impl ContentMetadata01 { 47 | pub(crate) fn upconvert(self) -> NewContentMetadata { 48 | let timestamp = self.timestamp.and_utc(); 49 | NewContentMetadata { 50 | timestamp, 51 | version: self.version, 52 | } 53 | } 54 | } 55 | 56 | impl InstalledContent01 { 57 | pub(crate) fn upconvert(self) -> NewInstalledContent { 58 | NewInstalledContent { 59 | meta: self.meta.upconvert(), 60 | filetree: self.filetree, 61 | adopted_from: None, 62 | } 63 | } 64 | } 65 | 66 | impl SavedState01 { 67 | pub(crate) fn upconvert(self) -> NewSavedState { 68 | let mut r: NewSavedState = Default::default(); 69 | for (k, v) in self.installed { 70 | r.installed.insert(k, v.upconvert()); 71 | } 72 | r 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod test { 78 | use super::*; 79 | use anyhow::Result; 80 | 81 | /// Validate we're not breaking the serialized format of `bootupctl status --json` 82 | #[test] 83 | fn test_deserialize_status() -> Result<()> { 84 | let data = include_str!("../tests/fixtures/example-state-v0-legacy.json"); 85 | let state: SavedState01 = serde_json::from_str(data)?; 86 | let efi = state.installed.get("EFI").expect("EFI"); 87 | assert_eq!( 88 | efi.meta.version, 89 | "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 90 | ); 91 | let state: NewSavedState = state.upconvert(); 92 | let efi = state.installed.get("EFI").expect("EFI"); 93 | let t = chrono::DateTime::parse_from_rfc3339("2020-09-15T13:01:21Z")?; 94 | assert_eq!(t, efi.meta.timestamp); 95 | assert_eq!( 96 | efi.meta.version, 97 | "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 98 | ); 99 | Ok(()) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ostreeutil.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Red Hat, Inc. 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | use std::path::Path; 8 | 9 | use anyhow::Result; 10 | use log::debug; 11 | 12 | /// https://github.com/coreos/rpm-ostree/pull/969/commits/dc0e8db5bd92e1f478a0763d1a02b48e57022b59 13 | #[cfg(any( 14 | target_arch = "x86_64", 15 | target_arch = "aarch64", 16 | target_arch = "riscv64" 17 | ))] 18 | pub(crate) const BOOT_PREFIX: &str = "usr/lib/ostree-boot"; 19 | const LEGACY_RPMOSTREE_DBPATH: &str = "usr/share/rpm"; 20 | const SYSIMAGE_RPM_DBPATH: &str = "usr/lib/sysimage/rpm"; 21 | 22 | /// Returns true if the target directory contains at least one file that does 23 | /// not start with `.` 24 | fn is_nonempty_dir(path: impl AsRef) -> Result { 25 | let path = path.as_ref(); 26 | let it = match std::fs::read_dir(path) { 27 | Ok(r) => r, 28 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), 29 | Err(e) => return Err(e.into()), 30 | }; 31 | for ent in it { 32 | let ent = ent?; 33 | let name = ent.file_name(); 34 | if name.as_encoded_bytes().starts_with(b".") { 35 | continue; 36 | } 37 | return Ok(true); 38 | } 39 | Ok(false) 40 | } 41 | 42 | pub(crate) fn rpm_cmd>(sysroot: P) -> Result { 43 | let mut c = std::process::Command::new("rpm"); 44 | let sysroot = sysroot.as_ref(); 45 | // Take the first non-empty database path 46 | let mut arg = None; 47 | for dbpath in [SYSIMAGE_RPM_DBPATH, LEGACY_RPMOSTREE_DBPATH] { 48 | let dbpath = sysroot.join(dbpath); 49 | if !is_nonempty_dir(&dbpath)? { 50 | continue; 51 | } 52 | let mut s = std::ffi::OsString::new(); 53 | s.push("--dbpath="); 54 | s.push(dbpath.as_os_str()); 55 | arg = Some(s); 56 | break; 57 | } 58 | if let Some(arg) = arg { 59 | debug!("Using dbpath {arg:?}"); 60 | c.arg(arg); 61 | } else { 62 | debug!("Failed to find dbpath"); 63 | } 64 | Ok(c) 65 | } 66 | -------------------------------------------------------------------------------- /src/packagesystem.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | use std::io::Write; 3 | use std::path::Path; 4 | 5 | use anyhow::{bail, Context, Result}; 6 | use chrono::prelude::*; 7 | 8 | use crate::model::*; 9 | use crate::ostreeutil; 10 | 11 | /// Parse the output of `rpm -q` 12 | fn rpm_parse_metadata(stdout: &[u8]) -> Result { 13 | let pkgs = std::str::from_utf8(stdout)? 14 | .split_whitespace() 15 | .map(|s| -> Result<_> { 16 | let parts: Vec<_> = s.splitn(2, ',').collect(); 17 | let name = parts[0]; 18 | if let Some(ts) = parts.get(1) { 19 | let nt = DateTime::parse_from_str(ts, "%s") 20 | .context("Failed to parse rpm buildtime")? 21 | .with_timezone(&chrono::Utc); 22 | Ok((name, nt)) 23 | } else { 24 | bail!("Failed to parse: {}", s); 25 | } 26 | }) 27 | .collect::>>>()?; 28 | if pkgs.is_empty() { 29 | bail!("Failed to find any RPM packages matching files in source efidir"); 30 | } 31 | let timestamps: BTreeSet<&DateTime> = pkgs.values().collect(); 32 | // Unwrap safety: We validated pkgs has at least one value above 33 | let largest_timestamp = timestamps.iter().last().unwrap(); 34 | let version = pkgs.keys().fold("".to_string(), |mut s, n| { 35 | if !s.is_empty() { 36 | s.push(','); 37 | } 38 | s.push_str(n); 39 | s 40 | }); 41 | Ok(ContentMetadata { 42 | timestamp: **largest_timestamp, 43 | version, 44 | }) 45 | } 46 | 47 | /// Query the rpm database and list the package and build times. 48 | pub(crate) fn query_files( 49 | sysroot_path: &str, 50 | paths: impl IntoIterator, 51 | ) -> Result 52 | where 53 | T: AsRef, 54 | { 55 | let mut c = ostreeutil::rpm_cmd(sysroot_path)?; 56 | c.args(["-q", "--queryformat", "%{nevra},%{buildtime} ", "-f"]); 57 | for arg in paths { 58 | c.arg(arg.as_ref()); 59 | } 60 | 61 | let rpmout = c.output()?; 62 | if !rpmout.status.success() { 63 | std::io::stderr().write_all(&rpmout.stderr)?; 64 | bail!("Failed to invoke rpm -qf"); 65 | } 66 | 67 | rpm_parse_metadata(&rpmout.stdout) 68 | } 69 | 70 | #[test] 71 | fn test_parse_rpmout() { 72 | 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"; 73 | let parsed = rpm_parse_metadata(testdata.as_bytes()).unwrap(); 74 | assert_eq!( 75 | parsed.version, 76 | "grub2-efi-x64-1:2.06-95.fc38.x86_64,shim-x64-15.6-2.x86_64" 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/sha512string.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Red Hat, Inc. 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | use openssl::hash::Hasher; 8 | use serde::{Deserialize, Serialize}; 9 | use std::fmt; 10 | 11 | #[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)] 12 | pub(crate) struct SHA512String(pub(crate) String); 13 | 14 | impl fmt::Display for SHA512String { 15 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 16 | write!(f, "{}", self.0) 17 | } 18 | } 19 | 20 | impl SHA512String { 21 | #[allow(dead_code)] 22 | pub(crate) fn from_hasher(hasher: &mut Hasher) -> Self { 23 | Self(format!( 24 | "sha512:{}", 25 | hex::encode(hasher.finish().expect("completing hash")) 26 | )) 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod test { 32 | use super::*; 33 | use anyhow::Result; 34 | 35 | #[test] 36 | fn test_empty() -> Result<()> { 37 | let mut h = Hasher::new(openssl::hash::MessageDigest::sha512())?; 38 | let s = SHA512String::from_hasher(&mut h); 39 | assert_eq!("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", format!("{}", s)); 40 | Ok(()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::path::Path; 3 | use std::process::Command; 4 | 5 | use anyhow::{bail, Context, Result}; 6 | use openat_ext::OpenatDirExt; 7 | 8 | pub(crate) trait CommandRunExt { 9 | fn run(&mut self) -> Result<()>; 10 | } 11 | 12 | impl CommandRunExt for Command { 13 | fn run(&mut self) -> Result<()> { 14 | let r = self.status()?; 15 | if !r.success() { 16 | bail!("Child [{:?}] exited: {}", self, r); 17 | } 18 | Ok(()) 19 | } 20 | } 21 | 22 | /// Parse an environment variable as UTF-8 23 | #[allow(dead_code)] 24 | pub(crate) fn getenv_utf8(n: &str) -> Result> { 25 | if let Some(v) = std::env::var_os(n) { 26 | Ok(Some( 27 | v.to_str() 28 | .ok_or_else(|| anyhow::anyhow!("{} is invalid UTF-8", n))? 29 | .to_string(), 30 | )) 31 | } else { 32 | Ok(None) 33 | } 34 | } 35 | 36 | pub(crate) fn filenames(dir: &openat::Dir) -> Result> { 37 | let mut ret = HashSet::new(); 38 | for entry in dir.list_dir(".")? { 39 | let entry = entry?; 40 | let Some(name) = entry.file_name().to_str() else { 41 | bail!("Invalid UTF-8 filename: {:?}", entry.file_name()) 42 | }; 43 | match dir.get_file_type(&entry)? { 44 | openat::SimpleType::File => { 45 | ret.insert(format!("/{name}")); 46 | } 47 | openat::SimpleType::Dir => { 48 | let child = dir.sub_dir(name)?; 49 | for mut k in filenames(&child)?.drain() { 50 | k.reserve(name.len() + 1); 51 | k.insert_str(0, name); 52 | k.insert(0, '/'); 53 | ret.insert(k); 54 | } 55 | } 56 | openat::SimpleType::Symlink => { 57 | bail!("Unsupported symbolic link {:?}", entry.file_name()) 58 | } 59 | openat::SimpleType::Other => { 60 | bail!("Unsupported non-file/directory {:?}", entry.file_name()) 61 | } 62 | } 63 | } 64 | Ok(ret) 65 | } 66 | 67 | pub(crate) fn ensure_writable_mount>(p: P) -> Result<()> { 68 | let p = p.as_ref(); 69 | let stat = rustix::fs::statvfs(p)?; 70 | if !stat.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) { 71 | return Ok(()); 72 | } 73 | let status = std::process::Command::new("mount") 74 | .args(["-o", "remount,rw"]) 75 | .arg(p) 76 | .status()?; 77 | if !status.success() { 78 | anyhow::bail!("Failed to remount {:?} writable", p); 79 | } 80 | Ok(()) 81 | } 82 | 83 | /// Runs the provided Command object, captures its stdout, and swallows its stderr except on 84 | /// failure. Returns a Result describing whether the command failed, and if not, its 85 | /// standard output. Output is assumed to be UTF-8. Errors are adequately prefixed with the full 86 | /// command. 87 | #[allow(dead_code)] 88 | pub(crate) fn cmd_output(cmd: &mut Command) -> Result { 89 | let result = cmd 90 | .output() 91 | .with_context(|| format!("running {:#?}", cmd))?; 92 | if !result.status.success() { 93 | eprintln!("{}", String::from_utf8_lossy(&result.stderr)); 94 | bail!("{:#?} failed with {}", cmd, result.status); 95 | } 96 | String::from_utf8(result.stdout) 97 | .with_context(|| format!("decoding as UTF-8 output of `{:#?}`", cmd)) 98 | } 99 | 100 | /// Copy from https://github.com/containers/bootc/blob/main/ostree-ext/src/container_utils.rs#L20 101 | /// Attempts to detect if the current process is running inside a container. 102 | /// This looks for the `container` environment variable or the presence 103 | /// of Docker or podman's more generic `/run/.containerenv`. 104 | /// This is a best-effort function, as there is not a 100% reliable way 105 | /// to determine this. 106 | pub fn running_in_container() -> bool { 107 | if std::env::var_os("container").is_some() { 108 | return true; 109 | } 110 | // https://stackoverflow.com/questions/20010199/how-to-determine-if-a-process-runs-inside-lxc-docker 111 | for p in ["/run/.containerenv", "/.dockerenv"] { 112 | if Path::new(p).exists() { 113 | return true; 114 | } 115 | } 116 | false 117 | } 118 | -------------------------------------------------------------------------------- /systemd/bootloader-update.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Update bootloader on boot 3 | Documentation=https://github.com/coreos/bootupd 4 | 5 | [Service] 6 | Type=oneshot 7 | ExecStart=/usr/bin/bootupctl update 8 | RemainAfterExit=yes 9 | # Keep this stuff in sync with SYSTEMD_ARGS_BOOTUPD in general 10 | PrivateNetwork=yes 11 | ProtectHome=yes 12 | KillMode=mixed 13 | MountFlags=slave 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /tests/e2e-update/e2e-update-in-vm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run inside the vm spawned from e2e.sh 3 | set -euo pipefail 4 | 5 | dn=$(cd $(dirname $0) && pwd) 6 | bn=$(basename $0) 7 | . ${dn}/../kola/data/libtest.sh 8 | 9 | cd $(mktemp -d) 10 | 11 | echo "Starting $0" 12 | 13 | current_commit=$(rpm-ostree status --json | jq -r .deployments[0].checksum) 14 | 15 | stampfile=/etc/${bn}.upgraded 16 | if ! test -f ${stampfile}; then 17 | if test "${current_commit}" = ${TARGET_COMMIT}; then 18 | fatal "already at ${TARGET_COMMIT}" 19 | fi 20 | 21 | current_grub=$(rpm -q --queryformat='%{nevra}\n' ${TARGET_GRUB_NAME}) 22 | if test "${current_grub}" == "${TARGET_GRUB_PKG}"; then 23 | fatal "Current grub ${current_grub} is same as target ${TARGET_GRUB_PKG}" 24 | fi 25 | 26 | # FIXME 27 | # https://github.com/coreos/rpm-ostree/issues/2210 28 | runv setenforce 0 29 | runv rpm-ostree rebase /run/cosadir/tmp/repo:${TARGET_COMMIT} 30 | runv touch ${stampfile} 31 | runv systemd-run -- systemctl reboot 32 | touch /run/rebooting 33 | sleep infinity 34 | else 35 | if test "${current_commit}" != ${TARGET_COMMIT}; then 36 | fatal "not at ${TARGET_COMMIT}" 37 | fi 38 | fi 39 | 40 | # We did setenforce 0 above for https://github.com/coreos/rpm-ostree/issues/2210 41 | # Validate that on reboot we're still enforcing. 42 | semode=$(getenforce) 43 | if test "$semode" != Enforcing; then 44 | fatal "SELinux mode is ${semode}" 45 | fi 46 | 47 | if ! test -n "${TARGET_GRUB_PKG}"; then 48 | fatal "Missing TARGET_GRUB_PKG" 49 | fi 50 | 51 | bootupctl validate 52 | ok validate 53 | 54 | bootupctl status | tee out.txt 55 | assert_file_has_content_literal out.txt 'Component EFI' 56 | assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' 57 | assert_not_file_has_content out.txt ' Installed:.*test-bootupd-payload' 58 | assert_not_file_has_content out.txt ' Installed:.*'"${TARGET_GRUB_PKG}" 59 | assert_file_has_content out.txt 'Update: Available:.*'"${TARGET_GRUB_PKG}" 60 | assert_file_has_content out.txt 'Update: Available:.*test-bootupd-payload-1.0' 61 | bootupctl status --print-if-available > out.txt 62 | assert_file_has_content_literal 'out.txt' 'Updates available: BIOS EFI' 63 | ok update avail 64 | 65 | # Mount the EFI partition. 66 | tmpefimount=$(mount_tmp_efi) 67 | 68 | assert_not_has_file ${tmpefimount}/EFI/fedora/test-bootupd.efi 69 | 70 | if env FAILPOINTS='update::exchange=return' bootupctl update -vvv 2>err.txt; then 71 | fatal "should have errored" 72 | fi 73 | assert_file_has_content err.txt "error: .*synthetic failpoint" 74 | 75 | bootupctl update -vvv | tee out.txt 76 | assert_file_has_content out.txt "Previous EFI: .*" 77 | assert_file_has_content out.txt "Updated EFI: ${TARGET_GRUB_PKG}.*,test-bootupd-payload-1.0" 78 | 79 | assert_file_has_content ${tmpefimount}/EFI/fedora/test-bootupd.efi test-payload 80 | 81 | bootupctl status --print-if-available > out.txt 82 | if test -s out.txt; then 83 | fatal "Found available updates: $(cat out.txt)" 84 | fi 85 | ok update not avail 86 | 87 | mount -o remount,rw /boot 88 | rm -f /boot/bootupd-state.json 89 | bootupctl adopt-and-update | tee out.txt 90 | assert_file_has_content out.txt "Adopted and updated: BIOS: .*" 91 | assert_file_has_content out.txt "Adopted and updated: EFI: .*" 92 | bootupctl validate 93 | ok adopt-and-update 94 | 95 | # Verify the adoption does not fail when install files if they are missing on the disk. 96 | # see https://github.com/coreos/bootupd/issues/762 97 | rm -f /boot/bootupd-state.json 98 | [ -f "${tmpefimount}/EFI/fedora/test-bootupd.efi" ] && rm -f ${tmpefimount}/EFI/fedora/test-bootupd.efi 99 | bootupctl adopt-and-update | tee out.txt 100 | assert_file_has_content out.txt "Adopted and updated: BIOS: .*" 101 | assert_file_has_content out.txt "Adopted and updated: EFI: .*" 102 | if bootupctl validate 2>err.txt; then 103 | fatal "unexpectedly passed validation" 104 | fi 105 | 106 | tap_finish 107 | touch /run/testtmp/success 108 | sync 109 | # TODO maybe try to make this use more of the exttest infrastructure? 110 | exec poweroff -ff 111 | -------------------------------------------------------------------------------- /tests/e2e-update/e2e-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Given a coreos-assembler dir (COSA_DIR) and assuming 3 | # the current dir is a git repository for bootupd, 4 | # synthesize a test update and upgrade to it. This 5 | # assumes that the latest cosa build is using the 6 | # code we want to test (as happens in CI). 7 | set -euo pipefail 8 | 9 | dn=$(cd $(dirname $0) && pwd) 10 | testprefix=$(cd ${dn} && git rev-parse --show-prefix) 11 | . ${dn}/../kola/data/libtest.sh 12 | . ${dn}/testrpmbuild.sh 13 | 14 | if test -z "${COSA_DIR:-}"; then 15 | fatal "COSA_DIR must be set" 16 | fi 17 | # Validate source directory 18 | bootupd_git=$(cd ${dn} && git rev-parse --show-toplevel) 19 | # https://github.com/coreos/bootupd/issues/551 20 | ! test -f ${bootupd_git}/systemd/bootupd.service 21 | 22 | testtmp=$(mktemp -d -p /var/tmp bootupd-e2e.XXXXXXX) 23 | export test_tmpdir=${testtmp} 24 | 25 | # This is new content for our update 26 | test_bootupd_payload_file=/boot/efi/EFI/fedora/test-bootupd.efi 27 | test_bootupd_payload_file1=/boot/efi/EFI/BOOT/test-bootupd1.efi 28 | build_rpm test-bootupd-payload \ 29 | files "${test_bootupd_payload_file} 30 | ${test_bootupd_payload_file1}" \ 31 | install "mkdir -p %{buildroot}/$(dirname ${test_bootupd_payload_file}) 32 | echo test-payload > %{buildroot}/${test_bootupd_payload_file} 33 | mkdir -p %{buildroot}/$(dirname ${test_bootupd_payload_file1}) 34 | echo test-payload1 > %{buildroot}/${test_bootupd_payload_file1}" 35 | 36 | # Start in cosa dir 37 | cd ${COSA_DIR} 38 | test -d builds 39 | 40 | overrides=${COSA_DIR}/overrides 41 | test -d "${overrides}" 42 | mkdir -p ${overrides}/rpm 43 | add_override() { 44 | override=$1 45 | shift 46 | # This relies on "gold" grub not being pruned, and different from what's 47 | # in the latest fcos 48 | (cd ${overrides}/rpm && runv koji download-build --arch=noarch --arch=$(arch) ${override}) 49 | } 50 | 51 | if test -z "${e2e_skip_build:-}"; then 52 | echo "Building starting image" 53 | rm -f ${overrides}/rpm/*.rpm 54 | # Version from F42 prior to GA 55 | add_override grub2-2.12-26.fc42 56 | runv cosa build 57 | prev_image=$(runv cosa meta --image-path qemu) 58 | # Modify manifest to include `test-bootupd-payload` RPM 59 | runv git -C src/config checkout manifest.yaml # first make sure it's clean 60 | echo "packages: [test-bootupd-payload]" >> src/config/manifest.yaml 61 | rm -f ${overrides}/rpm/*.rpm 62 | echo "Building update ostree" 63 | # Latest (current) version in F42 64 | add_override grub2-2.12-28.fc42 65 | mv ${test_tmpdir}/yumrepo/packages/$(arch)/*.rpm ${overrides}/rpm/ 66 | # Only build ostree update 67 | runv cosa build ostree 68 | # Undo manifest modification 69 | runv git -C src/config checkout manifest.yaml 70 | fi 71 | echo "Preparing test" 72 | grubarch= 73 | case $(arch) in 74 | x86_64) grubarch=x64;; 75 | aarch64) grubarch=aa64;; 76 | *) fatal "Unhandled arch $(arch)";; 77 | esac 78 | target_grub_name=grub2-efi-${grubarch} 79 | target_grub_pkg=$(rpm -qp --queryformat='%{nevra}\n' ${overrides}/rpm/${target_grub_name}-2*.rpm) 80 | target_commit=$(cosa meta --get-value ostree-commit) 81 | echo "Target commit: ${target_commit}" 82 | # For some reason 9p can't write to tmpfs 83 | 84 | cat >${testtmp}/test.bu << EOF 85 | variant: fcos 86 | version: 1.0.0 87 | systemd: 88 | units: 89 | - name: bootupd-test.service 90 | enabled: true 91 | contents: | 92 | [Unit] 93 | RequiresMountsFor=/run/testtmp 94 | [Service] 95 | Type=oneshot 96 | RemainAfterExit=yes 97 | Environment=TARGET_COMMIT=${target_commit} 98 | Environment=TARGET_GRUB_NAME=${target_grub_name} 99 | Environment=TARGET_GRUB_PKG=${target_grub_pkg} 100 | Environment=SRCDIR=/run/bootupd-source 101 | # Run via shell because selinux denies systemd writing to 9p apparently 102 | ExecStart=/bin/sh -c '/run/bootupd-source/${testprefix}/e2e-update-in-vm.sh &>>/run/testtmp/out.txt; test -f /run/rebooting || poweroff -ff' 103 | [Install] 104 | WantedBy=multi-user.target 105 | EOF 106 | runv butane -o ${testtmp}/test.ign ${testtmp}/test.bu 107 | cd ${testtmp} 108 | qemuexec_args=(kola qemuexec --propagate-initramfs-failure --qemu-image "${prev_image}" --qemu-firmware uefi \ 109 | -i test.ign --bind-ro ${COSA_DIR},/run/cosadir --bind-ro ${bootupd_git},/run/bootupd-source --bind-rw ${testtmp},/run/testtmp) 110 | if test -n "${e2e_debug:-}"; then 111 | runv ${qemuexec_args[@]} --devshell 112 | else 113 | runv timeout 5m "${qemuexec_args[@]}" --console-to-file ${COSA_DIR}/tmp/console.txt 114 | fi 115 | if ! test -f ${testtmp}/success; then 116 | if test -s ${testtmp}/out.txt; then 117 | sed -e 's,^,# ,' < ${testtmp}/out.txt 118 | else 119 | echo "No out.txt created, systemd unit failed to start" 120 | fi 121 | fatal "test failed" 122 | fi 123 | echo "ok bootupd e2e" 124 | -------------------------------------------------------------------------------- /tests/e2e-update/testrpmbuild.sh: -------------------------------------------------------------------------------- 1 | # Copied from rpm-ostree 2 | 3 | # builds a new RPM and adds it to the testdir's repo 4 | # $1 - name 5 | # $2+ - optional, treated as directive/value pairs 6 | build_rpm() { 7 | local name=$1; shift 8 | # Unset, not zero https://github.com/projectatomic/rpm-ostree/issues/349 9 | local epoch="" 10 | local version=1.0 11 | local release=1 12 | local arch=x86_64 13 | 14 | mkdir -p $test_tmpdir/yumrepo/{specs,packages} 15 | local spec=$test_tmpdir/yumrepo/specs/$name.spec 16 | 17 | # write out the header 18 | cat > $spec << EOF 19 | Name: $name 20 | Summary: %{name} 21 | License: GPLv2+ 22 | EOF 23 | 24 | local build= install= files= pretrans= pre= post= posttrans= post_args= 25 | local verifyscript= uinfo= 26 | local transfiletriggerin= transfiletriggerin_patterns= 27 | local transfiletriggerin2= transfiletriggerin2_patterns= 28 | local transfiletriggerun= transfiletriggerun_patterns= 29 | while [ $# -ne 0 ]; do 30 | local section=$1; shift 31 | local arg=$1; shift 32 | case $section in 33 | requires) 34 | echo "Requires: $arg" >> $spec;; 35 | recommends) 36 | echo "Recommends: $arg" >> $spec;; 37 | provides) 38 | echo "Provides: $arg" >> $spec;; 39 | conflicts) 40 | echo "Conflicts: $arg" >> $spec;; 41 | post_args) 42 | post_args="$arg";; 43 | version|release|epoch|arch|build|install|files|pretrans|pre|post|posttrans|verifyscript|uinfo) 44 | declare $section="$arg";; 45 | transfiletriggerin) 46 | transfiletriggerin_patterns="$arg"; 47 | declare $section="$1"; shift;; 48 | transfiletriggerin2) 49 | transfiletriggerin2_patterns="$arg"; 50 | declare $section="$1"; shift;; 51 | transfiletriggerun) 52 | transfiletriggerun_patterns="$arg"; 53 | declare $section="$1"; shift;; 54 | *) 55 | assert_not_reached "unhandled section $section";; 56 | esac 57 | done 58 | 59 | cat >> $spec << EOF 60 | Version: $version 61 | Release: $release 62 | ${epoch:+Epoch: $epoch} 63 | BuildArch: $arch 64 | 65 | %description 66 | %{summary} 67 | 68 | # by default, we create a /usr/bin/$name script which just outputs $name 69 | %build 70 | echo -e "#!/bin/sh\necho $name-$version-$release.$arch" > $name 71 | chmod a+x $name 72 | $build 73 | 74 | ${pretrans:+%pretrans} 75 | $pretrans 76 | 77 | ${pre:+%pre} 78 | $pre 79 | 80 | ${post:+%post} ${post_args} 81 | $post 82 | 83 | ${posttrans:+%posttrans} 84 | $posttrans 85 | 86 | ${transfiletriggerin:+%transfiletriggerin -- ${transfiletriggerin_patterns}} 87 | $transfiletriggerin 88 | 89 | ${transfiletriggerin2:+%transfiletriggerin -- ${transfiletriggerin2_patterns}} 90 | $transfiletriggerin2 91 | 92 | ${transfiletriggerun:+%transfiletriggerun -- ${transfiletriggerun_patterns}} 93 | $transfiletriggerun 94 | 95 | ${verifyscript:+%verifyscript} 96 | $verifyscript 97 | 98 | %install 99 | mkdir -p %{buildroot}/usr/bin 100 | install $name %{buildroot}/usr/bin 101 | $install 102 | 103 | %clean 104 | rm -rf %{buildroot} 105 | 106 | %files 107 | /usr/bin/$name 108 | $files 109 | EOF 110 | 111 | # because it'd be overkill to set up mock for this, let's just fool 112 | # rpmbuild using setarch 113 | local buildarch=$arch 114 | if [ "$arch" == "noarch" ]; then 115 | buildarch=$(uname -m) 116 | fi 117 | 118 | (cd $test_tmpdir/yumrepo/specs && 119 | setarch $buildarch rpmbuild --target $arch -ba $name.spec \ 120 | --define "_topdir $PWD" \ 121 | --define "_sourcedir $PWD" \ 122 | --define "_specdir $PWD" \ 123 | --define "_builddir $PWD/.build" \ 124 | --define "_srcrpmdir $PWD" \ 125 | --define "_rpmdir $test_tmpdir/yumrepo/packages" \ 126 | --define "_buildrootdir $PWD") 127 | # use --keep-all-metadata to retain previous updateinfo 128 | (cd $test_tmpdir/yumrepo && 129 | createrepo_c --no-database --update --keep-all-metadata .) 130 | # convenience function to avoid follow-up add-pkg 131 | if [ -n "$uinfo" ]; then 132 | uinfo_cmd add-pkg $uinfo $name 0 $version $release $arch 133 | fi 134 | if test '!' -f $test_tmpdir/yumrepo.repo; then 135 | cat > $test_tmpdir/yumrepo.repo.tmp << EOF 136 | [test-repo] 137 | name=test-repo 138 | baseurl=file:///$PWD/yumrepo 139 | EOF 140 | mv $test_tmpdir/yumrepo.repo{.tmp,} 141 | fi 142 | } 143 | -------------------------------------------------------------------------------- /tests/fixtures/example-lsblk-output.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockdevices": [ 3 | { 4 | "path": "/dev/sr0", 5 | "pttype": null, 6 | "parttypename": null 7 | },{ 8 | "path": "/dev/zram0", 9 | "pttype": null, 10 | "parttypename": null 11 | },{ 12 | "path": "/dev/vda", 13 | "pttype": "gpt", 14 | "parttypename": null 15 | },{ 16 | "path": "/dev/vda1", 17 | "pttype": "gpt", 18 | "parttypename": "EFI System" 19 | },{ 20 | "path": "/dev/vda2", 21 | "pttype": "gpt", 22 | "parttypename": "Linux extended boot" 23 | },{ 24 | "path": "/dev/vda3", 25 | "pttype": "gpt", 26 | "parttypename": "Linux filesystem" 27 | },{ 28 | "path": "/dev/mapper/luks-df2d5f95-5725-44dd-83e1-81bc4cdc49b8", 29 | "pttype": null, 30 | "parttypename": null 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tests/fixtures/example-state-v0-legacy.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": { 3 | "EFI": { 4 | "meta": { 5 | "timestamp": "2020-09-15T13:01:21", 6 | "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 7 | }, 8 | "filetree": { 9 | "timestamp": "1970-01-01T00:00:00", 10 | "children": { 11 | "BOOT/BOOTX64.EFI": { 12 | "size": 1210776, 13 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 14 | }, 15 | "BOOT/fbx64.efi": { 16 | "size": 357248, 17 | "sha512": "sha512:81fed5039bdd2bc53a203a1eaf56c6a6c9a95aa7ac88f037718a342205d83550f409741c8ef86b481f55ea7188ce0d661742548596f92ef97ba2a1695bc4caae" 18 | }, 19 | "fedora/BOOTX64.CSV": { 20 | "size": 110, 21 | "sha512": "sha512:0c29b8ae73171ef683ba690069c1bae711e130a084a81169af33a83dfbae4e07d909c2482dbe89a96ab26e171f17c53f1de8cb13d558bc1535412ff8accf253f" 22 | }, 23 | "fedora/grubx64.efi": { 24 | "size": 2528520, 25 | "sha512": "sha512:b35a6317658d07844d6bf0f96c35f2df90342b8b13a329b4429ac892351ff74fc794a97bc3d3e2d79bef4c234b49a8dd5147b71a3376f24bc956130994e9961c" 26 | }, 27 | "fedora/mmx64.efi": { 28 | "size": 1159560, 29 | "sha512": "sha512:f83ea67756cfcc3ec4eb1c83104c719ba08e66abfadb94b4bd75891e237c448bbec0fdb5bd42826e291ccc3dee559af424900b3d642a7d11c5bc9f117718837a" 30 | }, 31 | "fedora/shim.efi": { 32 | "size": 1210776, 33 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 34 | }, 35 | "fedora/shimx64-fedora.efi": { 36 | "size": 1204496, 37 | "sha512": "sha512:dc3656b90c0d1767365bea462cc94a2a3044899f510bd61a9a7ae1a9ca586e3d6189592b1ba1ee859f45614421297fa2f5353328caa615f51da5aed9ecfbf29c" 38 | }, 39 | "fedora/shimx64.efi": { 40 | "size": 1210776, 41 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "pending": null 48 | } 49 | -------------------------------------------------------------------------------- /tests/fixtures/example-state-v0.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": { 3 | "EFI": { 4 | "meta": { 5 | "timestamp": "2020-09-15T13:01:21Z", 6 | "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 7 | }, 8 | "filetree": { 9 | "children": { 10 | "BOOT/BOOTX64.EFI": { 11 | "size": 1210776, 12 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 13 | }, 14 | "BOOT/fbx64.efi": { 15 | "size": 357248, 16 | "sha512": "sha512:81fed5039bdd2bc53a203a1eaf56c6a6c9a95aa7ac88f037718a342205d83550f409741c8ef86b481f55ea7188ce0d661742548596f92ef97ba2a1695bc4caae" 17 | }, 18 | "fedora/BOOTX64.CSV": { 19 | "size": 110, 20 | "sha512": "sha512:0c29b8ae73171ef683ba690069c1bae711e130a084a81169af33a83dfbae4e07d909c2482dbe89a96ab26e171f17c53f1de8cb13d558bc1535412ff8accf253f" 21 | }, 22 | "fedora/grubx64.efi": { 23 | "size": 2528520, 24 | "sha512": "sha512:b35a6317658d07844d6bf0f96c35f2df90342b8b13a329b4429ac892351ff74fc794a97bc3d3e2d79bef4c234b49a8dd5147b71a3376f24bc956130994e9961c" 25 | }, 26 | "fedora/mmx64.efi": { 27 | "size": 1159560, 28 | "sha512": "sha512:f83ea67756cfcc3ec4eb1c83104c719ba08e66abfadb94b4bd75891e237c448bbec0fdb5bd42826e291ccc3dee559af424900b3d642a7d11c5bc9f117718837a" 29 | }, 30 | "fedora/shim.efi": { 31 | "size": 1210776, 32 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 33 | }, 34 | "fedora/shimx64-fedora.efi": { 35 | "size": 1204496, 36 | "sha512": "sha512:dc3656b90c0d1767365bea462cc94a2a3044899f510bd61a9a7ae1a9ca586e3d6189592b1ba1ee859f45614421297fa2f5353328caa615f51da5aed9ecfbf29c" 37 | }, 38 | "fedora/shimx64.efi": { 39 | "size": 1210776, 40 | "sha512": "sha512:52e08b6e1686b19fea9e8f8d8ca51d22bba252467ceaf6db6ead8dd2dca4a0b0b02e547e50ddf1cdee225b8785f8514f6baa846bdf1ea0bf994e772daf70f2c3" 41 | } 42 | } 43 | } 44 | } 45 | }, 46 | "pending": null 47 | } 48 | -------------------------------------------------------------------------------- /tests/fixtures/example-status-v0.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "EFI": { 4 | "installed": { 5 | "timestamp": "2020-09-15T13:01:21Z", 6 | "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 7 | }, 8 | "interrupted": null, 9 | "update": { 10 | "timestamp": "2020-09-15T13:01:21Z", 11 | "version": "grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64" 12 | }, 13 | "updatable": "at-latest-version", 14 | "adopted-from": null 15 | } 16 | }, 17 | "adoptable": { 18 | "BIOS": { 19 | "version": { 20 | "version": "grub2-bios-42.x86_64", 21 | "timestamp": "2020-09-15T13:01:21Z" 22 | }, 23 | "confident": true 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/kola/data/libtest.sh: -------------------------------------------------------------------------------- 1 | # Source library for shell script tests 2 | # Copyright (C) 2020 Red Hat, Inc. 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | runv() { 6 | (set -x && "$@") 7 | } 8 | 9 | N_TESTS=0 10 | ok() { 11 | echo "ok" $@ 12 | N_TESTS=$((N_TESTS + 1)) 13 | } 14 | 15 | tap_finish() { 16 | echo "Completing TAP test with:" 17 | echo "1..${N_TESTS}" 18 | } 19 | 20 | fatal() { 21 | echo error: $@ 1>&2; exit 1 22 | } 23 | 24 | runv() { 25 | set -x 26 | "$@" 27 | } 28 | 29 | # Dump ls -al + file contents to stderr, then fatal() 30 | _fatal_print_file() { 31 | file="$1" 32 | shift 33 | ls -al "$file" >&2 34 | sed -e 's/^/# /' < "$file" >&2 35 | fatal "$@" 36 | } 37 | 38 | assert_not_has_file () { 39 | fpath=$1 40 | shift 41 | if test -e "$fpath"; then 42 | fatal "Path exists: ${fpath}" 43 | fi 44 | } 45 | 46 | assert_file_has_content () { 47 | fpath=$1 48 | shift 49 | for re in "$@"; do 50 | if ! grep -q -e "$re" "$fpath"; then 51 | _fatal_print_file "$fpath" "File '$fpath' doesn't match regexp '$re'" 52 | fi 53 | done 54 | } 55 | 56 | assert_file_has_content_literal () { 57 | fpath=$1; shift 58 | for s in "$@"; do 59 | if ! grep -q -F -e "$s" "$fpath"; then 60 | _fatal_print_file "$fpath" "File '$fpath' doesn't match fixed string list '$s'" 61 | fi 62 | done 63 | } 64 | 65 | assert_not_file_has_content () { 66 | fpath=$1 67 | shift 68 | for re in "$@"; do 69 | if grep -q -e "$re" "$fpath"; then 70 | _fatal_print_file "$fpath" "File '$fpath' matches regexp '$re'" 71 | fi 72 | done 73 | } 74 | 75 | assert_not_file_has_content_literal () { 76 | fpath=$1; shift 77 | for s in "$@"; do 78 | if grep -q -F -e "$s" "$fpath"; then 79 | _fatal_print_file "$fpath" "File '$fpath' matches fixed string list '$s'" 80 | fi 81 | done 82 | } 83 | 84 | # Mount the EFI partition at a temporary location. 85 | efipart=/dev/disk/by-partlabel/EFI-SYSTEM 86 | mount_tmp_efi () { 87 | tmpmount=$(mktemp -d) 88 | mkdir -p ${tmpmount} 89 | mount ${efipart} ${tmpmount} 90 | echo ${tmpmount} 91 | } 92 | -------------------------------------------------------------------------------- /tests/kola/test-bootupd: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | . ${KOLA_EXT_DATA}/libtest.sh 5 | 6 | tmpdir=$(mktemp -d) 7 | cd ${tmpdir} 8 | echo "using tmpdir: ${tmpdir}" 9 | touch .testtmp 10 | trap cleanup EXIT 11 | function cleanup () { 12 | if test -z "${TEST_SKIP_CLEANUP:-}"; then 13 | if test -f "${tmpdir}"/.testtmp; then 14 | cd / 15 | rm "${tmpdir}" -rf 16 | fi 17 | else 18 | echo "Skipping cleanup of ${tmpdir}" 19 | fi 20 | } 21 | 22 | # Mount the EFI partition. 23 | tmpefimount=$(mount_tmp_efi) 24 | bootmount=/boot 25 | tmpefidir=${tmpefimount}/EFI 26 | bootupdir=/usr/lib/bootupd/updates 27 | efiupdir=${bootupdir}/EFI 28 | ostbaseefi=/usr/lib/ostree-boot/efi/EFI 29 | efisubdir=fedora 30 | efidir=${efiupdir}/${efisubdir} 31 | ostefi=${ostbaseefi}/${efisubdir} 32 | shim=shimx64.efi 33 | 34 | test -f "${efidir}/${shim}" 35 | 36 | prepare_efi_update() { 37 | test -w /usr 38 | mkdir -p ${ostbaseefi} 39 | cp -a ${efiupdir}.orig/* ${ostbaseefi}/ 40 | rm -rf ${efiupdir} ${bootupdir}/EFI.json 41 | } 42 | 43 | bootupctl status > out.txt 44 | assert_file_has_content_literal out.txt 'Component EFI' 45 | assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' 46 | assert_file_has_content_literal out.txt 'Update: At latest version' 47 | assert_file_has_content out.txt '^CoreOS aleph version:' 48 | ok status 49 | 50 | bootupctl validate | tee out.txt 51 | ok validate 52 | 53 | if env LANG=C.UTF-8 runuser -u bin bootupctl status 2>err.txt; then 54 | fatal "Was able to bootupctl status as non-root" 55 | fi 56 | assert_file_has_content err.txt 'error: This command requires root privileges' 57 | 58 | # From here we'll fake updates 59 | test -w /usr || rpm-ostree usroverlay 60 | # Save a backup copy of the update dir 61 | cp -a ${efiupdir} ${efiupdir}.orig 62 | 63 | prepare_efi_update 64 | # FIXME need to synthesize an RPM for this 65 | # echo somenewfile > ${ostefi}/somenew.efi 66 | rm -v ${ostefi}/shim.efi 67 | echo bootupd-test-changes >> ${ostefi}/grubx64.efi 68 | /usr/libexec/bootupd generate-update-metadata / 69 | ver=$(jq -r .version < ${bootupdir}/EFI.json) 70 | cat >ver.json << EOF 71 | { "version": "${ver},test", "timestamp": "$(date -u --iso-8601=seconds)" } 72 | EOF 73 | jq -s add ${bootupdir}/EFI.json ver.json > new.json 74 | mv new.json ${bootupdir}/EFI.json 75 | 76 | bootupctl status | tee out.txt 77 | assert_file_has_content_literal out.txt 'Component EFI' 78 | assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-' 79 | assert_not_file_has_content out.txt ' Installed: grub2-efi-x64.*,test' 80 | assert_file_has_content_literal out.txt 'Update: Available:' 81 | ok update avail 82 | 83 | bootupctl status --json > status.json 84 | jq -r '.components.EFI.installed.version' < status.json > installed.txt 85 | assert_file_has_content installed.txt '^grub2-efi-x64' 86 | 87 | bootupctl update | tee out.txt 88 | assert_file_has_content out.txt 'Updated EFI: grub2-efi-x64.*,test' 89 | 90 | bootupctl status > out.txt 91 | assert_file_has_content_literal out.txt 'Component EFI' 92 | assert_file_has_content out.txt ' Installed: grub2-efi-x64.*,test' 93 | assert_file_has_content_literal out.txt 'Update: At latest version' 94 | ok status after update 95 | 96 | bootupctl validate | tee out.txt 97 | ok validate after update 98 | 99 | # FIXME see above 100 | # assert_file_has_content ${tmpefidir}/${efisubdir}/somenew.efi 'somenewfile' 101 | if test -f ${tmpefidir}/${efisubdir}/shim.efi; then 102 | fatal "failed to remove file" 103 | fi 104 | if ! grep -q 'bootupd-test-changes' ${tmpefidir}/${efisubdir}/grubx64.efi; then 105 | fatal "failed to update modified file" 106 | fi 107 | cmp ${tmpefidir}/${efisubdir}/shimx64.efi ${efiupdir}/${efisubdir}/shimx64.efi 108 | ok filesystem changes 109 | 110 | bootupctl update | tee out.txt 111 | assert_file_has_content_literal out.txt 'No update available for any component' 112 | assert_not_file_has_content_literal out.txt 'Updated EFI' 113 | 114 | echo "some additions" >> ${tmpefidir}/${efisubdir}/shimx64.efi 115 | if bootupctl validate 2>err.txt; then 116 | fatal "unexpectedly passed validation" 117 | fi 118 | assert_file_has_content err.txt "Changed: ${efisubdir}/shimx64.efi" 119 | test "$(grep -cEe '^Changed:' err.txt)" = "1" 120 | ok validate detected changes 121 | 122 | tap_finish 123 | -------------------------------------------------------------------------------- /tests/kolainst/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | echo "No build step" 3 | 4 | install: 5 | mkdir -p $(DESTDIR)/usr/lib/coreos-assembler/tests/kola/ 6 | rsync -rlv ../kola $(DESTDIR)/usr/lib/coreos-assembler/tests/kola/bootupd 7 | -------------------------------------------------------------------------------- /xtask/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | fastbuild*.qcow2 3 | _kola_temp 4 | .cosa 5 | Cargo.lock 6 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.68" 10 | camino = "1.0" 11 | chrono = { version = "0.4.23", default-features = false, features = ["std"] } 12 | fn-error-context = "0.2.0" 13 | toml = "0.8" 14 | tempfile = "3.3" 15 | xshell = { version = "0.2" } 16 | -------------------------------------------------------------------------------- /xtask/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufRead, BufReader, BufWriter, Write}; 3 | use std::process::Command; 4 | 5 | use anyhow::{Context, Result}; 6 | use camino::{Utf8Path, Utf8PathBuf}; 7 | use fn_error_context::context; 8 | use xshell::{cmd, Shell}; 9 | 10 | const NAME: &str = "bootupd"; 11 | const VENDORPATH: &str = "vendor.tar.zstd"; 12 | const TAR_REPRODUCIBLE_OPTS: &[&str] = &[ 13 | "--sort=name", 14 | "--owner=0", 15 | "--group=0", 16 | "--numeric-owner", 17 | "--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime", 18 | ]; 19 | 20 | fn main() { 21 | if let Err(e) = try_main() { 22 | eprintln!("error: {e:#}"); 23 | std::process::exit(1); 24 | } 25 | } 26 | 27 | fn try_main() -> Result<()> { 28 | let task = std::env::args().nth(1); 29 | let sh = xshell::Shell::new()?; 30 | if let Some(cmd) = task.as_deref() { 31 | let f = match cmd { 32 | "vendor" => vendor, 33 | "package" => package, 34 | "package-srpm" => package_srpm, 35 | _ => print_help, 36 | }; 37 | f(&sh)?; 38 | } else { 39 | print_help(&sh)?; 40 | } 41 | Ok(()) 42 | } 43 | 44 | fn get_target_dir() -> Result { 45 | let target = Utf8Path::new("target"); 46 | std::fs::create_dir_all(&target)?; 47 | Ok(target.to_owned()) 48 | } 49 | 50 | fn vendor(sh: &Shell) -> Result<()> { 51 | let _targetdir = get_target_dir()?; 52 | let target = VENDORPATH; 53 | cmd!( 54 | sh, 55 | "cargo vendor-filterer --prefix=vendor --format=tar.zstd {target}" 56 | ) 57 | .run()?; 58 | Ok(()) 59 | } 60 | 61 | fn gitrev_to_version(v: &str) -> String { 62 | let v = v.trim().trim_start_matches('v'); 63 | v.replace('-', ".") 64 | } 65 | 66 | #[context("Finding gitrev")] 67 | fn gitrev(sh: &Shell) -> Result { 68 | if let Ok(rev) = cmd!(sh, "git describe --tags").ignore_stderr().read() { 69 | Ok(gitrev_to_version(&rev)) 70 | } else { 71 | let mut desc = cmd!(sh, "git describe --tags --always").read()?; 72 | desc.insert_str(0, "0."); 73 | Ok(desc) 74 | } 75 | } 76 | 77 | /// Return a string formatted version of the git commit timestamp, up to the minute 78 | /// but not second because, well, we're not going to build more than once a second. 79 | #[allow(dead_code)] 80 | #[context("Finding git timestamp")] 81 | fn git_timestamp(sh: &Shell) -> Result { 82 | let ts = cmd!(sh, "git show -s --format=%ct").read()?; 83 | let ts = ts.trim().parse::()?; 84 | let ts = chrono::DateTime::from_timestamp(ts, 0) 85 | .ok_or_else(|| anyhow::anyhow!("Failed to parse timestamp"))?; 86 | Ok(ts.format("%Y%m%d%H%M").to_string()) 87 | } 88 | 89 | struct Package { 90 | version: String, 91 | srcpath: Utf8PathBuf, 92 | vendorpath: Utf8PathBuf, 93 | } 94 | 95 | /// Return the timestamp of the latest git commit in seconds since the Unix epoch. 96 | fn git_source_date_epoch(dir: &Utf8Path) -> Result { 97 | let o = Command::new("git") 98 | .args(["log", "-1", "--pretty=%ct"]) 99 | .current_dir(dir) 100 | .output()?; 101 | if !o.status.success() { 102 | anyhow::bail!("git exited with an error: {:?}", o); 103 | } 104 | let buf = String::from_utf8(o.stdout).context("Failed to parse git log output")?; 105 | let r = buf.trim().parse()?; 106 | Ok(r) 107 | } 108 | 109 | 110 | /// When using cargo-vendor-filterer --format=tar, the config generated has a bogus source 111 | /// directory. This edits it to refer to vendor/ as a stable relative reference. 112 | #[context("Editing vendor config")] 113 | fn edit_vendor_config(config: &str) -> Result { 114 | let mut config: toml::Value = toml::from_str(config)?; 115 | let config = config.as_table_mut().unwrap(); 116 | let source_table = config.get_mut("source").unwrap(); 117 | let source_table = source_table.as_table_mut().unwrap(); 118 | let vendored_sources = source_table.get_mut("vendored-sources").unwrap(); 119 | let vendored_sources = vendored_sources.as_table_mut().unwrap(); 120 | let previous = 121 | vendored_sources.insert("directory".into(), toml::Value::String("vendor".into())); 122 | assert!(previous.is_some()); 123 | 124 | Ok(config.to_string()) 125 | } 126 | 127 | #[context("Packaging")] 128 | fn impl_package(sh: &Shell) -> Result { 129 | let source_date_epoch = git_source_date_epoch(".".into())?; 130 | let v = gitrev(sh)?; 131 | 132 | let namev = format!("{NAME}-{v}"); 133 | let p = Utf8Path::new("target").join(format!("{namev}.tar")); 134 | let prefix = format!("{namev}/"); 135 | cmd!(sh, "git archive --format=tar --prefix={prefix} -o {p} HEAD").run()?; 136 | // Generate the vendor directory now, as we want to embed the generated config to use 137 | // it in our source. 138 | let vendorpath = Utf8Path::new("target").join(format!("{namev}-vendor.tar.zstd")); 139 | let vendor_config = cmd!( 140 | sh, 141 | "cargo vendor-filterer --prefix=vendor --format=tar.zstd {vendorpath}" 142 | ) 143 | .read()?; 144 | let vendor_config = edit_vendor_config(&vendor_config)?; 145 | // Append .cargo/vendor-config.toml (a made up filename) into the tar archive. 146 | { 147 | let tmpdir = tempfile::tempdir_in("target")?; 148 | let tmpdir_path = tmpdir.path(); 149 | let path = tmpdir_path.join("vendor-config.toml"); 150 | std::fs::write(&path, vendor_config)?; 151 | let source_date_epoch = format!("{source_date_epoch}"); 152 | cmd!( 153 | sh, 154 | "tar -r -C {tmpdir_path} {TAR_REPRODUCIBLE_OPTS...} --mtime=@{source_date_epoch} --transform=s,^,{prefix}.cargo/, -f {p} vendor-config.toml" 155 | ) 156 | .run()?; 157 | } 158 | // Compress with zstd 159 | let srcpath: Utf8PathBuf = format!("{p}.zstd").into(); 160 | cmd!(sh, "zstd --rm -f {p} -o {srcpath}").run()?; 161 | 162 | Ok(Package { 163 | version: v, 164 | srcpath, 165 | vendorpath, 166 | }) 167 | } 168 | 169 | fn package(sh: &Shell) -> Result<()> { 170 | let p = impl_package(sh)?.srcpath; 171 | println!("Generated: {p}"); 172 | Ok(()) 173 | } 174 | 175 | fn impl_srpm(sh: &Shell) -> Result { 176 | let pkg = impl_package(sh)?; 177 | vendor(sh)?; 178 | let td = tempfile::tempdir_in("target").context("Allocating tmpdir")?; 179 | let td = td.into_path(); 180 | let td: &Utf8Path = td.as_path().try_into().unwrap(); 181 | let srcpath = td.join(pkg.srcpath.file_name().unwrap()); 182 | std::fs::rename(pkg.srcpath, srcpath)?; 183 | let v = pkg.version; 184 | let vendorpath = td.join(format!("{NAME}-{v}-vendor.tar.zstd")); 185 | std::fs::rename(VENDORPATH, vendorpath)?; 186 | { 187 | let specin = File::open(format!("contrib/packaging/{NAME}.spec")) 188 | .map(BufReader::new) 189 | .context("Opening spec")?; 190 | let mut o = File::create(td.join(format!("{NAME}.spec"))).map(BufWriter::new)?; 191 | for line in specin.lines() { 192 | let line = line?; 193 | if line.starts_with("Version:") { 194 | writeln!(o, "# Replaced by cargo xtask package-srpm")?; 195 | writeln!(o, "Version: {v}")?; 196 | } else { 197 | writeln!(o, "{}", line)?; 198 | } 199 | } 200 | } 201 | let d = sh.push_dir(td); 202 | let mut cmd = cmd!(sh, "rpmbuild"); 203 | for k in [ 204 | "_sourcedir", 205 | "_specdir", 206 | "_builddir", 207 | "_srcrpmdir", 208 | "_rpmdir", 209 | ] { 210 | cmd = cmd.arg("--define"); 211 | cmd = cmd.arg(format!("{k} {td}")); 212 | } 213 | let spec = format!("{NAME}.spec"); 214 | cmd.arg("--define") 215 | .arg(format!("_buildrootdir {td}/.build")) 216 | .args(["-bs", spec.as_str()]) 217 | .run()?; 218 | drop(d); 219 | let mut srpm = None; 220 | for e in std::fs::read_dir(td)? { 221 | let e = e?; 222 | let n = e.file_name(); 223 | let n = if let Some(n) = n.to_str() { 224 | n 225 | } else { 226 | continue; 227 | }; 228 | if n.ends_with(".src.rpm") { 229 | srpm = Some(td.join(n)); 230 | break; 231 | } 232 | } 233 | let srpm = srpm.ok_or_else(|| anyhow::anyhow!("Failed to find generated .src.rpm"))?; 234 | let dest = Utf8Path::new("target").join(srpm.file_name().unwrap()); 235 | std::fs::rename(&srpm, &dest)?; 236 | Ok(dest) 237 | } 238 | 239 | fn package_srpm(sh: &Shell) -> Result<()> { 240 | let _targetdir = get_target_dir()?; 241 | let srpm = impl_srpm(sh)?; 242 | println!("Generated: {srpm}"); 243 | Ok(()) 244 | } 245 | 246 | fn print_help(_sh: &Shell) -> Result<()> { 247 | eprintln!( 248 | "Tasks: 249 | - vendor 250 | " 251 | ); 252 | Ok(()) 253 | } 254 | --------------------------------------------------------------------------------