Skip to content

Commit a425676

Browse files
authored
Add support for downloading prebuilt blobs from Buildomat (#51)
Allow packaging TOMLs to define one or more `buildomat_blobs` that refer to a Buildomat artifact. This artifact need not be a zone image. The existing S3 blob type is not changed, and buildomat blobs are optional, so this should be a non-breaking change for existing package manifests. Tested: cargo test; picked up the changes into Propolis's packaging utility and verified that they can be used to pick up guest firmware images produced by Buildomat.
1 parent bf662c5 commit a425676

File tree

4 files changed

+192
-64
lines changed

4 files changed

+192
-64
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ chrono = "0.4.24"
1919
filetime = "0.2"
2020
flate2 = "1.0.25"
2121
futures-util = "0.3"
22+
hex = "0.4.3"
2223
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "stream"] }
24+
ring = "0.16.20"
2325
semver = { version = "1.0.17", features = ["std", "serde"] }
2426
serde = { version = "1.0", features = [ "derive" ] }
2527
serde_derive = "1.0"

src/blob.rs

Lines changed: 129 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,85 +4,138 @@
44

55
//! Tools for downloading blobs
66
7-
use anyhow::{anyhow, Result};
7+
use anyhow::{anyhow, Context, Result};
88
use chrono::{DateTime, FixedOffset, Utc};
99
use futures_util::StreamExt;
1010
use reqwest::header::{CONTENT_LENGTH, LAST_MODIFIED};
11-
use std::path::Path;
11+
use ring::digest::{Context as DigestContext, Digest, SHA256};
12+
use std::path::{Path, PathBuf};
1213
use std::str::FromStr;
13-
use tokio::io::AsyncWriteExt;
14+
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
1415

15-
use crate::progress::Progress;
16+
use crate::progress::{NoProgress, Progress};
1617

1718
// Path to the blob S3 Bucket.
1819
const S3_BUCKET: &str = "https://oxide-omicron-build.s3.amazonaws.com";
1920
// Name for the directory component where downloaded blobs are stored.
2021
pub(crate) const BLOB: &str = "blob";
2122

23+
#[derive(Debug)]
24+
pub enum Source<'a> {
25+
S3(&'a PathBuf),
26+
Buildomat(&'a crate::package::PrebuiltBlob),
27+
}
28+
29+
impl<'a> Source<'a> {
30+
pub(crate) fn get_url(&self) -> String {
31+
match self {
32+
Self::S3(s) => format!("{}/{}", S3_BUCKET, s.to_string_lossy()),
33+
Self::Buildomat(spec) => {
34+
format!(
35+
"https://buildomat.eng.oxide.computer/public/file/oxidecomputer/{}/{}/{}/{}",
36+
spec.repo, spec.series, spec.commit, spec.artifact
37+
)
38+
}
39+
}
40+
}
41+
42+
async fn download_required(
43+
&self,
44+
url: &str,
45+
client: &reqwest::Client,
46+
destination: &Path,
47+
) -> Result<bool> {
48+
if !destination.exists() {
49+
return Ok(true);
50+
}
51+
52+
match self {
53+
Self::S3(_) => {
54+
// Issue a HEAD request to get the blob's size and last modified
55+
// time. If these match what's on disk, assume the blob is
56+
// current and don't re-download it.
57+
let head_response = client
58+
.head(url)
59+
.send()
60+
.await?
61+
.error_for_status()
62+
.with_context(|| format!("HEAD failed for {}", url))?;
63+
let headers = head_response.headers();
64+
let content_length = headers
65+
.get(CONTENT_LENGTH)
66+
.ok_or_else(|| anyhow!("no content length on {} HEAD response!", url))?;
67+
let content_length: u64 = u64::from_str(content_length.to_str()?)?;
68+
69+
// From S3, header looks like:
70+
//
71+
// "Last-Modified: Fri, 27 May 2022 20:50:17 GMT"
72+
let last_modified = headers
73+
.get(LAST_MODIFIED)
74+
.ok_or_else(|| anyhow!("no last modified on {} HEAD response!", url))?;
75+
let last_modified: DateTime<FixedOffset> =
76+
chrono::DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
77+
78+
let metadata = tokio::fs::metadata(&destination).await?;
79+
let metadata_modified: DateTime<Utc> = metadata.modified()?.into();
80+
81+
Ok(metadata.len() != content_length || metadata_modified != last_modified)
82+
}
83+
Self::Buildomat(blob_spec) => {
84+
let digest = get_sha256_digest(destination).await?;
85+
let expected_digest = hex::decode(&blob_spec.sha256)?;
86+
Ok(digest.as_ref() != expected_digest)
87+
}
88+
}
89+
}
90+
}
91+
2292
// Downloads "source" from S3_BUCKET to "destination".
23-
pub async fn download(progress: &impl Progress, source: &str, destination: &Path) -> Result<()> {
93+
pub async fn download<'a>(
94+
progress: &impl Progress,
95+
source: &Source<'a>,
96+
destination: &Path,
97+
) -> Result<()> {
2498
let blob = destination
2599
.file_name()
26100
.ok_or_else(|| anyhow!("missing blob filename"))?;
27101

28-
let url = format!("{}/{}", S3_BUCKET, source);
102+
let url = source.get_url();
29103
let client = reqwest::Client::new();
30-
31-
let head_response = client.head(&url).send().await?.error_for_status()?;
32-
let headers = head_response.headers();
33-
34-
// From S3, header looks like:
35-
//
36-
// "Content-Length: 49283072"
37-
let content_length = headers
38-
.get(CONTENT_LENGTH)
39-
.ok_or_else(|| anyhow!("no content length on {} HEAD response!", url))?;
40-
let mut content_length: u64 = u64::from_str(content_length.to_str()?)?;
41-
42-
if destination.exists() {
43-
// If destination exists, check against size and last modified time. If
44-
// both are the same, then return Ok
45-
46-
// From S3, header looks like:
47-
//
48-
// "Last-Modified: Fri, 27 May 2022 20:50:17 GMT"
49-
let last_modified = headers
50-
.get(LAST_MODIFIED)
51-
.ok_or_else(|| anyhow!("no last modified on {} HEAD response!", url))?;
52-
let last_modified: DateTime<FixedOffset> =
53-
chrono::DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
54-
let metadata = tokio::fs::metadata(&destination).await?;
55-
let metadata_modified: DateTime<Utc> = metadata.modified()?.into();
56-
57-
if metadata.len() == content_length && metadata_modified == last_modified {
58-
return Ok(());
59-
}
104+
if !source.download_required(&url, &client, destination).await? {
105+
return Ok(());
60106
}
61107

62108
let response = client.get(url).send().await?.error_for_status()?;
63109
let response_headers = response.headers();
64110

65111
// Grab update Content-Length from response headers, if present.
66112
// We only use it as a hint for the progress so no need to fail.
67-
if let Some(Ok(Ok(resp_len))) = response_headers
113+
let content_length = if let Some(Ok(Ok(resp_len))) = response_headers
68114
.get(CONTENT_LENGTH)
69115
.map(|c| c.to_str().map(u64::from_str))
70116
{
71-
content_length = resp_len;
72-
}
73-
74-
// Store modified time from HTTPS response
75-
let last_modified = response_headers
76-
.get(LAST_MODIFIED)
77-
.ok_or_else(|| anyhow!("no last modified on GET response!"))?;
78-
let last_modified: DateTime<FixedOffset> =
79-
chrono::DateTime::parse_from_rfc2822(last_modified.to_str()?)?;
117+
Some(resp_len)
118+
} else {
119+
None
120+
};
121+
122+
// If the server advertised a last-modified time for the blob, save it here
123+
// so that the downloaded blob's last-modified time can be set to it.
124+
let last_modified = if let Some(time) = response_headers.get(LAST_MODIFIED) {
125+
Some(chrono::DateTime::parse_from_rfc2822(time.to_str()?)?)
126+
} else {
127+
None
128+
};
80129

81130
// Write file bytes to destination
82131
let mut file = tokio::fs::File::create(destination).await?;
83132

84133
// Create a sub-progress for the blob download
85-
let blob_progress = progress.sub_progress(content_length);
134+
let blob_progress = if let Some(length) = content_length {
135+
progress.sub_progress(length)
136+
} else {
137+
Box::new(NoProgress)
138+
};
86139
blob_progress.set_message(blob.to_string_lossy().into_owned().into());
87140

88141
let mut stream = response.bytes_stream();
@@ -107,14 +160,39 @@ pub async fn download(progress: &impl Progress, source: &str, destination: &Path
107160
drop(file);
108161

109162
// Set destination file's modified time based on HTTPS response
110-
filetime::set_file_mtime(
111-
destination,
112-
filetime::FileTime::from_system_time(last_modified.into()),
113-
)?;
163+
if let Some(last_modified) = last_modified {
164+
filetime::set_file_mtime(
165+
destination,
166+
filetime::FileTime::from_system_time(last_modified.into()),
167+
)?;
168+
}
114169

115170
Ok(())
116171
}
117172

173+
async fn get_sha256_digest(path: &Path) -> Result<Digest> {
174+
let mut reader = BufReader::new(
175+
tokio::fs::File::open(path)
176+
.await
177+
.with_context(|| format!("could not open {path:?}"))?,
178+
);
179+
let mut context = DigestContext::new(&SHA256);
180+
let mut buffer = [0; 1024];
181+
182+
loop {
183+
let count = reader
184+
.read(&mut buffer)
185+
.await
186+
.with_context(|| format!("failed to read {path:?}"))?;
187+
if count == 0 {
188+
break;
189+
} else {
190+
context.update(&buffer[..count]);
191+
}
192+
}
193+
Ok(context.finish())
194+
}
195+
118196
#[test]
119197
fn test_converts() {
120198
let content_length = "1966080";

src/package.rs

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ async fn add_package_to_zone_archive(
172172
Ok(())
173173
}
174174

175+
/// Describes a path to a Buildomat-generated artifact that should reside at
176+
/// the following path:
177+
///
178+
/// <https://buildomat.eng.oxide.computer/public/file/oxidecomputer/REPO/SERIES/COMMIT/ARTIFACT>
179+
#[derive(Deserialize, Debug)]
180+
pub struct PrebuiltBlob {
181+
pub repo: String,
182+
pub series: String,
183+
pub commit: String,
184+
pub artifact: String,
185+
pub sha256: String,
186+
}
187+
175188
/// Describes the origin of an externally-built package.
176189
#[derive(Deserialize, Debug)]
177190
#[serde(tag = "type", rename_all = "lowercase")]
@@ -182,6 +195,9 @@ pub enum PackageSource {
182195
/// within this package.
183196
blobs: Option<Vec<PathBuf>>,
184197

198+
/// A list of Buildomat blobs that should be placed in this package.
199+
buildomat_blobs: Option<Vec<PrebuiltBlob>>,
200+
185201
/// Configuration for packages containing Rust binaries.
186202
rust: Option<RustPackage>,
187203

@@ -228,6 +244,16 @@ impl PackageSource {
228244
_ => None,
229245
}
230246
}
247+
248+
fn buildomat_blobs(&self) -> Option<&[PrebuiltBlob]> {
249+
match self {
250+
PackageSource::Local {
251+
buildomat_blobs: Some(buildomat_blobs),
252+
..
253+
} => Some(buildomat_blobs),
254+
_ => None,
255+
}
256+
}
231257
}
232258

233259
/// Describes the output format of the package.
@@ -439,11 +465,19 @@ impl Package {
439465
//
440466
// - 1 tick for each included path
441467
// - 1 tick per rust binary
442-
// - 1 tick per blob + 1 tick for appending blob dir to archive
468+
// - 1 tick per blob
469+
// - 1 tick for appending the blob directory to the archive, but only if
470+
// there is at least one blob
443471
let progress_total = match &self.source {
444-
PackageSource::Local { blobs, rust, paths } => {
445-
let blob_work = blobs.as_ref().map(|b| b.len() + 1).unwrap_or(0);
446-
472+
PackageSource::Local {
473+
blobs,
474+
buildomat_blobs,
475+
rust,
476+
paths,
477+
} => {
478+
let blob_work = blobs.as_ref().map(|b| b.len()).unwrap_or(0);
479+
let buildomat_work = buildomat_blobs.as_ref().map(|b| b.len()).unwrap_or(0);
480+
let blob_dir_work = (blob_work != 0 || buildomat_work != 0) as usize;
447481
let rust_work = rust.as_ref().map(|r| r.binary_names.len()).unwrap_or(0);
448482

449483
let mut paths_work = 0;
@@ -455,7 +489,7 @@ impl Package {
455489
.count();
456490
}
457491

458-
rust_work + blob_work + paths_work
492+
rust_work + blob_work + buildomat_work + paths_work + blob_dir_work
459493
}
460494
_ => 1,
461495
};
@@ -631,19 +665,32 @@ impl Package {
631665
download_directory: &Path,
632666
destination_path: &Path,
633667
) -> Result<()> {
668+
let mut all_blobs = Vec::new();
634669
if let Some(blobs) = self.source.blobs() {
670+
all_blobs.extend(blobs.iter().map(crate::blob::Source::S3));
671+
}
672+
673+
if let Some(buildomat_blobs) = self.source.buildomat_blobs() {
674+
all_blobs.extend(buildomat_blobs.iter().map(crate::blob::Source::Buildomat));
675+
}
676+
677+
if !all_blobs.is_empty() {
635678
progress.set_message("downloading blobs".into());
636679
let blobs_path = download_directory.join(&self.service_name);
637680
std::fs::create_dir_all(&blobs_path)?;
638-
stream::iter(blobs.iter())
681+
stream::iter(all_blobs.iter())
639682
.map(Ok)
640683
.try_for_each_concurrent(None, |blob| {
641-
let blob_path = blobs_path.join(blob);
684+
let blob_path = match blob {
685+
blob::Source::S3(s) => blobs_path.join(s),
686+
blob::Source::Buildomat(spec) => blobs_path.join(&spec.artifact),
687+
};
688+
642689
async move {
643-
blob::download(progress, &blob.to_string_lossy(), &blob_path)
690+
blob::download(progress, blob, &blob_path)
644691
.await
645692
.with_context(|| {
646-
format!("failed to download blob: {}", blob.to_string_lossy())
693+
format!("failed to download blob: {}", blob.get_url())
647694
})?;
648695
progress.increment(1);
649696
Ok::<_, anyhow::Error>(())

tests/mod.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,12 @@ mod test {
231231
async fn test_download() -> Result<()> {
232232
let out = tempfile::tempdir()?;
233233

234-
let url = "OVMF_CODE.fd";
235-
let dst = out.path().join(url);
234+
let path = PathBuf::from("OVMF_CODE.fd");
235+
let src = omicron_zone_package::blob::Source::S3(&path);
236+
let dst = out.path().join(&path);
236237

237-
download(&NoProgress, &url, &dst).await?;
238-
download(&NoProgress, &url, &dst).await?;
238+
download(&NoProgress, &src, &dst).await?;
239+
download(&NoProgress, &src, &dst).await?;
239240

240241
Ok(())
241242
}

0 commit comments

Comments
 (0)