1use 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
54pub const DEFAULT_DELETE_TIMEOUT: Duration = Duration::from_secs(300);
59
60const MAX_IMAGE_NAME: usize = 64;
62
63const NAME_HASH_LEN: usize = 12;
65
66#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub enum Found {
69 Absent,
71 Building,
73 Ready,
75 Failed,
77 Deleting,
79}
80
81impl Found {
82 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
100pub enum Plan {
101 Reuse,
103 Wait,
105 Delete,
107 AwaitAbsent,
109 Build,
111}
112
113pub 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
131pub 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
163pub 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
183pub 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
198fn 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
218fn 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#[derive(Clone, Debug)]
241pub struct EnsureImageRequest {
242 pub name_prefix: String,
244 pub binary: Vec<u8>,
246 pub dockerfile: String,
249 pub context: Option<BuildContext>,
251 pub s3_bucket: String,
253 pub s3_key_prefix: Option<String>,
255 pub build_role_arn: String,
258 pub size: SizeClass,
260 pub base_image: Option<BaseImage>,
262 pub force: bool,
264 pub tags: BTreeMap<String, String>,
266 pub wait_timeout: Option<Duration>,
268}
269
270impl EnsureImageRequest {
271 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#[derive(Clone, Debug)]
299pub struct EnsuredImage {
300 pub image: Image,
302 pub reused: bool,
305 pub artifact_uri: String,
307 pub uploaded: bool,
309 pub warnings: Vec<String>,
311}
312
313#[derive(Clone, Debug)]
316pub struct Prepared {
317 pub name: String,
319 pub bucket: String,
320 pub key: String,
321 pub artifact: Vec<u8>,
323 pub create: CreateImageRequest,
325 pub force: bool,
326 pub wait_timeout: Option<Duration>,
327 pub warnings: Vec<String>,
328}
329
330impl Prepared {
331 pub fn artifact_uri(&self) -> String {
333 format!("s3://{}/{}", self.bucket, self.key)
334 }
335}
336
337pub 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 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 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 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 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 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#[derive(Default)]
512struct Progress {
513 uploaded: bool,
514 deleted: bool,
516 redescribed: bool,
518}
519
520pub(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 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 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 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}