1use futures_util::future::BoxFuture;
31
32use microvms_app::control::BuildServices;
33use microvms_app::error::{Error, ErrorKind};
34use microvms_app::region::Region;
35
36pub struct SignedBuildServices {
38 region: Region,
39 credentials: aws_credential_types::provider::SharedCredentialsProvider,
40 http: reqwest::Client,
41}
42
43impl std::fmt::Debug for SignedBuildServices {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("SignedBuildServices")
48 .field("region", &self.region)
49 .finish_non_exhaustive()
50 }
51}
52
53impl SignedBuildServices {
54 pub async fn new(region: Region) -> Result<Self, Error> {
59 Ok(Self {
60 credentials: crate::control::transport::default_credentials(®ion).await?,
61 http: crate::control::transport::http_client(std::time::Duration::from_secs(15 * 60))?,
62 region,
63 })
64 }
65
66 async fn send(
67 &self,
68 operation: &str,
69 mut request: http::Request<Vec<u8>>,
70 service: &str,
71 settings: aws_sigv4::http_request::SigningSettings,
72 ) -> Result<(u16, Vec<u8>), Error> {
73 let credentials =
74 crate::control::transport::resolve_credentials(&self.credentials, operation).await?;
75 crate::control::transport::sign_for(
76 &mut request,
77 &credentials,
78 &self.region,
79 service,
80 settings,
81 )?;
82 let url = request.uri().to_string();
83 let (parts, body) = request.into_parts();
84 let request = http::Request::from_parts(parts, reqwest::Body::from(body));
85 let request = reqwest::Request::try_from(request).map_err(|error| {
86 Error::new(
87 ErrorKind::Unexpected,
88 format!("could not convert the signed {operation} request: {error}"),
89 )
90 .with_source(error)
91 })?;
92 let response = self.http.execute(request).await.map_err(|error| {
93 Error::new(
94 ErrorKind::Retryable,
95 format!("the {operation} request to {url} did not complete: {error}"),
96 )
97 .with_source(error)
98 })?;
99 let status = response.status().as_u16();
100 let body = response.bytes().await.map_err(|error| {
101 Error::new(
102 ErrorKind::Retryable,
103 format!("could not read the {operation} response body: {error}"),
104 )
105 .with_source(error)
106 })?;
107 Ok((status, body.to_vec()))
108 }
109
110 async fn caller_account_once(&self) -> Result<String, Error> {
111 let body = b"Action=GetCallerIdentity&Version=2011-06-15".to_vec();
112 let request = http::Request::builder()
113 .method("POST")
114 .uri(sts_endpoint(&self.region))
115 .header(
116 "content-type",
117 "application/x-www-form-urlencoded; charset=utf-8",
118 )
119 .body(body)
120 .map_err(|error| {
121 Error::new(
122 ErrorKind::Unexpected,
123 format!("could not build the GetCallerIdentity request: {error}"),
124 )
125 })?;
126 let (status, body) = self
127 .send(
128 "GetCallerIdentity",
129 request,
130 "sts",
131 aws_sigv4::http_request::SigningSettings::default(),
132 )
133 .await?;
134 if status == 200 {
135 return account_from_sts(&body);
136 }
137 Err(xml_failure("GetCallerIdentity", status, &body))
138 }
139
140 async fn put_object_once(&self, bucket: &str, key: &str, bytes: &[u8]) -> Result<(), Error> {
141 let request = http::Request::builder()
142 .method("PUT")
143 .uri(s3_object_url(bucket, key, &self.region))
144 .header("content-type", "application/zip")
145 .body(bytes.to_vec())
146 .map_err(|error| {
147 Error::new(
148 ErrorKind::Unexpected,
149 format!("could not build the PutObject request: {error}"),
150 )
151 })?;
152 let (status, body) = self
153 .send("PutObject", request, "s3", s3_signing_settings())
154 .await?;
155 if status == 200 {
156 return Ok(());
157 }
158 Err(xml_failure(
159 &format!("PutObject s3://{bucket}/{key}"),
160 status,
161 &body,
162 ))
163 }
164}
165
166async fn with_retry<T, F, Fut>(attempt: F) -> Result<T, Error>
169where
170 F: FnMut() -> Fut,
171 Fut: std::future::Future<Output = Result<T, Error>>,
172{
173 use backon::{ExponentialBuilder, Retryable};
174
175 attempt
176 .retry(
177 ExponentialBuilder::default()
178 .with_jitter()
179 .with_min_delay(std::time::Duration::from_millis(200))
180 .with_max_delay(std::time::Duration::from_secs(20))
181 .with_max_times(5),
182 )
183 .when(Error::retryable)
184 .await
185}
186
187impl BuildServices for SignedBuildServices {
188 fn caller_account(&self) -> BoxFuture<'_, Result<String, Error>> {
189 Box::pin(with_retry(|| self.caller_account_once()))
190 }
191
192 fn put_object<'a>(
193 &'a self,
194 bucket: &'a str,
195 key: &'a str,
196 bytes: Vec<u8>,
197 ) -> BoxFuture<'a, Result<(), Error>> {
198 Box::pin(async move { with_retry(|| self.put_object_once(bucket, key, &bytes)).await })
199 }
200}
201
202fn sts_endpoint(region: &Region) -> String {
204 format!("https://sts.{}.amazonaws.com/", region.as_str())
205}
206
207pub(crate) fn s3_object_url(bucket: &str, key: &str, region: &Region) -> String {
211 let path: Vec<String> = key
212 .split('/')
213 .map(microvms_app::control::transport::paths::encode_segment)
214 .collect();
215 let path = path.join("/");
216 if bucket.contains('.') {
217 format!(
218 "https://s3.{}.amazonaws.com/{bucket}/{path}",
219 region.as_str()
220 )
221 } else {
222 format!(
223 "https://{bucket}.s3.{}.amazonaws.com/{path}",
224 region.as_str()
225 )
226 }
227}
228
229pub(crate) fn s3_signing_settings() -> aws_sigv4::http_request::SigningSettings {
232 use aws_sigv4::http_request::{
233 PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode,
234 };
235 let mut settings = SigningSettings::default();
236 settings.percent_encoding_mode = PercentEncodingMode::Single;
237 settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled;
238 settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
239 settings
240}
241
242fn xml_text<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
244 let open = format!("<{tag}>");
245 let close = format!("</{tag}>");
246 let start = body.find(&open)? + open.len();
247 let end = body[start..].find(&close)? + start;
248 Some(body[start..end].trim())
249}
250
251pub(crate) fn account_from_sts(body: &[u8]) -> Result<String, Error> {
253 let text = String::from_utf8_lossy(body);
254 match xml_text(&text, "Account") {
255 Some(account) if account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit()) => {
256 Ok(account.to_string())
257 }
258 _ => Err(Error::new(
259 ErrorKind::Platform,
260 format!(
261 "GetCallerIdentity answered 200 with no twelve-digit Account: {}",
262 text.chars().take(512).collect::<String>()
263 ),
264 )),
265 }
266}
267
268pub(crate) fn xml_failure(operation: &str, status: u16, body: &[u8]) -> Error {
271 let text = String::from_utf8_lossy(body);
272 let code = xml_text(&text, "Code").unwrap_or("").to_string();
273 let message = xml_text(&text, "Message").unwrap_or("").to_string();
274 let said = if code.is_empty() && message.is_empty() {
275 "the service sent no error code".to_string()
276 } else {
277 format!("{code}: {message}")
278 };
279 let (kind, remedy) = match (status, code.as_str()) {
280 (301, _) | (_, "PermanentRedirect") | (_, "AuthorizationHeaderMalformed") => (
281 ErrorKind::Precondition,
282 format!(
283 "The bucket is in another region{}. The artifact bucket must be in the \
284 sandbox's region, where the image build reads it.",
285 xml_text(&text, "Region")
286 .map(|region| format!(" ({region})"))
287 .unwrap_or_default()
288 ),
289 ),
290 (404, _) | (_, "NoSuchBucket") => (
291 ErrorKind::Precondition,
292 "The bucket does not exist in this account and region.".to_string(),
293 ),
294 (403, _) => (
295 ErrorKind::Credentials,
296 "The identity is not allowed this call: an upload needs s3:PutObject on the \
297 bucket and key prefix."
298 .to_string(),
299 ),
300 (429, _) | (_, "SlowDown") | (_, "Throttling") | (_, "ThrottlingException") => (
301 ErrorKind::Retryable,
302 "Throttled; retry the identical request.".to_string(),
303 ),
304 (status, _) if status >= 500 => (
305 ErrorKind::Retryable,
306 "A service fault; retry the identical request.".to_string(),
307 ),
308 _ => (ErrorKind::Platform, String::new()),
309 };
310 Error::new(
311 kind,
312 format!("{operation} failed with HTTP {status}: {said}. {remedy}")
313 .trim_end()
314 .to_string(),
315 )
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
325 fn the_object_url_names_the_bucket_and_the_encoded_key() {
326 assert_eq!(
327 s3_object_url(
328 "agentd-conformance",
329 "harbor/task-0123456789ab/artifact.zip",
330 &Region::US_EAST_1
331 ),
332 "https://agentd-conformance.s3.us-east-1.amazonaws.com/harbor/task-0123456789ab/artifact.zip"
333 );
334 assert_eq!(
335 s3_object_url("my.bucket", "a b/c+d.zip", &Region::EU_WEST_1),
336 "https://s3.eu-west-1.amazonaws.com/my.bucket/a%20b/c%2Bd.zip"
337 );
338 }
339
340 #[test]
343 fn the_account_is_read_from_the_sts_answer() {
344 let body =
345 br#"<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
346 <GetCallerIdentityResult>
347 <Arn>arn:aws:sts::741448939267:assumed-role/Admin/me</Arn>
348 <UserId>AROAEXAMPLE:me</UserId>
349 <Account>741448939267</Account>
350 </GetCallerIdentityResult>
351 <ResponseMetadata><RequestId>x</RequestId></ResponseMetadata>
352</GetCallerIdentityResponse>"#;
353 assert_eq!(account_from_sts(body).expect("an account"), "741448939267");
354 account_from_sts(b"<Account>12</Account>").expect_err("not twelve digits");
355 account_from_sts(b"<html>").expect_err("no account");
356 }
357
358 #[test]
361 fn a_failed_call_names_the_code_and_is_classified() {
362 let error = xml_failure(
363 "PutObject s3://b/k",
364 403,
365 b"<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>",
366 );
367 assert_eq!(error.kind(), ErrorKind::Credentials);
368 assert!(error.to_string().contains("AccessDenied"), "{error}");
369 assert!(error.to_string().contains("s3:PutObject"), "{error}");
370
371 let error = xml_failure(
372 "PutObject s3://b/k",
373 301,
374 b"<Error><Code>PermanentRedirect</Code><Message>m</Message></Error>",
375 );
376 assert_eq!(error.kind(), ErrorKind::Precondition);
377 assert!(error.to_string().contains("another region"), "{error}");
378
379 let error = xml_failure(
380 "PutObject",
381 404,
382 b"<Error><Code>NoSuchBucket</Code></Error>",
383 );
384 assert_eq!(error.kind(), ErrorKind::Precondition);
385
386 for status in [500, 503] {
387 assert!(
388 xml_failure("PutObject", status, b"").retryable(),
389 "{status}"
390 );
391 }
392 assert!(xml_failure("PutObject", 503, b"<Error><Code>SlowDown</Code></Error>").retryable());
393 assert!(
394 !xml_failure(
395 "PutObject",
396 400,
397 b"<Error><Code>InvalidRequest</Code></Error>"
398 )
399 .retryable()
400 );
401 }
402
403 #[test]
407 fn an_upload_is_signed_for_s3_with_the_payload_hash() {
408 let credentials =
409 aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
410 let mut request = http::Request::builder()
411 .method("PUT")
412 .uri(s3_object_url(
413 "bucket",
414 "k/artifact.zip",
415 &Region::US_EAST_1,
416 ))
417 .body(b"zip bytes".to_vec())
418 .expect("a request");
419 crate::control::transport::sign_for(
420 &mut request,
421 &credentials,
422 &Region::US_EAST_1,
423 "s3",
424 s3_signing_settings(),
425 )
426 .expect("signs");
427 let headers = request.headers();
428 let authorization = headers["authorization"].to_str().expect("ascii");
429 assert!(
430 authorization.contains("/us-east-1/s3/aws4_request"),
431 "{authorization}"
432 );
433 assert!(
434 authorization.contains("x-amz-content-sha256"),
435 "{authorization}"
436 );
437 let digest = {
438 use sha2::{Digest as _, Sha256};
439 const_hex::encode(Sha256::digest(b"zip bytes"))
440 };
441 assert_eq!(headers["x-amz-content-sha256"], digest.as_str());
442 }
443}