1
0
mirror of https://github.com/containers/bootc.git synced 2026-02-05 15:45:53 +01:00

Implementation of adoption

So far we've supported updating systems that we installed,
but we also need to handle updating at least older CoreOS
systems.

This shares a lot of similarity with `update`; the biggest
difference is that we aren't sure which files we should
be managing.  So given a pending update, we only replace
files that exist in that update.

Closes: https://github.com/coreos/bootupd/issues/38
This commit is contained in:
Colin Walters
2020-10-02 17:32:18 -04:00
committed by OpenShift Merge Robot
parent e3c36fb4cb
commit 6d9d2e36a4
12 changed files with 407 additions and 5 deletions

View File

@@ -47,10 +47,16 @@ cosaPod(buildroot: true, runAsUser: 0, memory: "3072Mi", cpu: "4") {
mkdir -p overrides/rootfs
mv insttree/* overrides/rootfs/
rmdir insttree
coreos-assembler fetch
cosa fetch
cosa build
""")
}
// The e2e-update test does a build, so we just end at fetch above
// The e2e-adopt test will use the ostree commit we just generated above
// but a static qemu base image.
stage("e2e adopt test") {
shwrap("env COSA_DIR=${env.WORKSPACE} ./tests/e2e-adopt/e2e-adopt.sh")
}
// Now a test that upgrades using bootupd
stage("e2e upgrade test") {
shwrap("env COSA_DIR=${env.WORKSPACE} ./tests/e2e-update/e2e-update.sh")
}

View File

@@ -23,6 +23,8 @@ pub(crate) const WRITE_LOCK_PATH: &str = "run/bootupd-lock";
pub(crate) enum ClientRequest {
/// Update a component
Update { component: String },
/// Update a component via adoption
AdoptAndUpdate { component: String },
/// Validate a component
Validate { component: String },
/// Print the current state
@@ -148,6 +150,28 @@ pub(crate) fn update(name: &str) -> Result<ComponentUpdateResult> {
})
}
/// daemon implementation of component adoption
pub(crate) fn adopt_and_update(name: &str) -> Result<ContentMetadata> {
let sysroot = openat::Dir::open("/")?;
let _lock = acquire_write_lock("/").context("Failed to acquire write lock")?;
let mut state = get_saved_state("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
if state.installed.get(name).is_some() {
anyhow::bail!("Component {} is already installed", name);
};
let update = if let Some(update) = component.query_update()? {
update
} else {
anyhow::bail!("Component {} has no available update", name);
};
let inst = component
.adopt_update(&update)
.context("Failed adopt and update")?;
state.installed.insert(component.name().into(), inst);
update_state(&sysroot, &state)?;
Ok(update)
}
/// daemon implementation of component validate
pub(crate) fn validate(name: &str) -> Result<ValidationResult> {
let state = get_saved_state("/")?.unwrap_or_default();
@@ -228,6 +252,7 @@ pub(crate) fn status() -> Result<Status> {
.flatten();
let update = component.query_update()?;
let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref());
let adopted_from = ic.adopted_from.clone();
ret.components.insert(
name.to_string(),
ComponentStatus {
@@ -235,6 +260,7 @@ pub(crate) fn status() -> Result<Status> {
interrupted: interrupted.cloned(),
update,
updatable,
adopted_from,
},
);
}
@@ -242,7 +268,15 @@ pub(crate) fn status() -> Result<Status> {
log::trace!("No saved state");
}
// Process the remaining components not installed
log::trace!("Remaining known components: {}", known_components.len());
for (name, component) in known_components {
if let Some(adopt_ver) = component.query_adopt()? {
ret.adoptable.insert(name.to_string(), adopt_ver);
} else {
log::trace!("Not adoptable: {}", name);
}
}
Ok(ret)
}
@@ -273,6 +307,13 @@ pub(crate) fn print_status(status: &Status) -> Result<()> {
println!(" Update: {}", msg);
}
if status.adoptable.is_empty() {
println!("No components are adoptable.");
}
for (name, version) in status.adoptable.iter() {
println!("Adoptable: {}: {}", name, version.version);
}
if let Some(coreos_aleph) = coreos::get_aleph_version()? {
println!("CoreOS aleph image ID: {}", coreos_aleph.aleph.imgid);
}
@@ -350,6 +391,22 @@ pub(crate) fn client_run_update(c: &mut ipc::ClientToDaemonConnection) -> Result
Ok(())
}
pub(crate) fn client_run_adopt_and_update(c: &mut ipc::ClientToDaemonConnection) -> Result<()> {
validate_preview_env()?;
let status: Status = c.send(&ClientRequest::Status)?;
if status.adoptable.is_empty() {
println!("No components are adoptable.");
} else {
for (name, _) in status.adoptable.iter() {
let r: ContentMetadata = c.send(&ClientRequest::AdoptAndUpdate {
component: name.to_string(),
})?;
println!("Adopted and updated: {}: {}", name, r.version);
}
}
Ok(())
}
pub(crate) fn client_run_validate(c: &mut ipc::ClientToDaemonConnection) -> Result<()> {
let status: Status = c.send(&ClientRequest::Status)?;
if status.components.is_empty() {

View File

@@ -42,6 +42,8 @@ pub enum CtlVerb {
Status(StatusOpts),
#[structopt(name = "update", about = "Update all components")]
Update,
#[structopt(name = "adopt-and-update", about = "Update all adoptable components")]
AdoptAndUpdate,
#[structopt(name = "validate", about = "Validate system state")]
Validate,
}
@@ -67,6 +69,7 @@ impl CtlCommand {
match self.cmd {
CtlVerb::Status(opts) => Self::run_status(opts),
CtlVerb::Update => Self::run_update(),
CtlVerb::AdoptAndUpdate => Self::run_adopt_and_update(),
CtlVerb::Validate => Self::run_validate(),
CtlVerb::Backend(CtlBackend::Generate(opts)) => {
super::bootupd::DCommand::run_generate_meta(opts)
@@ -106,6 +109,17 @@ impl CtlCommand {
Ok(())
}
/// Runner for `update` verb.
fn run_adopt_and_update() -> Result<()> {
let mut client = ClientToDaemonConnection::new();
client.connect()?;
bootupd::client_run_adopt_and_update(&mut client)?;
client.shutdown()?;
Ok(())
}
/// Runner for `validate` verb.
fn run_validate() -> Result<()> {
let mut client = ClientToDaemonConnection::new();

View File

@@ -25,6 +25,14 @@ pub(crate) trait Component {
/// and should remain stable.
fn name(&self) -> &'static str;
/// In an operating system whose initially booted disk image is not
/// using bootupd, detect whether it looks like the component exists
/// and "synthesize" content metadata from it.
fn query_adopt(&self) -> Result<Option<ContentMetadata>>;
/// Given an adoptable system and an update, perform the update.
fn adopt_update(&self, update: &ContentMetadata) -> Result<InstalledContent>;
/// Implementation of `bootupd install` for a given component. This should
/// gather data (or run binaries) from the source root, and install them
/// into the target root. It is expected that sub-partitions (e.g. the ESP)

View File

@@ -24,7 +24,6 @@ pub(crate) struct Aleph {
pub(crate) struct AlephWithTimestamp {
pub(crate) aleph: Aleph,
#[allow(dead_code)]
pub(crate) ts: chrono::DateTime<Utc>,
}

View File

@@ -104,6 +104,12 @@ fn process_client_requests(client: ipc::AuthenticatedClient) -> Result<()> {
Err(e) => ipc::DaemonToClientReply::Failure(format!("{:#}", e)),
})?
}
ClientRequest::AdoptAndUpdate { component } => {
bincode::serialize(&match bootupd::adopt_and_update(component.as_str()) {
Ok(v) => ipc::DaemonToClientReply::Success::<crate::model::ContentMetadata>(v),
Err(e) => ipc::DaemonToClientReply::Failure(format!("{:#}", e)),
})?
}
ClientRequest::Validate { component } => {
bincode::serialize(&match bootupd::validate(component.as_str()) {
Ok(v) => ipc::DaemonToClientReply::Success::<ValidationResult>(v),

View File

@@ -6,10 +6,11 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::prelude::*;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use openat_ext::OpenatDirExt;
use chrono::prelude::*;
@@ -26,11 +27,73 @@ pub(crate) const MOUNT_PATH: &str = "boot/efi";
#[derive(Default)]
pub(crate) struct EFI {}
impl EFI {
fn esp_path(&self) -> PathBuf {
Path::new(MOUNT_PATH).join("EFI")
}
fn open_esp_optional(&self) -> Result<Option<openat::Dir>> {
let sysroot = openat::Dir::open("/")?;
let esp = sysroot.sub_dir_optional(&self.esp_path())?;
Ok(esp)
}
fn open_esp(&self) -> Result<openat::Dir> {
let sysroot = openat::Dir::open("/")?;
let esp = sysroot.sub_dir(&self.esp_path())?;
Ok(esp)
}
}
impl Component for EFI {
fn name(&self) -> &'static str {
"EFI"
}
fn query_adopt(&self) -> Result<Option<ContentMetadata>> {
let esp = self.open_esp_optional()?;
if esp.is_none() {
log::trace!("No ESP detected");
return Ok(None);
};
// This would be extended with support for other operating systems later
let coreos_aleph = if let Some(a) = crate::coreos::get_aleph_version()? {
a
} else {
log::trace!("No CoreOS aleph detected");
return Ok(None);
};
let meta = ContentMetadata {
timestamp: coreos_aleph.ts,
version: coreos_aleph.aleph.imgid,
};
log::trace!("EFI adoptable: {:?}", &meta);
Ok(Some(meta))
}
/// Given an adoptable system and an update, perform the update.
fn adopt_update(&self, updatemeta: &ContentMetadata) -> Result<InstalledContent> {
let meta = if let Some(meta) = self.query_adopt()? {
meta
} else {
anyhow::bail!("Failed to find adoptable system");
};
let esp = self.open_esp()?;
validate_esp(&esp)?;
let updated =
openat::Dir::open(&component_updatedir("/", self)).context("opening update dir")?;
let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?;
// For adoption, we should only touch files that we know about.
let diff = updatef.relative_diff_to(&esp)?;
log::trace!("applying adoption diff: {}", &diff);
filetree::apply_diff(&updated, &esp, &diff, None).context("applying filesystem changes")?;
Ok(InstalledContent {
meta: updatemeta.clone(),
filetree: Some(updatef),
adopted_from: Some(meta),
})
}
fn install(&self, src_root: &str, dest_root: &str) -> Result<InstalledContent> {
let meta = if let Some(meta) = get_component_update(src_root, self)? {
meta
@@ -56,6 +119,7 @@ impl Component for EFI {
Ok(InstalledContent {
meta,
filetree: Some(ft),
adopted_from: None,
})
}
@@ -72,11 +136,14 @@ impl Component for EFI {
let destdir = openat::Dir::open(&Path::new("/").join(MOUNT_PATH).join("EFI"))
.context("opening EFI dir")?;
validate_esp(&destdir)?;
log::trace!("applying diff: {}", &diff);
filetree::apply_diff(&updated, &destdir, &diff, None)
.context("applying filesystem changes")?;
let adopted_from = None;
Ok(InstalledContent {
meta: updatemeta,
filetree: Some(updatef),
adopted_from: adopted_from,
})
}

View File

@@ -9,6 +9,7 @@ use openat_ext::OpenatDirExt;
use openssl::hash::{Hasher, MessageDigest};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Display;
use std::os::linux::fs::MetadataExt;
use std::os::unix::io::AsRawFd;
use std::os::unix::process::CommandExt;
@@ -49,6 +50,18 @@ pub(crate) struct FileTreeDiff {
pub(crate) changes: HashSet<String>,
}
impl Display for FileTreeDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
write!(
f,
"additions: {} removals: {} changes: {}",
self.additions.len(),
self.removals.len(),
self.changes.len()
)
}
}
#[cfg(test)]
impl FileTreeDiff {
pub(crate) fn count(&self) -> usize {

View File

@@ -37,6 +37,8 @@ pub(crate) struct InstalledContent {
pub(crate) meta: ContentMetadata,
/// Human readable version number, like ostree it is not ever parsed, just displayed
pub(crate) filetree: Option<crate::filetree::FileTree>,
/// The version this was originally adopted from
pub(crate) adopted_from: Option<ContentMetadata>,
}
/// Will be serialized into /boot/bootupd-state.json
@@ -89,6 +91,8 @@ pub(crate) struct ComponentStatus {
pub(crate) update: Option<ContentMetadata>,
/// Is true if the version in `update` is different from `installed`
pub(crate) updatable: ComponentUpdatable,
/// Originally adopted version
pub(crate) adopted_from: Option<ContentMetadata>,
}
/// Representation of bootupd's worldview at a point in time.
@@ -101,6 +105,8 @@ pub(crate) struct ComponentStatus {
pub(crate) struct Status {
/// Maps a component name to status
pub(crate) components: BTreeMap<String, ComponentStatus>,
/// Components that appear to be installed, not via bootupd
pub(crate) adoptable: BTreeMap<String, ContentMetadata>,
}
#[cfg(test)]

View File

@@ -0,0 +1,133 @@
#!/bin/bash
# Run inside the vm spawned from e2e.sh
set -euo pipefail
dn=$(cd $(dirname $0) && pwd)
bn=$(basename $0)
. ${dn}/../kola/data/libtest.sh
cd $(mktemp -d)
echo "Starting $0"
enable_bootupd() {
systemctl start bootupd.socket
# For now
export BOOTUPD_ACCEPT_PREVIEW=1
}
current_commit=$(rpm-ostree status --json | jq -r .deployments[0].checksum)
reboot_with_mark() {
mark=$1; shift
runv echo ${mark} > ${reboot_mark_path}
sync ${reboot_mark_path}
runv systemd-run -- systemctl reboot
touch /run/rebooting
sleep infinity
}
status_ok_no_update() {
bootupctl status | tee out.txt
assert_file_has_content_literal out.txt 'Component EFI'
assert_file_has_content_literal out.txt ' Installed: grub2-efi-x64-'
assert_file_has_content_literal out.txt 'Update: At latest version'
assert_file_has_content out.txt 'CoreOS aleph image ID: .*coreos.*-qemu'
bootupctl validate
ok status and validate
}
reboot_mark_path=/etc/${bn}.rebootstamp
reboot_mark=
if test -f "${reboot_mark_path}"; then
reboot_mark=$(cat ${reboot_mark_path})
fi
case "${reboot_mark}" in
"")
if test "${current_commit}" = ${TARGET_COMMIT}; then
fatal "already at ${TARGET_COMMIT}"
fi
# This system wasn't built via bootupd
assert_not_has_file /boot/bootupd-state.json
# FIXME
# https://github.com/coreos/rpm-ostree/issues/2210
runv setenforce 0
runv rpm-ostree rebase /run/cosadir/tmp/repo:${TARGET_COMMIT}
reboot_with_mark first
;;
first)
if test "${current_commit}" != ${TARGET_COMMIT}; then
fatal "not at ${TARGET_COMMIT}"
fi
# NOTE Fall through NOTE
;;
second)
enable_bootupd
status_ok_no_update
touch /run/testtmp/success
sync
# TODO maybe try to make this use more of the exttest infrastructure?
exec poweroff -ff
;;
esac
enable_bootupd
# We did setenforce 0 above for https://github.com/coreos/rpm-ostree/issues/2210
# Validate that on reboot we're still enforcing.
semode=$(getenforce)
if test "$semode" != Enforcing; then
fatal "SELinux mode is ${semode}"
fi
source_grub_cfg=$(find /boot/efi/EFI -name grub.cfg)
test -f "${source_grub_cfg}"
source_grub=$(find /boot/efi/EFI -name grubx64.efi)
test -f ${source_grub}
source_grub_sha256=$(sha256sum ${source_grub} | cut -f 1 -d ' ')
update_grub=$(find /usr/lib/bootupd/updates/EFI/ -name grubx64.efi)
test -f ${update_grub}
update_grub_sha256=$(sha256sum ${update_grub} | cut -f 1 -d ' ')
if test "${source_grub_sha256}" = "${update_grub_sha256}"; then
fatal "Already have target grubx64.efi"
fi
bootupctl status | tee out.txt
assert_file_has_content_literal out.txt 'No components installed.'
assert_file_has_content out.txt 'Adoptable: EFI: .*coreos.*-qemu.*'
bootupctl validate | tee out.txt
assert_file_has_content_literal out.txt 'No components installed.'
assert_not_file_has_content_literal out.txt "Validated"
# Shouldn't write state just starting and validating
assert_not_has_file /boot/bootupd-state.json
ok validate
bootupctl adopt-and-update | tee out.txt
assert_file_has_content out.txt 'Adopted and updated: EFI: grub2-efi-x64'
ok adoption
status_ok_no_update
bootupctl validate | tee out.txt
assert_not_file_has_content_literal out.txt "Validated EFI"
new_grub_sha256=$(sha256sum ${source_grub} | cut -f 1 -d ' ')
if test "${new_grub_sha256}" != "${update_grub_sha256}"; then
fatal "Failed to update grub"
fi
ok updated grub
# We shouldn't have deleted the config file which was unmanaged
test -f "${source_grub_cfg}"
ok still have grub.cfg
tap_finish
# And now do another reboot to validate that things are good
reboot_with_mark second

92
tests/e2e-adopt/e2e-adopt.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/bash
# Given an old FCOS build (pre-bootupd), upgrade
# to the latest build in ${COSA_DIR} and run through
# the adoption procedure to update the ESP.
set -euo pipefail
# There was a grub2-efi-x64 change after this
PRE_BOOTUPD_FCOS=https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/32.20200907.3.0/x86_64/meta.json
dn=$(cd $(dirname $0) && pwd)
testprefix=$(cd ${dn} && git rev-parse --show-prefix)
. ${dn}/../kola/data/libtest.sh
if test -z "${COSA_DIR:-}"; then
fatal "COSA_DIR must be set"
fi
# Validate source directory
bootupd_git=$(cd ${dn} && git rev-parse --show-toplevel)
test -f ${bootupd_git}/systemd/bootupd.service
testtmp=$(mktemp -d -p /var/tmp bootupd-e2e.XXXXXXX)
export test_tmpdir=${testtmp}
cd ${test_tmpdir}
runv curl -sSL -o meta.json ${PRE_BOOTUPD_FCOS}
jq .images.qemu < meta.json > qemu.json
qemu_image_xz=$(jq -r .path < qemu.json)
qemu_image=${qemu_image_xz%%.xz}
if test -f "${COSA_DIR}/tmp/${qemu_image}"; then
qemu_image="${COSA_DIR}/tmp/${qemu_image}"
else
runv curl -sSL $(dirname ${PRE_BOOTUPD_FCOS})/${qemu_image_xz} | xz -d > ${COSA_DIR}/tmp/${qemu_image}.tmp
mv ${COSA_DIR}/tmp/${qemu_image}{.tmp,}
qemu_image=${COSA_DIR}/tmp/${qemu_image}
fi
# Start in cosa dir
cd ${COSA_DIR}
test -d builds
echo "Preparing test"
target_commit=$(cosa meta --get-value ostree-commit)
echo "Target commit: ${target_commit}"
execstop='test -f /run/rebooting || poweroff -ff'
if test -n "${e2e_debug:-}"; then
execstop=
fi
cat >${testtmp}/test.fcct << EOF
variant: fcos
version: 1.0.0
systemd:
units:
- name: zincati.service
dropins:
- name: disabled.conf
contents: |
[Unit]
# Disable zincati, we're going to do our own updates
ConditionPathExists=/nosuchfile
- name: bootupd-test.service
enabled: true
contents: |
[Unit]
RequiresMountsFor=/run/testtmp
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=TARGET_COMMIT=${target_commit}
Environment=SRCDIR=/run/bootupd-source
# Run via shell because selinux denies systemd writing to 9p apparently
ExecStart=/bin/sh -c '/run/bootupd-source/${testprefix}/e2e-adopt-in-vm.sh &>>/run/testtmp/out.txt; ${execstop}'
[Install]
WantedBy=multi-user.target
EOF
runv fcct -o ${testtmp}/test.ign ${testtmp}/test.fcct
cd ${testtmp}
qemuexec_args=(kola qemuexec --propagate-initramfs-failure --qemu-image "${qemu_image}" --qemu-firmware uefi \
-i test.ign --bind-ro ${COSA_DIR},/run/cosadir --bind-ro ${bootupd_git},/run/bootupd-source --bind-rw .,/run/testtmp)
if test -n "${e2e_debug:-}"; then
runv ${qemuexec_args[@]} --devshell
else
runv timeout 5m "${qemuexec_args[@]}" --console-to-file $(pwd)/console.txt
fi
if ! test -f ${testtmp}/success; then
if test -s ${testtmp}/out.txt; then
sed -e 's,^,# ,' < ${testtmp}/out.txt
else
echo "No out.txt created, systemd unit failed to start"
fi
fatal "test failed"
fi
echo "ok bootupd e2e"

View File

@@ -13,5 +13,6 @@
"updatable": "at-latest-version",
"adopted-from": null
}
}
},
"adoptable": {}
}