Skip to main content

microvms_edges/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 ([`crate::control::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//! ([`crate::control::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` (`microvms_app::control::BuildServices`) is the trait
26//! `Sandbox::with_build_services` takes, the way `ControlPlane::from_ports` takes a transport:
27//! a test records the calls and answers them, and production builds [`SignedBuildServices`]
28//! through the plane's adapters on the first `ensure_image`.
29
30use futures_util::future::BoxFuture;
31
32use microvms_app::control::BuildServices;
33use microvms_app::error::{Error, ErrorKind};
34use microvms_app::region::Region;
35
36/// STS and S3 over SigV4, with the default credential chain.
37pub 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    /// Hand-written for the reason `SignedTransport`'s is: a derived one would print the
45    /// credentials provider.
46    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    /// Resolves the default credential chain for `region`. No call is made.
55    ///
56    /// The HTTP timeout is fifteen minutes rather than the control plane's minute, because
57    /// the upload carries the whole artifact in one request.
58    pub async fn new(region: Region) -> Result<Self, Error> {
59        Ok(Self {
60            credentials: crate::control::transport::default_credentials(&region).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
166/// Retries `attempt` on a retryable failure, as `send_with_retry` retries a control-plane
167/// call: jittered exponential backoff from 200 ms to 20 s, five retries.
168async 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
202/// `https://sts.<region>.amazonaws.com/`, the regional endpoint.
203fn sts_endpoint(region: &Region) -> String {
204    format!("https://sts.{}.amazonaws.com/", region.as_str())
205}
206
207/// The object URL: virtual-hosted, or path-style for a bucket name with a dot (a dotted
208/// name does not match the `*.s3.<region>.amazonaws.com` certificate). Each key segment is
209/// percent-encoded and the `/` between them kept, which is the path S3 signs.
210pub(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
229/// S3's signing rules: the path is signed as sent (already encoded once, never
230/// normalized), and the payload hash travels as `x-amz-content-sha256`, which S3 requires.
231pub(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
242/// The text between `<tag>` and `</tag>`, the first time it appears.
243fn 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
251/// The account in a `GetCallerIdentityResponse`, which must be twelve digits.
252pub(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
268/// A failed STS or S3 call as an [`Error`]: the service's `Code` and `Message` from its XML
269/// error body, classified by status, with the remedy the common ones need.
270pub(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    /// **IMAGE-8, the upload URL.** Virtual-hosted for an ordinary bucket, path-style for a
323    /// dotted one, and each key segment encoded with the `/` between them kept.
324    #[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    /// **IMAGE-8, the account.** Read from the XML answer, and refused unless it is twelve
341    /// digits — an ARN built from anything else names no image.
342    #[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    /// A failed upload names the service's code and the remedy, and is classified so the
359    /// retry loop retries only what a retry can fix.
360    #[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    /// The S3 signature covers the payload hash, carried as `x-amz-content-sha256`, and is
404    /// scoped to `s3` in the request's region. Checked with static credentials, since the
405    /// signing is the only part of the upload that can be checked without a call.
406    #[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}