Skip to main content

microvms_core/control/
ensure.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Content-addressed build-or-reuse for an image: what `Sandbox::ensure_image` does (#221).
3//!
4//! A harness with many concurrent trials of one task needs one image for them all: the one
5//! already built, the one a sibling trial is building, or — when there is none — one it
6//! builds itself. This module is that composition, from build inputs to a usable image:
7//!
8//! 1. **Name** (IMAGE-6). `<prefix>-<hash12>`, the hash over the artifact's inputs (the
9//!    daemon, the Dockerfile, the build context — [`artifact_content_hash_with_context`])
10//!    plus the base image and the size class, because an image is created on one base at one
11//!    size. Equal inputs name one image; any changed input names a fresh one, which is what
12//!    keeps a stale snapshot from being served under a reused name.
13//! 2. **Artifact** (IMAGE-7). The Dockerfile, the daemon, and the context, zipped with fixed
14//!    dates and modes, so equal inputs are one object at one content-addressed key.
15//! 3. **ARN and upload** (IMAGE-8). The service takes image ARNs, not names, so the ARN is
16//!    built from the caller's account, looked up once per sandbox
17//!    ([`super::services::BuildServices::caller_account`]). The artifact goes to
18//!    `s3://<bucket>/<prefix>/<name>/artifact.zip`, and only when a build is needed.
19//! 4. **Decide** (IMAGE-9, IMAGE-10). After each describe, [`plan`] says what to do: reuse a
20//!    ready image, wait for one building, delete a failed one (or, under `force`, any one)
21//!    and wait for the name to be free, or build.
22//! 5. **Race** (IMAGE-11). Two trials that both find the name free both create; the service
23//!    accepts one and refuses the other. The loser describes again and waits for the winner's
24//!    build, returning it only once it is ready, and marks it reused.
25//!
26//! `model/src/image.rs` is this module as a Stateright model: two concurrent callers against
27//! a platform that moves the image through its states, checked over every interleaving. The
28//! decision table there is [`plan`]'s, row for row.
29//!
30//! # Everything local happens before the first call
31//!
32//! [`prepare`] derives the name, the key, the artifact, and the create request, and runs the
33//! create call's preflight, with zero calls. A request this client refuses costs nothing: not
34//! the account lookup, not a describe, not the upload.
35//!
36//! # Timeouts
37//!
38//! The build wait is the one `build` uses — [`WaitOpts::default`], 45 minutes with a
39//! four-minute stall probe — or the caller's `wait_timeout`. The wait for a deleted name to
40//! free up is [`DEFAULT_DELETE_TIMEOUT`].
41
42use std::collections::BTreeMap;
43use std::time::Duration;
44
45use super::artifact::{BaseImage, artifact_content_hash_with_context, build_artifact_with_context};
46use super::context::BuildContext;
47use super::image::{DEFAULT_BUILD_TIMEOUT, Image, WaitOpts};
48use super::services::BuildServices;
49use super::transport::{Call, paths, send_accepting};
50use super::{ControlPlane, CreateImageRequest, ops};
51use crate::error::{Error, ErrorKind};
52use crate::sizing::SizeClass;
53
54/// How long a deleted image may take to free its name before `ensure_image` gives up.
55///
56/// Five minutes, the bound a harness provider measured for the same wait; a deletion that
57/// has not finished by then is reported rather than waited on for the build's 45.
58pub const DEFAULT_DELETE_TIMEOUT: Duration = Duration::from_secs(300);
59
60/// The longest name the service's `ImageName` admits.
61const MAX_IMAGE_NAME: usize = 64;
62
63/// The hex characters of the identity hash in a name.
64const NAME_HASH_LEN: usize = 12;
65
66/// What a describe of the ensured image found.
67#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub enum Found {
69    /// No image under the name.
70    Absent,
71    /// `CREATING` or `UPDATING`, or a state this client does not know, which is waited on.
72    Building,
73    /// `CREATED` or `UPDATED` (or a tolerated ready spelling): usable.
74    Ready,
75    /// `CREATE_FAILED`, `UPDATE_FAILED`, or `DELETE_FAILED`.
76    Failed,
77    /// `DELETING`.
78    Deleting,
79}
80
81impl Found {
82    /// The reading of a `MicrovmImageState`.
83    pub fn from_state(state: &str) -> Self {
84        if Image::is_ready(state) {
85            Found::Ready
86        } else if Image::is_failed(state) {
87            Found::Failed
88        } else if state == "DELETING" {
89            Found::Deleting
90        } else if state == "DELETED" {
91            Found::Absent
92        } else {
93            Found::Building
94        }
95    }
96}
97
98/// What `ensure_image` does next with what a describe found.
99#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
100pub enum Plan {
101    /// Return the ready image; nothing is uploaded or created.
102    Reuse,
103    /// Wait for the running build to settle.
104    Wait,
105    /// Delete the image, wait until the name is free, then build.
106    Delete,
107    /// A deletion is under way: wait until the name is free, then build.
108    AwaitAbsent,
109    /// Upload the artifact and create the image.
110    Build,
111}
112
113/// The decision `ensure_image` makes after each describe (IMAGE-9, IMAGE-10).
114///
115/// A ready image is reused unless the caller forces a rebuild; a building one is waited on
116/// either way, because the service refuses to delete an image in `CREATING` — a forced
117/// caller deletes what the build settles to. A failed image is deleted whether or not the
118/// caller forced, because the name is content-addressed: the only way to a fresh build under
119/// it is to free it first. `model/src/image.rs` checks this table against two concurrent
120/// callers, and `the_plan_table` holds the two equal.
121pub fn plan(found: Found, force: bool) -> Plan {
122    match (found, force) {
123        (Found::Absent, _) => Plan::Build,
124        (Found::Ready, false) => Plan::Reuse,
125        (Found::Ready, true) | (Found::Failed, _) => Plan::Delete,
126        (Found::Building, _) => Plan::Wait,
127        (Found::Deleting, _) => Plan::AwaitAbsent,
128    }
129}
130
131/// The image name for a prefix and an identity hash: `<prefix>-<hash12>` (IMAGE-6).
132///
133/// The prefix is reduced to what the service's `ImageName` pattern admits
134/// (`[a-zA-Z0-9-_]`), every other character becoming `-`, trimmed of `-` at both ends, and
135/// cut so the whole name fits the pattern's 64 characters. A prefix with nothing left is
136/// refused: the name would be the hash alone, and nothing in the account would say what it
137/// is for.
138pub fn ensured_image_name(prefix: &str, identity_hash: &str) -> Result<String, Error> {
139    let sanitized: String = prefix
140        .chars()
141        .map(|c| {
142            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
143                c
144            } else {
145                '-'
146            }
147        })
148        .collect();
149    let room = MAX_IMAGE_NAME - 1 - NAME_HASH_LEN;
150    let cut: String = sanitized.trim_matches('-').chars().take(room).collect();
151    let cut = cut.trim_end_matches('-');
152    if cut.is_empty() {
153        return Err(Error::invalid_arg(format!(
154            "the image name prefix {prefix:?} has no character the service's ImageName pattern \
155             admits ([a-zA-Z0-9-_]), so the name would be a bare hash. Pass a prefix that says \
156             what the image is for, such as the task's name."
157        )));
158    }
159    let hash: String = identity_hash.chars().take(NAME_HASH_LEN).collect();
160    Ok(format!("{cut}-{hash}"))
161}
162
163/// The identity an ensured image is named for (IMAGE-6): the artifact's content hash, the
164/// base image's name (what `baseImageArn` names), and the size class's baseline.
165///
166/// Length-prefixed and tagged, so no two field boundaries can collide.
167pub fn image_identity_hash(artifact_hash: &str, base: &BaseImage, size: SizeClass) -> String {
168    use sha2::{Digest as _, Sha256};
169
170    let mut hasher = Sha256::new();
171    for field in [
172        b"microvms-ensure-image/1".as_slice(),
173        artifact_hash.as_bytes(),
174        base.name.as_bytes(),
175    ] {
176        hasher.update((field.len() as u64).to_be_bytes());
177        hasher.update(field);
178    }
179    hasher.update(size.baseline_mib().to_be_bytes());
180    const_hex::encode(hasher.finalize())
181}
182
183/// The artifact's S3 key: `<prefix>/<name>/artifact.zip`, with the prefix's empty segments
184/// dropped so no key starts with or doubles a `/` (IMAGE-8).
185pub fn artifact_key(key_prefix: Option<&str>, name: &str) -> String {
186    let prefix: Vec<&str> = key_prefix
187        .unwrap_or("")
188        .split('/')
189        .filter(|segment| !segment.is_empty())
190        .collect();
191    if prefix.is_empty() {
192        format!("{name}/artifact.zip")
193    } else {
194        format!("{}/{name}/artifact.zip", prefix.join("/"))
195    }
196}
197
198/// Refuses a bucket name S3 does not admit, before any call.
199fn require_valid_bucket(bucket: &str) -> Result<(), Error> {
200    let chars_ok = bucket
201        .bytes()
202        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-');
203    let ends_ok = bucket
204        .bytes()
205        .next()
206        .zip(bucket.bytes().last())
207        .is_some_and(|(first, last)| first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric());
208    if (3..=63).contains(&bucket.len()) && chars_ok && ends_ok && !bucket.contains("..") {
209        return Ok(());
210    }
211    Err(Error::invalid_arg(format!(
212        "the S3 bucket {bucket:?} is not a bucket name S3 admits: 3 to 63 characters of \
213         lowercase letters, digits, `.` and `-`, starting and ending with a letter or digit. \
214         Pass the bucket the build role can read, in the sandbox's region."
215    )))
216}
217
218/// Refuses a key prefix that could not form a legal S3 key.
219fn require_valid_key_prefix(prefix: Option<&str>, name: &str) -> Result<(), Error> {
220    let Some(prefix) = prefix else {
221        return Ok(());
222    };
223    if prefix.chars().any(char::is_control) {
224        return Err(Error::invalid_arg(format!(
225            "the S3 key prefix {prefix:?} carries a control character, which no S3 key \
226             should; pass a plain path such as `harbor/images`."
227        )));
228    }
229    let key = artifact_key(Some(prefix), name);
230    if key.len() > 1024 {
231        return Err(Error::invalid_arg(format!(
232            "the S3 key prefix makes a {}-byte key, over S3's 1024; shorten it.",
233            key.len()
234        )));
235    }
236    Ok(())
237}
238
239/// Everything `Sandbox::ensure_image` needs.
240#[derive(Clone, Debug)]
241pub struct EnsureImageRequest {
242    /// The stem of the image name; the name is `<prefix>-<hash12>`.
243    pub name_prefix: String,
244    /// The daemon binary's bytes.
245    pub binary: Vec<u8>,
246    /// The Dockerfile, usually `wrap_dockerfile`'s output. Checked by the create call's
247    /// preflight before anything else happens.
248    pub dockerfile: String,
249    /// The files the Dockerfile may `COPY` ([`BuildContext::from_dir`]), or `None`.
250    pub context: Option<BuildContext>,
251    /// The bucket the artifact is uploaded to, in the sandbox's region.
252    pub s3_bucket: String,
253    /// A key prefix inside the bucket, or `None` for the bucket root.
254    pub s3_key_prefix: Option<String>,
255    /// The build role, which must read the bucket and write logs under
256    /// `/aws/lambda-microvms/`.
257    pub build_role_arn: String,
258    /// The size class the image is created with; part of the name.
259    pub size: SizeClass,
260    /// The base image, or `None` for [`BaseImage::from_dockerfile`] of the Dockerfile.
261    pub base_image: Option<BaseImage>,
262    /// Delete what exists under the name and build afresh.
263    pub force: bool,
264    /// Tags for a created image. Not part of the name: a reused image keeps its own.
265    pub tags: BTreeMap<String, String>,
266    /// The build wait's deadline, or `None` for [`DEFAULT_BUILD_TIMEOUT`].
267    pub wait_timeout: Option<Duration>,
268}
269
270impl EnsureImageRequest {
271    /// A request with the defaults: no context, no key prefix, the default size class, the
272    /// base derived from the Dockerfile, no force, no tags, the default wait.
273    pub fn new(
274        name_prefix: impl Into<String>,
275        binary: Vec<u8>,
276        dockerfile: impl Into<String>,
277        s3_bucket: impl Into<String>,
278        build_role_arn: impl Into<String>,
279    ) -> Self {
280        Self {
281            name_prefix: name_prefix.into(),
282            binary,
283            dockerfile: dockerfile.into(),
284            context: None,
285            s3_bucket: s3_bucket.into(),
286            s3_key_prefix: None,
287            build_role_arn: build_role_arn.into(),
288            size: SizeClass::DEFAULT,
289            base_image: None,
290            force: false,
291            tags: BTreeMap::new(),
292            wait_timeout: None,
293        }
294    }
295}
296
297/// What `Sandbox::ensure_image` returns.
298#[derive(Clone, Debug)]
299pub struct EnsuredImage {
300    /// The ready image.
301    pub image: Image,
302    /// True when this call's own create did not build the image: it was ready, or a build
303    /// already running was waited out, or a sibling won the create race.
304    pub reused: bool,
305    /// Where the artifact is, whether or not this call uploaded it.
306    pub artifact_uri: String,
307    /// Whether this call uploaded the artifact.
308    pub uploaded: bool,
309    /// What reading the build context skipped, one line each ([`BuildContext::warnings`]).
310    pub warnings: Vec<String>,
311}
312
313/// Everything [`prepare`] decided locally: the name, the key, the artifact, and the create
314/// request, with the create's preflight already passed.
315#[derive(Clone, Debug)]
316pub struct Prepared {
317    /// `<prefix>-<hash12>`.
318    pub name: String,
319    pub bucket: String,
320    pub key: String,
321    /// The zip to upload when a build is needed.
322    pub artifact: Vec<u8>,
323    /// The create request, named and pointed at the artifact.
324    pub create: CreateImageRequest,
325    pub force: bool,
326    pub wait_timeout: Option<Duration>,
327    pub warnings: Vec<String>,
328}
329
330impl Prepared {
331    /// `s3://<bucket>/<key>`.
332    pub fn artifact_uri(&self) -> String {
333        format!("s3://{}/{}", self.bucket, self.key)
334    }
335}
336
337/// The local half of `ensure_image`: every name, key, and artifact decision and every guard,
338/// with zero calls.
339pub fn prepare(control: &ControlPlane, request: EnsureImageRequest) -> Result<Prepared, Error> {
340    require_valid_bucket(&request.s3_bucket)?;
341    let base = match request.base_image {
342        Some(base) => base,
343        None => BaseImage::from_dockerfile(&request.dockerfile)?,
344    };
345    let artifact_hash = artifact_content_hash_with_context(
346        &request.binary,
347        &request.dockerfile,
348        None,
349        request.context.as_ref(),
350    );
351    let name = ensured_image_name(
352        &request.name_prefix,
353        &image_identity_hash(&artifact_hash, &base, request.size),
354    )?;
355    require_valid_key_prefix(request.s3_key_prefix.as_deref(), &name)?;
356    let key = artifact_key(request.s3_key_prefix.as_deref(), &name);
357
358    let mut create = CreateImageRequest::new(
359        name.clone(),
360        request.binary,
361        format!("s3://{}/{key}", request.s3_bucket),
362        request.build_role_arn,
363    );
364    create.base_image = base;
365    create.dockerfile = Some(request.dockerfile);
366    create.size = request.size;
367    create.tags = request.tags;
368    create.token_scope = Some(name.clone());
369    control.preflight(&create)?;
370
371    let artifact = build_artifact_with_context(
372        &create.binary,
373        create.dockerfile.as_deref().unwrap_or_default(),
374        None,
375        request.context.as_ref(),
376    )?;
377    Ok(Prepared {
378        name,
379        bucket: request.s3_bucket,
380        key,
381        artifact,
382        create,
383        force: request.force,
384        wait_timeout: request.wait_timeout,
385        warnings: request
386            .context
387            .map(|context| context.warnings().to_vec())
388            .unwrap_or_default(),
389    })
390}
391
392impl ControlPlane {
393    /// The image under `identifier`, or `None` when the service answers that there is none.
394    ///
395    /// `GetMicrovmImage`'s 404 is an answer here rather than an error: "no image under this
396    /// name" is the state `ensure_image` builds from. Every other failure is an error as
397    /// usual, and throttles are retried.
398    pub async fn describe_image(
399        &self,
400        identifier: &str,
401    ) -> Result<Option<ops::GetMicrovmImageResponseWire>, Error> {
402        super::require_valid_identifier("imageIdentifier", identifier)?;
403        let call = Call::get("GetMicrovmImage", paths::microvm_image(identifier));
404        let reply = send_accepting(self.transport(), call, &[404]).await?;
405        if reply.status == 404 {
406            return Ok(None);
407        }
408        Ok(Some(reply.json("GetMicrovmImage")?))
409    }
410
411    /// Polls until `identifier` is no longer building, answering what it settled to.
412    async fn await_settled(&self, identifier: &str, opts: WaitOpts) -> Result<Found, Error> {
413        let started = self.clock().elapsed();
414        loop {
415            let found = self
416                .describe_image(identifier)
417                .await?
418                .map_or(Found::Absent, |image| Found::from_state(&image.state));
419            if found != Found::Building {
420                return Ok(found);
421            }
422            let elapsed = self.clock().elapsed().saturating_sub(started);
423            if elapsed >= opts.timeout {
424                return Err(super::timed_out(
425                    &format!("image {identifier} did not finish building"),
426                    elapsed,
427                ));
428            }
429            self.clock().sleep(opts.poll_interval).await;
430        }
431    }
432
433    /// Deletes `identifier` and polls until its name is free (IMAGE-10).
434    ///
435    /// The delete is [`ControlPlane::delete_image`]'s, versions first. The poll ends when the
436    /// describe answers 404, or when the image under the name is no longer the one deleted —
437    /// a sibling rebuilt it, and the create that follows will join that build — or with
438    /// [`ErrorKind::Platform`] for `DELETE_FAILED` and a timeout at
439    /// [`DEFAULT_DELETE_TIMEOUT`].
440    async fn delete_and_await_absent(
441        &self,
442        identifier: &str,
443        deleted_state: &str,
444        poll_interval: Duration,
445    ) -> Result<(), Error> {
446        self.delete_image(identifier, 3, poll_interval).await;
447        self.await_absent(identifier, Some(deleted_state), poll_interval)
448            .await
449    }
450
451    /// Polls until the name is free, treating `deleted_state` — the state a delete was just
452    /// issued against — as a deletion the describe has not caught up with yet.
453    async fn await_absent(
454        &self,
455        identifier: &str,
456        deleted_state: Option<&str>,
457        poll_interval: Duration,
458    ) -> Result<(), Error> {
459        let started = self.clock().elapsed();
460        loop {
461            let Some(image) = self.describe_image(identifier).await? else {
462                return Ok(());
463            };
464            if image.state == "DELETE_FAILED" {
465                return Err(Error::new(
466                    ErrorKind::Platform,
467                    format!(
468                        "image {identifier} reports DELETE_FAILED, so its name cannot be \
469                         freed for a rebuild. Delete it by hand (`aws lambda-microvms \
470                         delete-microvm-image`), or pass a different name prefix."
471                    ),
472                ));
473            }
474            let pending = image.state == "DELETING" || Some(image.state.as_str()) == deleted_state;
475            if !pending {
476                return Ok(());
477            }
478            let elapsed = self.clock().elapsed().saturating_sub(started);
479            if elapsed >= DEFAULT_DELETE_TIMEOUT {
480                return Err(super::timed_out(
481                    &format!("image {identifier} was still present after its deletion"),
482                    elapsed,
483                ));
484            }
485            self.clock().sleep(poll_interval).await;
486        }
487    }
488
489    /// Whether the name is free or being freed: the image a wait was on has gone.
490    async fn vanished(&self, identifier: &str) -> Result<bool, Error> {
491        Ok(match self.describe_image(identifier).await? {
492            None => true,
493            Some(image) => Found::from_state(&image.state) == Found::Deleting,
494        })
495    }
496}
497
498fn image_from_wire(image: ops::GetMicrovmImageResponseWire, size: SizeClass) -> Image {
499    Image {
500        identifier: image.image_arn,
501        name: image.name,
502        version: image.latest_active_image_version.unwrap_or_default(),
503        state: image.state,
504        size,
505        log_group: None,
506        log_stream: None,
507    }
508}
509
510/// Where one `ensure_image` call is, across its describes.
511#[derive(Default)]
512struct Progress {
513    uploaded: bool,
514    /// This call deleted what it found once; a forced rebuild is one-shot.
515    deleted: bool,
516    /// This call went back to the describe after the image it waited on disappeared.
517    redescribed: bool,
518}
519
520/// The remote half of `ensure_image`: describe, decide, and build, reuse, wait, or join.
521pub(crate) async fn ensure(
522    control: &ControlPlane,
523    services: &dyn BuildServices,
524    account: &str,
525    prepared: Prepared,
526) -> Result<EnsuredImage, Error> {
527    let arn = format!(
528        "arn:aws:lambda:{}:{account}:microvm-image:{}",
529        control.region().as_str(),
530        prepared.name
531    );
532    let size = prepared.create.size;
533    let wait = WaitOpts {
534        timeout: prepared.wait_timeout.unwrap_or(DEFAULT_BUILD_TIMEOUT),
535        ..WaitOpts::default()
536    };
537    let done = |image: Image, reused: bool, uploaded: bool| EnsuredImage {
538        image,
539        reused,
540        artifact_uri: prepared.artifact_uri(),
541        uploaded,
542        warnings: prepared.warnings.clone(),
543    };
544    let mut progress = Progress::default();
545
546    loop {
547        let described = control.describe_image(&arn).await?;
548        let found = described
549            .as_ref()
550            .map_or(Found::Absent, |image| Found::from_state(&image.state));
551        let force = prepared.force && !progress.deleted;
552        match plan(found, force) {
553            Plan::Reuse => {
554                let image = described.expect("a ready image was described");
555                return Ok(done(image_from_wire(image, size), true, progress.uploaded));
556            }
557            Plan::Wait if force => {
558                // The service refuses to delete a build in progress: let it settle, then
559                // delete whatever it settled to.
560                let settled = control.await_settled(&arn, wait).await?;
561                if settled != Found::Absent {
562                    let state = control
563                        .describe_image(&arn)
564                        .await?
565                        .map(|image| image.state)
566                        .unwrap_or_default();
567                    control
568                        .delete_and_await_absent(&arn, &state, wait.poll_interval)
569                        .await?;
570                }
571                progress.deleted = true;
572            }
573            Plan::Wait => match control.wait_for_image(&arn, size, wait).await {
574                Ok(image) => return Ok(done(image, true, progress.uploaded)),
575                Err(error) => {
576                    if !progress.redescribed && control.vanished(&arn).await? {
577                        progress.redescribed = true;
578                        continue;
579                    }
580                    return Err(error);
581                }
582            },
583            Plan::Delete => {
584                let state = described.map(|image| image.state).unwrap_or_default();
585                control
586                    .delete_and_await_absent(&arn, &state, wait.poll_interval)
587                    .await?;
588                progress.deleted = true;
589            }
590            Plan::AwaitAbsent => control.await_absent(&arn, None, wait.poll_interval).await?,
591            Plan::Build => {}
592        }
593
594        // The name is free as far as this call knows: upload, create, and wait.
595        if !progress.uploaded {
596            services
597                .put_object(&prepared.bucket, &prepared.key, prepared.artifact.clone())
598                .await?;
599            progress.uploaded = true;
600        }
601        match control.create_image(prepared.create.clone()).await {
602            Ok(created) => match control
603                .wait_for_image(&created.identifier, size, wait)
604                .await
605            {
606                Ok(mut built) => {
607                    // The logging config survives the wait from the create's own record, as
608                    // in `Sandbox::build_image`: the readback cannot carry it.
609                    built.log_group = created.log_group;
610                    built.log_stream = created.log_stream;
611                    return Ok(done(built, false, true));
612                }
613                Err(error) => {
614                    if !progress.redescribed && control.vanished(&arn).await? {
615                        progress.redescribed = true;
616                        continue;
617                    }
618                    return Err(error);
619                }
620            },
621            // IMAGE-11: a refused create is a race lost when something now holds the name.
622            Err(refused) if refused.kind() == ErrorKind::Platform => {
623                match control.describe_image(&arn).await? {
624                    Some(image) if Found::from_state(&image.state) == Found::Ready => {
625                        return Ok(done(image_from_wire(image, size), true, true));
626                    }
627                    Some(image)
628                        if matches!(
629                            Found::from_state(&image.state),
630                            Found::Building | Found::Failed
631                        ) =>
632                    {
633                        // Waits for the winner's build; a failed build answers its own
634                        // diagnosis rather than this call's refused create.
635                        match control.wait_for_image(&arn, size, wait).await {
636                            Ok(image) => return Ok(done(image, true, true)),
637                            Err(error) => {
638                                if !progress.redescribed && control.vanished(&arn).await? {
639                                    progress.redescribed = true;
640                                    continue;
641                                }
642                                return Err(error);
643                            }
644                        }
645                    }
646                    _ if !progress.redescribed => {
647                        progress.redescribed = true;
648                        continue;
649                    }
650                    _ => return Err(refused),
651                }
652            }
653            Err(error) => return Err(error),
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use std::sync::atomic::{AtomicUsize, Ordering};
661    use std::sync::{Arc, Mutex};
662
663    use super::*;
664    use crate::control::fake::{self, Answer, FakeControlPlane, TestClock};
665    use crate::region::Region;
666    use crate::sandbox::Sandbox;
667
668    const ACCOUNT: &str = "123456789012";
669    const BUCKET: &str = "artifact-bucket";
670    const ROLE: &str = "arn:aws:iam::123456789012:role/build";
671
672    /// STS and S3, recorded.
673    #[derive(Default)]
674    struct FakeServices {
675        account_calls: AtomicUsize,
676        puts: Mutex<Vec<(String, String, Vec<u8>)>>,
677    }
678
679    impl BuildServices for FakeServices {
680        fn caller_account(&self) -> futures_util::future::BoxFuture<'_, Result<String, Error>> {
681            self.account_calls.fetch_add(1, Ordering::SeqCst);
682            Box::pin(async { Ok(ACCOUNT.to_string()) })
683        }
684
685        fn put_object<'a>(
686            &'a self,
687            bucket: &'a str,
688            key: &'a str,
689            bytes: Vec<u8>,
690        ) -> futures_util::future::BoxFuture<'a, Result<(), Error>> {
691            self.puts.lock().expect("not poisoned").push((
692                bucket.to_string(),
693                key.to_string(),
694                bytes,
695            ));
696            Box::pin(async { Ok(()) })
697        }
698    }
699
700    fn sandbox(fake: &Arc<FakeControlPlane>, services: &Arc<FakeServices>) -> Sandbox {
701        let plane =
702            ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()));
703        Sandbox::with_control_plane(plane).with_build_services(services.clone())
704    }
705
706    fn dockerfile() -> String {
707        super::super::artifact::wrap_dockerfile(
708            "FROM python:3.12-slim\nWORKDIR /app\n",
709            &super::super::artifact::WrapOptions::default(),
710        )
711        .expect("wraps")
712    }
713
714    fn request() -> EnsureImageRequest {
715        let mut request =
716            EnsureImageRequest::new("task", b"daemon".to_vec(), dockerfile(), BUCKET, ROLE);
717        request.s3_key_prefix = Some("harbor/images".to_string());
718        request
719    }
720
721    /// The name `request()` derives, computed from the public pieces.
722    fn expected_name() -> String {
723        let request = request();
724        let base = BaseImage::from_dockerfile(&request.dockerfile).expect("a FROM");
725        let hash = super::super::artifact::artifact_content_hash_with_context(
726            &request.binary,
727            &request.dockerfile,
728            None,
729            None,
730        );
731        ensured_image_name("task", &image_identity_hash(&hash, &base, request.size))
732            .expect("a legal name")
733    }
734
735    /// `GetMicrovmImageOutput` for a usable image.
736    fn ready(name: &str) -> String {
737        format!(
738            r#"{{"imageArn": "arn:aws:lambda:us-east-1:{ACCOUNT}:microvm-image:{name}",
739                "name": "{name}", "state": "CREATED", "latestActiveImageVersion": "1",
740                "createdAt": 1754524800, "updatedAt": 1754528400, "tags": {{}}}}"#
741        )
742    }
743
744    fn state(name: &str, state: &str) -> Answer {
745        Answer::ok(fake::get_image_response(name, state))
746    }
747
748    fn absent() -> Answer {
749        Answer::failure(404, "Image not found")
750    }
751
752    fn uploads(services: &FakeServices) -> Vec<(String, String)> {
753        services
754            .puts
755            .lock()
756            .expect("not poisoned")
757            .iter()
758            .map(|(bucket, key, _)| (bucket.clone(), key.clone()))
759            .collect()
760    }
761
762    /// **The table `model/src/image.rs` specifies**, row for row (`the_plan_table` there).
763    #[test]
764    fn the_plan_table() {
765        for (found, force, expected) in [
766            (Found::Absent, false, Plan::Build),
767            (Found::Absent, true, Plan::Build),
768            (Found::Ready, false, Plan::Reuse),
769            (Found::Ready, true, Plan::Delete),
770            (Found::Building, false, Plan::Wait),
771            (Found::Building, true, Plan::Wait),
772            (Found::Failed, false, Plan::Delete),
773            (Found::Failed, true, Plan::Delete),
774            (Found::Deleting, false, Plan::AwaitAbsent),
775            (Found::Deleting, true, Plan::AwaitAbsent),
776        ] {
777            assert_eq!(plan(found, force), expected, "{found:?} force={force}");
778        }
779        for (spelling, found) in [
780            ("CREATING", Found::Building),
781            ("UPDATING", Found::Building),
782            ("CREATED", Found::Ready),
783            ("UPDATED", Found::Ready),
784            ("ACTIVE", Found::Ready),
785            ("CREATE_FAILED", Found::Failed),
786            ("UPDATE_FAILED", Found::Failed),
787            ("DELETE_FAILED", Found::Failed),
788            ("DELETING", Found::Deleting),
789            ("DELETED", Found::Absent),
790        ] {
791            assert_eq!(Found::from_state(spelling), found, "{spelling}");
792        }
793    }
794
795    /// **IMAGE-6, the name.** `<prefix>-<hash12>`, the prefix reduced to the characters the
796    /// service's `ImageName` admits and cut so the whole fits its 64; a prefix with nothing
797    /// left is refused.
798    #[test]
799    fn the_name_is_the_sanitized_prefix_and_twelve_hex() {
800        let hash = "0123456789abcdef".repeat(4);
801        assert_eq!(
802            ensured_image_name("harbor/task:1", &hash).expect("legal"),
803            "harbor-task-1-0123456789ab"
804        );
805        let long = ensured_image_name(&"p".repeat(100), &hash).expect("legal");
806        assert_eq!(long.len(), 64, "IMAGE-6: {long}");
807        assert!(long.ends_with("-0123456789ab"));
808        super::super::require_valid_image_name(&long).expect("the service's pattern");
809        for empty in ["", "///", "   "] {
810            let error = ensured_image_name(empty, &hash).expect_err("nothing left");
811            assert_eq!(error.kind(), ErrorKind::InvalidArg);
812        }
813    }
814
815    /// **IMAGE-6, the identity.** The base image and the size class are part of the name,
816    /// because an image is created at one size on one base: two tasks with one Dockerfile and
817    /// different sizes need two images.
818    #[test]
819    fn the_identity_covers_the_base_and_the_size() {
820        let hash = "a".repeat(64);
821        let base = BaseImage::al2023();
822        let identity = image_identity_hash(&hash, &base, SizeClass::DEFAULT);
823        assert_eq!(
824            identity,
825            image_identity_hash(&hash, &base, SizeClass::DEFAULT)
826        );
827        assert_eq!(identity.len(), 64);
828        let other_size = SizeClass::from_baseline_mib(4096).expect("a class");
829        assert_ne!(
830            identity,
831            image_identity_hash(&hash, &base, other_size),
832            "IMAGE-6: size"
833        );
834        let other_base = BaseImage {
835            name: "al2023-2".to_string(),
836            ..BaseImage::al2023()
837        };
838        assert_ne!(
839            identity,
840            image_identity_hash(&hash, &other_base, SizeClass::DEFAULT)
841        );
842        assert_ne!(
843            identity,
844            image_identity_hash(&"b".repeat(64), &base, SizeClass::DEFAULT)
845        );
846    }
847
848    /// **IMAGE-8, the key.** `<prefix>/<name>/artifact.zip`, with the prefix's slashes
849    /// trimmed so no key carries an empty segment.
850    #[test]
851    fn the_artifact_key_is_content_addressed() {
852        assert_eq!(
853            artifact_key(Some("harbor/images"), "task-0123456789ab"),
854            "harbor/images/task-0123456789ab/artifact.zip"
855        );
856        assert_eq!(artifact_key(Some("/harbor/"), "n"), "harbor/n/artifact.zip");
857        assert_eq!(artifact_key(None, "n"), "n/artifact.zip");
858        assert_eq!(artifact_key(Some(""), "n"), "n/artifact.zip");
859    }
860
861    /// **IMAGE-9, reuse.** A ready image is returned as it is: one account lookup, one
862    /// describe, no upload, no create.
863    #[tokio::test]
864    async fn a_ready_image_is_reused_with_no_upload_and_no_create() {
865        let name = expected_name();
866        let fake = Arc::new(FakeControlPlane::new());
867        fake.answer("GetMicrovmImage", Answer::ok(ready(&name)));
868        let services = Arc::new(FakeServices::default());
869        let ensured = sandbox(&fake, &services)
870            .ensure_image(request())
871            .await
872            .expect("reused");
873        assert!(ensured.reused, "IMAGE-9");
874        assert!(!ensured.uploaded);
875        assert_eq!(ensured.image.name, name);
876        assert_eq!(ensured.image.version, "1");
877        assert_eq!(fake.operations(), ["GetMicrovmImage"], "IMAGE-9");
878        assert!(uploads(&services).is_empty());
879        assert!(
880            fake.paths()[0].contains(&paths_encoded(&format!(
881                "arn:aws:lambda:us-east-1:{ACCOUNT}:microvm-image:{name}"
882            ))),
883            "IMAGE-8: the describe names the ARN built from the account: {:?}",
884            fake.paths()
885        );
886    }
887
888    fn paths_encoded(arn: &str) -> String {
889        crate::control::transport::paths::encode_segment(arn)
890    }
891
892    /// **IMAGE-9, wait.** A build already running under the name is waited out and reused;
893    /// this caller uploads and creates nothing.
894    #[tokio::test]
895    async fn a_running_build_is_waited_out_and_reused() {
896        let name = expected_name();
897        let fake = Arc::new(FakeControlPlane::new());
898        fake.answer("GetMicrovmImage", state(&name, "CREATING"))
899            .answer("GetMicrovmImage", Answer::ok(ready(&name)));
900        let services = Arc::new(FakeServices::default());
901        let ensured = sandbox(&fake, &services)
902            .ensure_image(request())
903            .await
904            .expect("waited");
905        assert!(ensured.reused, "IMAGE-9");
906        assert_eq!(fake.call_count("CreateMicrovmImage"), 0, "IMAGE-9");
907        assert!(uploads(&services).is_empty(), "IMAGE-9");
908    }
909
910    /// **IMAGE-8, the build.** No image under the name: the artifact goes to the
911    /// content-addressed key, the create names that URI and the derived name, and the image
912    /// this call built is not reused.
913    #[tokio::test]
914    async fn an_absent_image_is_uploaded_to_its_key_and_built() {
915        let name = expected_name();
916        let fake = Arc::new(FakeControlPlane::new());
917        fake.answer("GetMicrovmImage", absent())
918            .answer("GetMicrovmImage", state(&name, "CREATING"))
919            .answer("GetMicrovmImage", Answer::ok(ready(&name)))
920            .answer(
921                "CreateMicrovmImage",
922                Answer::created(fake::create_image_response(&name)),
923            );
924        let services = Arc::new(FakeServices::default());
925        let mut request = request();
926        request
927            .tags
928            .insert("harbor:task".to_string(), "t".to_string());
929        let ensured = sandbox(&fake, &services)
930            .ensure_image(request)
931            .await
932            .expect("built");
933        assert!(!ensured.reused);
934        assert!(ensured.uploaded);
935        let key = format!("harbor/images/{name}/artifact.zip");
936        assert_eq!(
937            uploads(&services),
938            [(BUCKET.to_string(), key.clone())],
939            "IMAGE-8"
940        );
941        assert_eq!(ensured.artifact_uri, format!("s3://{BUCKET}/{key}"));
942        let body = fake.first_body("CreateMicrovmImage");
943        assert_eq!(body["name"], name.as_str());
944        assert_eq!(body["codeArtifact"]["uri"], ensured.artifact_uri.as_str());
945        assert_eq!(body["tags"]["harbor:task"], "t");
946        assert_eq!(
947            body["baseImageArn"], "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
948            "the derived base keeps the managed name"
949        );
950        assert_eq!(services.account_calls.load(Ordering::SeqCst), 1);
951
952        // The uploaded bytes are the artifact the request describes.
953        let (_, _, bytes) = services.puts.lock().expect("not poisoned")[0].clone();
954        let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a zip");
955        assert_eq!(archive.len(), 2);
956    }
957
958    /// **IMAGE-10, a failure.** A failed image is deleted, the name awaited free, and then
959    /// rebuilt; the delete comes before the create.
960    #[tokio::test]
961    async fn a_failed_image_is_deleted_then_rebuilt() {
962        let name = expected_name();
963        let fake = Arc::new(FakeControlPlane::new());
964        fake.answer("GetMicrovmImage", state(&name, "CREATE_FAILED"))
965            .answer("GetMicrovmImage", absent())
966            .answer("GetMicrovmImage", state(&name, "CREATING"))
967            .answer("GetMicrovmImage", Answer::ok(ready(&name)))
968            .answer(
969                "ListMicrovmImageVersions",
970                Answer::ok(fake::list_versions_response("1")),
971            )
972            .answer(
973                "DeleteMicrovmImage",
974                Answer::ok(fake::delete_image_response()),
975            )
976            .answer(
977                "CreateMicrovmImage",
978                Answer::created(fake::create_image_response(&name)),
979            );
980        let services = Arc::new(FakeServices::default());
981        let ensured = sandbox(&fake, &services)
982            .ensure_image(request())
983            .await
984            .expect("rebuilt");
985        assert!(!ensured.reused);
986        let operations = fake.operations();
987        let deleted = operations
988            .iter()
989            .position(|op| *op == "DeleteMicrovmImage")
990            .expect("IMAGE-10: deleted");
991        let created = operations
992            .iter()
993            .position(|op| *op == "CreateMicrovmImage")
994            .expect("rebuilt");
995        assert!(deleted < created, "IMAGE-10: {operations:?}");
996    }
997
998    /// **IMAGE-10, force.** A ready image is rebuilt only when the caller forces it.
999    #[tokio::test]
1000    async fn a_ready_image_is_rebuilt_only_under_force() {
1001        let name = expected_name();
1002        let fake = Arc::new(FakeControlPlane::new());
1003        fake.answer("GetMicrovmImage", Answer::ok(ready(&name)))
1004            .answer("GetMicrovmImage", absent())
1005            .answer("GetMicrovmImage", state(&name, "CREATING"))
1006            .answer("GetMicrovmImage", Answer::ok(ready(&name)))
1007            .answer(
1008                "ListMicrovmImageVersions",
1009                Answer::ok(fake::list_versions_response("1")),
1010            )
1011            .answer(
1012                "DeleteMicrovmImage",
1013                Answer::ok(fake::delete_image_response()),
1014            )
1015            .answer(
1016                "CreateMicrovmImage",
1017                Answer::created(fake::create_image_response(&name)),
1018            );
1019        let services = Arc::new(FakeServices::default());
1020        let mut forced = request();
1021        forced.force = true;
1022        let ensured = sandbox(&fake, &services)
1023            .ensure_image(forced)
1024            .await
1025            .expect("rebuilt");
1026        assert!(!ensured.reused, "IMAGE-10");
1027        assert_eq!(fake.call_count("DeleteMicrovmImage"), 1, "IMAGE-10");
1028        assert_eq!(fake.call_count("CreateMicrovmImage"), 1);
1029    }
1030
1031    /// **IMAGE-10, a deletion under way.** The name is awaited free, then built.
1032    #[tokio::test]
1033    async fn a_deletion_under_way_is_awaited_then_built() {
1034        let name = expected_name();
1035        let fake = Arc::new(FakeControlPlane::new());
1036        fake.answer("GetMicrovmImage", state(&name, "DELETING"))
1037            .answer("GetMicrovmImage", absent())
1038            .answer("GetMicrovmImage", state(&name, "CREATING"))
1039            .answer("GetMicrovmImage", Answer::ok(ready(&name)))
1040            .answer(
1041                "CreateMicrovmImage",
1042                Answer::created(fake::create_image_response(&name)),
1043            );
1044        let services = Arc::new(FakeServices::default());
1045        let ensured = sandbox(&fake, &services)
1046            .ensure_image(request())
1047            .await
1048            .expect("built");
1049        assert!(!ensured.reused);
1050        assert_eq!(
1051            fake.call_count("DeleteMicrovmImage"),
1052            0,
1053            "not this caller's delete"
1054        );
1055    }
1056
1057    /// **IMAGE-11, the race.** The create is refused because a sibling created the name
1058    /// first: describe again, wait for the sibling's build, and return it as reused.
1059    #[tokio::test]
1060    async fn a_refused_create_joins_the_winners_build() {
1061        let name = expected_name();
1062        let fake = Arc::new(FakeControlPlane::new());
1063        fake.answer("GetMicrovmImage", absent())
1064            .answer("GetMicrovmImage", state(&name, "CREATING"))
1065            .answer("GetMicrovmImage", state(&name, "CREATING"))
1066            .answer("GetMicrovmImage", Answer::ok(ready(&name)))
1067            .answer(
1068                "CreateMicrovmImage",
1069                Answer::failure(409, "An image with this name already exists"),
1070            );
1071        let services = Arc::new(FakeServices::default());
1072        let ensured = sandbox(&fake, &services)
1073            .ensure_image(request())
1074            .await
1075            .expect("IMAGE-11: joined");
1076        assert!(
1077            ensured.reused,
1078            "IMAGE-11: this caller's create did not build it"
1079        );
1080        assert_eq!(
1081            ensured.image.state, "CREATED",
1082            "IMAGE-11: returned only once ready"
1083        );
1084        assert_eq!(fake.call_count("CreateMicrovmImage"), 1);
1085    }
1086
1087    /// **IMAGE-11, not a race.** A refused create with nothing under the name afterwards is
1088    /// the create's own failure, surfaced as it came.
1089    #[tokio::test]
1090    async fn a_refused_create_with_no_winner_surfaces_the_refusal() {
1091        let fake = Arc::new(FakeControlPlane::new());
1092        fake.answer("GetMicrovmImage", absent()).answer(
1093            "CreateMicrovmImage",
1094            Answer::failure(400, "buildRoleArn is invalid"),
1095        );
1096        let services = Arc::new(FakeServices::default());
1097        let error = sandbox(&fake, &services)
1098            .ensure_image(request())
1099            .await
1100            .expect_err("no winner");
1101        assert!(
1102            error.to_string().contains("buildRoleArn is invalid"),
1103            "{error}"
1104        );
1105    }
1106
1107    /// **IMAGE-8, once per sandbox.** Two ensures on one sandbox make one account lookup.
1108    #[tokio::test]
1109    async fn the_account_is_resolved_once_per_sandbox() {
1110        let name = expected_name();
1111        let fake = Arc::new(FakeControlPlane::new());
1112        fake.answer("GetMicrovmImage", Answer::ok(ready(&name)));
1113        let services = Arc::new(FakeServices::default());
1114        let mut sandbox = sandbox(&fake, &services);
1115        sandbox.ensure_image(request()).await.expect("first");
1116        sandbox.ensure_image(request()).await.expect("second");
1117        assert_eq!(services.account_calls.load(Ordering::SeqCst), 1, "IMAGE-8");
1118    }
1119
1120    /// Every local refusal costs zero calls — not the account lookup, not a describe, not
1121    /// the upload.
1122    #[tokio::test]
1123    async fn a_locally_refused_request_makes_no_call() {
1124        let fake = Arc::new(FakeControlPlane::new());
1125        let services = Arc::new(FakeServices::default());
1126        let mut sandbox = sandbox(&fake, &services);
1127        for (mutate, cause) in [
1128            (
1129                Box::new(|r: &mut EnsureImageRequest| {
1130                    r.dockerfile = "FROM x\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n".into()
1131                }) as Box<dyn Fn(&mut EnsureImageRequest)>,
1132                "ENTRYPOINT",
1133            ),
1134            (Box::new(|r| r.name_prefix = "//".into()), "prefix"),
1135            (Box::new(|r| r.s3_bucket = String::new()), "bucket"),
1136            (
1137                Box::new(|r| r.build_role_arn = "not-an-arn".into()),
1138                "buildRoleArn",
1139            ),
1140            (
1141                Box::new(|r| r.s3_key_prefix = Some("a\nb".into())),
1142                "key prefix",
1143            ),
1144        ] {
1145            let mut request = request();
1146            mutate(&mut request);
1147            let error = sandbox.ensure_image(request).await.expect_err(cause);
1148            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
1149            assert!(error.to_string().contains(cause), "{cause}: {error}");
1150        }
1151        assert!(fake.calls().is_empty());
1152        assert_eq!(services.account_calls.load(Ordering::SeqCst), 0);
1153        assert!(uploads(&services).is_empty());
1154    }
1155
1156    /// The build wait honours `wait_timeout`: a build that never settles is a timeout at the
1157    /// caller's deadline, not the 45-minute default.
1158    #[tokio::test]
1159    async fn the_wait_timeout_bounds_the_build_wait() {
1160        let name = expected_name();
1161        let fake = Arc::new(FakeControlPlane::new());
1162        fake.answer("GetMicrovmImage", absent())
1163            .answer("GetMicrovmImage", state(&name, "CREATING"))
1164            .answer(
1165                "CreateMicrovmImage",
1166                Answer::created(fake::create_image_response(&name)),
1167            )
1168            .answer(
1169                "ListMicrovmImageBuilds",
1170                Answer::ok(fake::list_builds_response("IN_PROGRESS")),
1171            )
1172            .answer(
1173                "ListMicrovmImageVersions",
1174                Answer::ok(fake::list_versions_response("1")),
1175            );
1176        let services = Arc::new(FakeServices::default());
1177        let mut request = request();
1178        request.wait_timeout = Some(Duration::from_secs(60));
1179        let error = sandbox(&fake, &services)
1180            .ensure_image(request)
1181            .await
1182            .expect_err("never settles");
1183        assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
1184        assert!(
1185            fake.call_count("GetMicrovmImage") < 10,
1186            "bounded by 60s, not 45 minutes"
1187        );
1188    }
1189}