Skip to main content

microvms_core/control/
services.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The two calls outside the MicroVMs API that `ensure_image` makes: the caller's account,
3//! for the image ARN, and the artifact upload (#221, IMAGE-8).
4//!
5//! # Why these are hand-signed here rather than an SDK
6//!
7//! The same reason the control plane is ([`super::transport`]): one HTTP stack, one TLS
8//! implementation, one credential chain. `aws-sdk-s3` and `aws-sdk-sts` would each bring a
9//! smithy client this crate turned off on purpose. Two operations — `GetCallerIdentity` and
10//! a single-part `PutObject` — are small enough to sign with the `aws-sigv4` the control
11//! plane already uses, and the credentials come from the same default chain
12//! ([`super::transport::default_credentials`]), so the ARN, the upload, and the create are
13//! one identity.
14//!
15//! # Why the account comes from STS
16//!
17//! `GetMicrovmImage` takes an ARN, and the service rejects a bare name, so the image ARN has
18//! to be built as `arn:aws:lambda:<region>:<account>:microvm-image:<name>` before the first
19//! describe. `GetCallerIdentity` is the one call that answers the account for any identity —
20//! a user, a role, an SSO session — and needs no permission to make. It is made once per
21//! `Sandbox` and cached there.
22//!
23//! # The seam
24//!
25//! [`BuildServices`] is the trait `Sandbox::with_build_services` takes, the way
26//! `ControlPlane::with_transport` takes a [`super::transport::Transport`]: a test records the
27//! calls and answers them, and production builds [`SignedBuildServices`] on the first
28//! `ensure_image`.
29
30use futures_util::future::BoxFuture;
31
32use crate::error::{Error, ErrorKind};
33use crate::region::Region;
34
35/// The seam `ensure_image` reaches STS and S3 through.
36pub trait BuildServices: Send + Sync {
37    /// The caller's twelve-digit account id.
38    fn caller_account(&self) -> BoxFuture<'_, Result<String, Error>>;
39
40    /// Puts `bytes` at `s3://<bucket>/<key>`.
41    fn put_object<'a>(
42        &'a self,
43        bucket: &'a str,
44        key: &'a str,
45        bytes: Vec<u8>,
46    ) -> BoxFuture<'a, Result<(), Error>>;
47}
48
49/// STS and S3 over SigV4, with the default credential chain.
50pub struct SignedBuildServices {
51    region: Region,
52    credentials: aws_credential_types::provider::SharedCredentialsProvider,
53    http: reqwest::Client,
54}
55
56impl std::fmt::Debug for SignedBuildServices {
57    /// Hand-written for the reason `SignedTransport`'s is: a derived one would print the
58    /// credentials provider.
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("SignedBuildServices")
61            .field("region", &self.region)
62            .finish_non_exhaustive()
63    }
64}
65
66impl SignedBuildServices {
67    /// Resolves the default credential chain for `region`. No call is made.
68    ///
69    /// The HTTP timeout is fifteen minutes rather than the control plane's minute, because
70    /// the upload carries the whole artifact in one request.
71    pub async fn new(region: Region) -> Result<Self, Error> {
72        Ok(Self {
73            credentials: super::transport::default_credentials(&region).await?,
74            http: super::transport::http_client(std::time::Duration::from_secs(15 * 60))?,
75            region,
76        })
77    }
78
79    async fn send(
80        &self,
81        operation: &str,
82        mut request: http::Request<Vec<u8>>,
83        service: &str,
84        settings: aws_sigv4::http_request::SigningSettings,
85    ) -> Result<(u16, Vec<u8>), Error> {
86        let credentials =
87            super::transport::resolve_credentials(&self.credentials, operation).await?;
88        super::transport::sign_for(&mut request, &credentials, &self.region, service, settings)?;
89        let url = request.uri().to_string();
90        let (parts, body) = request.into_parts();
91        let request = http::Request::from_parts(parts, reqwest::Body::from(body));
92        let request = reqwest::Request::try_from(request).map_err(|error| {
93            Error::new(
94                ErrorKind::Unexpected,
95                format!("could not convert the signed {operation} request: {error}"),
96            )
97            .with_source(error)
98        })?;
99        let response = self.http.execute(request).await.map_err(|error| {
100            Error::new(
101                ErrorKind::Retryable,
102                format!("the {operation} request to {url} did not complete: {error}"),
103            )
104            .with_source(error)
105        })?;
106        let status = response.status().as_u16();
107        let body = response.bytes().await.map_err(|error| {
108            Error::new(
109                ErrorKind::Retryable,
110                format!("could not read the {operation} response body: {error}"),
111            )
112            .with_source(error)
113        })?;
114        Ok((status, body.to_vec()))
115    }
116
117    async fn caller_account_once(&self) -> Result<String, Error> {
118        let body = b"Action=GetCallerIdentity&Version=2011-06-15".to_vec();
119        let request = http::Request::builder()
120            .method("POST")
121            .uri(sts_endpoint(&self.region))
122            .header(
123                "content-type",
124                "application/x-www-form-urlencoded; charset=utf-8",
125            )
126            .body(body)
127            .map_err(|error| {
128                Error::new(
129                    ErrorKind::Unexpected,
130                    format!("could not build the GetCallerIdentity request: {error}"),
131                )
132            })?;
133        let (status, body) = self
134            .send(
135                "GetCallerIdentity",
136                request,
137                "sts",
138                aws_sigv4::http_request::SigningSettings::default(),
139            )
140            .await?;
141        if status == 200 {
142            return account_from_sts(&body);
143        }
144        Err(xml_failure("GetCallerIdentity", status, &body))
145    }
146
147    async fn put_object_once(&self, bucket: &str, key: &str, bytes: &[u8]) -> Result<(), Error> {
148        let request = http::Request::builder()
149            .method("PUT")
150            .uri(s3_object_url(bucket, key, &self.region))
151            .header("content-type", "application/zip")
152            .body(bytes.to_vec())
153            .map_err(|error| {
154                Error::new(
155                    ErrorKind::Unexpected,
156                    format!("could not build the PutObject request: {error}"),
157                )
158            })?;
159        let (status, body) = self
160            .send("PutObject", request, "s3", s3_signing_settings())
161            .await?;
162        if status == 200 {
163            return Ok(());
164        }
165        Err(xml_failure(
166            &format!("PutObject s3://{bucket}/{key}"),
167            status,
168            &body,
169        ))
170    }
171}
172
173/// Retries `attempt` on a retryable failure, as `send_with_retry` retries a control-plane
174/// call: jittered exponential backoff from 200 ms to 20 s, five retries.
175async fn with_retry<T, F, Fut>(attempt: F) -> Result<T, Error>
176where
177    F: FnMut() -> Fut,
178    Fut: std::future::Future<Output = Result<T, Error>>,
179{
180    use backon::{ExponentialBuilder, Retryable};
181
182    attempt
183        .retry(
184            ExponentialBuilder::default()
185                .with_jitter()
186                .with_min_delay(std::time::Duration::from_millis(200))
187                .with_max_delay(std::time::Duration::from_secs(20))
188                .with_max_times(5),
189        )
190        .when(Error::retryable)
191        .await
192}
193
194impl BuildServices for SignedBuildServices {
195    fn caller_account(&self) -> BoxFuture<'_, Result<String, Error>> {
196        Box::pin(with_retry(|| self.caller_account_once()))
197    }
198
199    fn put_object<'a>(
200        &'a self,
201        bucket: &'a str,
202        key: &'a str,
203        bytes: Vec<u8>,
204    ) -> BoxFuture<'a, Result<(), Error>> {
205        Box::pin(async move { with_retry(|| self.put_object_once(bucket, key, &bytes)).await })
206    }
207}
208
209/// `https://sts.<region>.amazonaws.com/`, the regional endpoint.
210fn sts_endpoint(region: &Region) -> String {
211    format!("https://sts.{}.amazonaws.com/", region.as_str())
212}
213
214/// The object URL: virtual-hosted, or path-style for a bucket name with a dot (a dotted
215/// name does not match the `*.s3.<region>.amazonaws.com` certificate). Each key segment is
216/// percent-encoded and the `/` between them kept, which is the path S3 signs.
217pub(crate) fn s3_object_url(bucket: &str, key: &str, region: &Region) -> String {
218    let path: Vec<String> = key
219        .split('/')
220        .map(super::transport::paths::encode_segment)
221        .collect();
222    let path = path.join("/");
223    if bucket.contains('.') {
224        format!(
225            "https://s3.{}.amazonaws.com/{bucket}/{path}",
226            region.as_str()
227        )
228    } else {
229        format!(
230            "https://{bucket}.s3.{}.amazonaws.com/{path}",
231            region.as_str()
232        )
233    }
234}
235
236/// S3's signing rules: the path is signed as sent (already encoded once, never
237/// normalized), and the payload hash travels as `x-amz-content-sha256`, which S3 requires.
238pub(crate) fn s3_signing_settings() -> aws_sigv4::http_request::SigningSettings {
239    use aws_sigv4::http_request::{
240        PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode,
241    };
242    let mut settings = SigningSettings::default();
243    settings.percent_encoding_mode = PercentEncodingMode::Single;
244    settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled;
245    settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
246    settings
247}
248
249/// The text between `<tag>` and `</tag>`, the first time it appears.
250fn xml_text<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
251    let open = format!("<{tag}>");
252    let close = format!("</{tag}>");
253    let start = body.find(&open)? + open.len();
254    let end = body[start..].find(&close)? + start;
255    Some(body[start..end].trim())
256}
257
258/// The account in a `GetCallerIdentityResponse`, which must be twelve digits.
259pub(crate) fn account_from_sts(body: &[u8]) -> Result<String, Error> {
260    let text = String::from_utf8_lossy(body);
261    match xml_text(&text, "Account") {
262        Some(account) if account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit()) => {
263            Ok(account.to_string())
264        }
265        _ => Err(Error::new(
266            ErrorKind::Platform,
267            format!(
268                "GetCallerIdentity answered 200 with no twelve-digit Account: {}",
269                text.chars().take(512).collect::<String>()
270            ),
271        )),
272    }
273}
274
275/// A failed STS or S3 call as an [`Error`]: the service's `Code` and `Message` from its XML
276/// error body, classified by status, with the remedy the common ones need.
277pub(crate) fn xml_failure(operation: &str, status: u16, body: &[u8]) -> Error {
278    let text = String::from_utf8_lossy(body);
279    let code = xml_text(&text, "Code").unwrap_or("").to_string();
280    let message = xml_text(&text, "Message").unwrap_or("").to_string();
281    let said = if code.is_empty() && message.is_empty() {
282        "the service sent no error code".to_string()
283    } else {
284        format!("{code}: {message}")
285    };
286    let (kind, remedy) = match (status, code.as_str()) {
287        (301, _) | (_, "PermanentRedirect") | (_, "AuthorizationHeaderMalformed") => (
288            ErrorKind::Precondition,
289            format!(
290                "The bucket is in another region{}. The artifact bucket must be in the \
291                 sandbox's region, where the image build reads it.",
292                xml_text(&text, "Region")
293                    .map(|region| format!(" ({region})"))
294                    .unwrap_or_default()
295            ),
296        ),
297        (404, _) | (_, "NoSuchBucket") => (
298            ErrorKind::Precondition,
299            "The bucket does not exist in this account and region.".to_string(),
300        ),
301        (403, _) => (
302            ErrorKind::Credentials,
303            "The identity is not allowed this call: an upload needs s3:PutObject on the \
304             bucket and key prefix."
305                .to_string(),
306        ),
307        (429, _) | (_, "SlowDown") | (_, "Throttling") | (_, "ThrottlingException") => (
308            ErrorKind::Retryable,
309            "Throttled; retry the identical request.".to_string(),
310        ),
311        (status, _) if status >= 500 => (
312            ErrorKind::Retryable,
313            "A service fault; retry the identical request.".to_string(),
314        ),
315        _ => (ErrorKind::Platform, String::new()),
316    };
317    Error::new(
318        kind,
319        format!("{operation} failed with HTTP {status}: {said}. {remedy}")
320            .trim_end()
321            .to_string(),
322    )
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    /// **IMAGE-8, the upload URL.** Virtual-hosted for an ordinary bucket, path-style for a
330    /// dotted one, and each key segment encoded with the `/` between them kept.
331    #[test]
332    fn the_object_url_names_the_bucket_and_the_encoded_key() {
333        assert_eq!(
334            s3_object_url(
335                "agentd-conformance",
336                "harbor/task-0123456789ab/artifact.zip",
337                &Region::UsEast1
338            ),
339            "https://agentd-conformance.s3.us-east-1.amazonaws.com/harbor/task-0123456789ab/artifact.zip"
340        );
341        assert_eq!(
342            s3_object_url("my.bucket", "a b/c+d.zip", &Region::EuWest1),
343            "https://s3.eu-west-1.amazonaws.com/my.bucket/a%20b/c%2Bd.zip"
344        );
345    }
346
347    /// **IMAGE-8, the account.** Read from the XML answer, and refused unless it is twelve
348    /// digits — an ARN built from anything else names no image.
349    #[test]
350    fn the_account_is_read_from_the_sts_answer() {
351        let body =
352            br#"<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
353  <GetCallerIdentityResult>
354    <Arn>arn:aws:sts::741448939267:assumed-role/Admin/me</Arn>
355    <UserId>AROAEXAMPLE:me</UserId>
356    <Account>741448939267</Account>
357  </GetCallerIdentityResult>
358  <ResponseMetadata><RequestId>x</RequestId></ResponseMetadata>
359</GetCallerIdentityResponse>"#;
360        assert_eq!(account_from_sts(body).expect("an account"), "741448939267");
361        account_from_sts(b"<Account>12</Account>").expect_err("not twelve digits");
362        account_from_sts(b"<html>").expect_err("no account");
363    }
364
365    /// A failed upload names the service's code and the remedy, and is classified so the
366    /// retry loop retries only what a retry can fix.
367    #[test]
368    fn a_failed_call_names_the_code_and_is_classified() {
369        let error = xml_failure(
370            "PutObject s3://b/k",
371            403,
372            b"<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>",
373        );
374        assert_eq!(error.kind(), ErrorKind::Credentials);
375        assert!(error.to_string().contains("AccessDenied"), "{error}");
376        assert!(error.to_string().contains("s3:PutObject"), "{error}");
377
378        let error = xml_failure(
379            "PutObject s3://b/k",
380            301,
381            b"<Error><Code>PermanentRedirect</Code><Message>m</Message></Error>",
382        );
383        assert_eq!(error.kind(), ErrorKind::Precondition);
384        assert!(error.to_string().contains("another region"), "{error}");
385
386        let error = xml_failure(
387            "PutObject",
388            404,
389            b"<Error><Code>NoSuchBucket</Code></Error>",
390        );
391        assert_eq!(error.kind(), ErrorKind::Precondition);
392
393        for status in [500, 503] {
394            assert!(
395                xml_failure("PutObject", status, b"").retryable(),
396                "{status}"
397            );
398        }
399        assert!(xml_failure("PutObject", 503, b"<Error><Code>SlowDown</Code></Error>").retryable());
400        assert!(
401            !xml_failure(
402                "PutObject",
403                400,
404                b"<Error><Code>InvalidRequest</Code></Error>"
405            )
406            .retryable()
407        );
408    }
409
410    /// The S3 signature covers the payload hash, carried as `x-amz-content-sha256`, and is
411    /// scoped to `s3` in the request's region. Checked with static credentials, since the
412    /// signing is the only part of the upload that can be checked without a call.
413    #[test]
414    fn an_upload_is_signed_for_s3_with_the_payload_hash() {
415        let credentials =
416            aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
417        let mut request = http::Request::builder()
418            .method("PUT")
419            .uri(s3_object_url("bucket", "k/artifact.zip", &Region::UsEast1))
420            .body(b"zip bytes".to_vec())
421            .expect("a request");
422        crate::control::transport::sign_for(
423            &mut request,
424            &credentials,
425            &Region::UsEast1,
426            "s3",
427            s3_signing_settings(),
428        )
429        .expect("signs");
430        let headers = request.headers();
431        let authorization = headers["authorization"].to_str().expect("ascii");
432        assert!(
433            authorization.contains("/us-east-1/s3/aws4_request"),
434            "{authorization}"
435        );
436        assert!(
437            authorization.contains("x-amz-content-sha256"),
438            "{authorization}"
439        );
440        let digest = {
441            use sha2::{Digest as _, Sha256};
442            const_hex::encode(Sha256::digest(b"zip bytes"))
443        };
444        assert_eq!(headers["x-amz-content-sha256"], digest.as_str());
445    }
446}