use std::collections::BTreeMap;
use std::time::Duration;
use super::artifact::{BaseImage, artifact_content_hash_with_context, build_artifact_with_context};
use super::context::BuildContext;
use super::image::{DEFAULT_BUILD_TIMEOUT, Image, WaitOpts};
use super::services::BuildServices;
use super::transport::{Call, paths, send_accepting};
use super::{ControlPlane, CreateImageRequest, ops};
use crate::error::{Error, ErrorKind};
use crate::sizing::SizeClass;
pub const DEFAULT_DELETE_TIMEOUT: Duration = Duration::from_secs(300);
const MAX_IMAGE_NAME: usize = 64;
const NAME_HASH_LEN: usize = 12;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Found {
Absent,
Building,
Ready,
Failed,
Deleting,
}
impl Found {
pub fn from_state(state: &str) -> Self {
if Image::is_ready(state) {
Found::Ready
} else if Image::is_failed(state) {
Found::Failed
} else if state == "DELETING" {
Found::Deleting
} else if state == "DELETED" {
Found::Absent
} else {
Found::Building
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Plan {
Reuse,
Wait,
Delete,
AwaitAbsent,
Build,
}
pub fn plan(found: Found, force: bool) -> Plan {
match (found, force) {
(Found::Absent, _) => Plan::Build,
(Found::Ready, false) => Plan::Reuse,
(Found::Ready, true) | (Found::Failed, _) => Plan::Delete,
(Found::Building, _) => Plan::Wait,
(Found::Deleting, _) => Plan::AwaitAbsent,
}
}
pub fn ensured_image_name(prefix: &str, identity_hash: &str) -> Result<String, Error> {
let sanitized: String = prefix
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let room = MAX_IMAGE_NAME - 1 - NAME_HASH_LEN;
let cut: String = sanitized.trim_matches('-').chars().take(room).collect();
let cut = cut.trim_end_matches('-');
if cut.is_empty() {
return Err(Error::invalid_arg(format!(
"the image name prefix {prefix:?} has no character the service's ImageName pattern \
admits ([a-zA-Z0-9-_]), so the name would be a bare hash. Pass a prefix that says \
what the image is for, such as the task's name."
)));
}
let hash: String = identity_hash.chars().take(NAME_HASH_LEN).collect();
Ok(format!("{cut}-{hash}"))
}
pub fn image_identity_hash(artifact_hash: &str, base: &BaseImage, size: SizeClass) -> String {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
for field in [
b"microvms-ensure-image/1".as_slice(),
artifact_hash.as_bytes(),
base.name.as_bytes(),
] {
hasher.update((field.len() as u64).to_be_bytes());
hasher.update(field);
}
hasher.update(size.baseline_mib().to_be_bytes());
const_hex::encode(hasher.finalize())
}
pub fn artifact_key(key_prefix: Option<&str>, name: &str) -> String {
let prefix: Vec<&str> = key_prefix
.unwrap_or("")
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
if prefix.is_empty() {
format!("{name}/artifact.zip")
} else {
format!("{}/{name}/artifact.zip", prefix.join("/"))
}
}
fn require_valid_bucket(bucket: &str) -> Result<(), Error> {
let chars_ok = bucket
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-');
let ends_ok = bucket
.bytes()
.next()
.zip(bucket.bytes().last())
.is_some_and(|(first, last)| first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric());
if (3..=63).contains(&bucket.len()) && chars_ok && ends_ok && !bucket.contains("..") {
return Ok(());
}
Err(Error::invalid_arg(format!(
"the S3 bucket {bucket:?} is not a bucket name S3 admits: 3 to 63 characters of \
lowercase letters, digits, `.` and `-`, starting and ending with a letter or digit. \
Pass the bucket the build role can read, in the sandbox's region."
)))
}
fn require_valid_key_prefix(prefix: Option<&str>, name: &str) -> Result<(), Error> {
let Some(prefix) = prefix else {
return Ok(());
};
if prefix.chars().any(char::is_control) {
return Err(Error::invalid_arg(format!(
"the S3 key prefix {prefix:?} carries a control character, which no S3 key \
should; pass a plain path such as `harbor/images`."
)));
}
let key = artifact_key(Some(prefix), name);
if key.len() > 1024 {
return Err(Error::invalid_arg(format!(
"the S3 key prefix makes a {}-byte key, over S3's 1024; shorten it.",
key.len()
)));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct EnsureImageRequest {
pub name_prefix: String,
pub binary: Vec<u8>,
pub dockerfile: String,
pub context: Option<BuildContext>,
pub s3_bucket: String,
pub s3_key_prefix: Option<String>,
pub build_role_arn: String,
pub size: SizeClass,
pub base_image: Option<BaseImage>,
pub force: bool,
pub tags: BTreeMap<String, String>,
pub wait_timeout: Option<Duration>,
}
impl EnsureImageRequest {
pub fn new(
name_prefix: impl Into<String>,
binary: Vec<u8>,
dockerfile: impl Into<String>,
s3_bucket: impl Into<String>,
build_role_arn: impl Into<String>,
) -> Self {
Self {
name_prefix: name_prefix.into(),
binary,
dockerfile: dockerfile.into(),
context: None,
s3_bucket: s3_bucket.into(),
s3_key_prefix: None,
build_role_arn: build_role_arn.into(),
size: SizeClass::DEFAULT,
base_image: None,
force: false,
tags: BTreeMap::new(),
wait_timeout: None,
}
}
}
#[derive(Clone, Debug)]
pub struct EnsuredImage {
pub image: Image,
pub reused: bool,
pub artifact_uri: String,
pub uploaded: bool,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct Prepared {
pub name: String,
pub bucket: String,
pub key: String,
pub artifact: Vec<u8>,
pub create: CreateImageRequest,
pub force: bool,
pub wait_timeout: Option<Duration>,
pub warnings: Vec<String>,
}
impl Prepared {
pub fn artifact_uri(&self) -> String {
format!("s3://{}/{}", self.bucket, self.key)
}
}
pub fn prepare(control: &ControlPlane, request: EnsureImageRequest) -> Result<Prepared, Error> {
require_valid_bucket(&request.s3_bucket)?;
let base = match request.base_image {
Some(base) => base,
None => BaseImage::from_dockerfile(&request.dockerfile)?,
};
let artifact_hash = artifact_content_hash_with_context(
&request.binary,
&request.dockerfile,
None,
request.context.as_ref(),
);
let name = ensured_image_name(
&request.name_prefix,
&image_identity_hash(&artifact_hash, &base, request.size),
)?;
require_valid_key_prefix(request.s3_key_prefix.as_deref(), &name)?;
let key = artifact_key(request.s3_key_prefix.as_deref(), &name);
let mut create = CreateImageRequest::new(
name.clone(),
request.binary,
format!("s3://{}/{key}", request.s3_bucket),
request.build_role_arn,
);
create.base_image = base;
create.dockerfile = Some(request.dockerfile);
create.size = request.size;
create.tags = request.tags;
create.token_scope = Some(name.clone());
control.preflight(&create)?;
let artifact = build_artifact_with_context(
&create.binary,
create.dockerfile.as_deref().unwrap_or_default(),
None,
request.context.as_ref(),
)?;
Ok(Prepared {
name,
bucket: request.s3_bucket,
key,
artifact,
create,
force: request.force,
wait_timeout: request.wait_timeout,
warnings: request
.context
.map(|context| context.warnings().to_vec())
.unwrap_or_default(),
})
}
impl ControlPlane {
pub async fn describe_image(
&self,
identifier: &str,
) -> Result<Option<ops::GetMicrovmImageResponseWire>, Error> {
super::require_valid_identifier("imageIdentifier", identifier)?;
let call = Call::get("GetMicrovmImage", paths::microvm_image(identifier));
let reply = send_accepting(self.transport(), call, &[404]).await?;
if reply.status == 404 {
return Ok(None);
}
Ok(Some(reply.json("GetMicrovmImage")?))
}
async fn await_settled(&self, identifier: &str, opts: WaitOpts) -> Result<Found, Error> {
let started = self.clock().elapsed();
loop {
let found = self
.describe_image(identifier)
.await?
.map_or(Found::Absent, |image| Found::from_state(&image.state));
if found != Found::Building {
return Ok(found);
}
let elapsed = self.clock().elapsed().saturating_sub(started);
if elapsed >= opts.timeout {
return Err(super::timed_out(
&format!("image {identifier} did not finish building"),
elapsed,
));
}
self.clock().sleep(opts.poll_interval).await;
}
}
async fn delete_and_await_absent(
&self,
identifier: &str,
deleted_state: &str,
poll_interval: Duration,
) -> Result<(), Error> {
self.delete_image(identifier, 3, poll_interval).await;
self.await_absent(identifier, Some(deleted_state), poll_interval)
.await
}
async fn await_absent(
&self,
identifier: &str,
deleted_state: Option<&str>,
poll_interval: Duration,
) -> Result<(), Error> {
let started = self.clock().elapsed();
loop {
let Some(image) = self.describe_image(identifier).await? else {
return Ok(());
};
if image.state == "DELETE_FAILED" {
return Err(Error::new(
ErrorKind::Platform,
format!(
"image {identifier} reports DELETE_FAILED, so its name cannot be \
freed for a rebuild. Delete it by hand (`aws lambda-microvms \
delete-microvm-image`), or pass a different name prefix."
),
));
}
let pending = image.state == "DELETING" || Some(image.state.as_str()) == deleted_state;
if !pending {
return Ok(());
}
let elapsed = self.clock().elapsed().saturating_sub(started);
if elapsed >= DEFAULT_DELETE_TIMEOUT {
return Err(super::timed_out(
&format!("image {identifier} was still present after its deletion"),
elapsed,
));
}
self.clock().sleep(poll_interval).await;
}
}
async fn vanished(&self, identifier: &str) -> Result<bool, Error> {
Ok(match self.describe_image(identifier).await? {
None => true,
Some(image) => Found::from_state(&image.state) == Found::Deleting,
})
}
}
fn image_from_wire(image: ops::GetMicrovmImageResponseWire, size: SizeClass) -> Image {
Image {
identifier: image.image_arn,
name: image.name,
version: image.latest_active_image_version.unwrap_or_default(),
state: image.state,
size,
log_group: None,
log_stream: None,
}
}
#[derive(Default)]
struct Progress {
uploaded: bool,
deleted: bool,
redescribed: bool,
}
pub(crate) async fn ensure(
control: &ControlPlane,
services: &dyn BuildServices,
account: &str,
prepared: Prepared,
) -> Result<EnsuredImage, Error> {
let arn = format!(
"arn:aws:lambda:{}:{account}:microvm-image:{}",
control.region().as_str(),
prepared.name
);
let size = prepared.create.size;
let wait = WaitOpts {
timeout: prepared.wait_timeout.unwrap_or(DEFAULT_BUILD_TIMEOUT),
..WaitOpts::default()
};
let done = |image: Image, reused: bool, uploaded: bool| EnsuredImage {
image,
reused,
artifact_uri: prepared.artifact_uri(),
uploaded,
warnings: prepared.warnings.clone(),
};
let mut progress = Progress::default();
loop {
let described = control.describe_image(&arn).await?;
let found = described
.as_ref()
.map_or(Found::Absent, |image| Found::from_state(&image.state));
let force = prepared.force && !progress.deleted;
match plan(found, force) {
Plan::Reuse => {
let image = described.expect("a ready image was described");
return Ok(done(image_from_wire(image, size), true, progress.uploaded));
}
Plan::Wait if force => {
let settled = control.await_settled(&arn, wait).await?;
if settled != Found::Absent {
let state = control
.describe_image(&arn)
.await?
.map(|image| image.state)
.unwrap_or_default();
control
.delete_and_await_absent(&arn, &state, wait.poll_interval)
.await?;
}
progress.deleted = true;
}
Plan::Wait => match control.wait_for_image(&arn, size, wait).await {
Ok(image) => return Ok(done(image, true, progress.uploaded)),
Err(error) => {
if !progress.redescribed && control.vanished(&arn).await? {
progress.redescribed = true;
continue;
}
return Err(error);
}
},
Plan::Delete => {
let state = described.map(|image| image.state).unwrap_or_default();
control
.delete_and_await_absent(&arn, &state, wait.poll_interval)
.await?;
progress.deleted = true;
}
Plan::AwaitAbsent => control.await_absent(&arn, None, wait.poll_interval).await?,
Plan::Build => {}
}
if !progress.uploaded {
services
.put_object(&prepared.bucket, &prepared.key, prepared.artifact.clone())
.await?;
progress.uploaded = true;
}
match control.create_image(prepared.create.clone()).await {
Ok(created) => match control
.wait_for_image(&created.identifier, size, wait)
.await
{
Ok(mut built) => {
built.log_group = created.log_group;
built.log_stream = created.log_stream;
return Ok(done(built, false, true));
}
Err(error) => {
if !progress.redescribed && control.vanished(&arn).await? {
progress.redescribed = true;
continue;
}
return Err(error);
}
},
Err(refused) if refused.kind() == ErrorKind::Platform => {
match control.describe_image(&arn).await? {
Some(image) if Found::from_state(&image.state) == Found::Ready => {
return Ok(done(image_from_wire(image, size), true, true));
}
Some(image)
if matches!(
Found::from_state(&image.state),
Found::Building | Found::Failed
) =>
{
match control.wait_for_image(&arn, size, wait).await {
Ok(image) => return Ok(done(image, true, true)),
Err(error) => {
if !progress.redescribed && control.vanished(&arn).await? {
progress.redescribed = true;
continue;
}
return Err(error);
}
}
}
_ if !progress.redescribed => {
progress.redescribed = true;
continue;
}
_ => return Err(refused),
}
}
Err(error) => return Err(error),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::*;
use crate::control::fake::{self, Answer, FakeControlPlane, TestClock};
use crate::region::Region;
use crate::sandbox::Sandbox;
const ACCOUNT: &str = "123456789012";
const BUCKET: &str = "artifact-bucket";
const ROLE: &str = "arn:aws:iam::123456789012:role/build";
#[derive(Default)]
struct FakeServices {
account_calls: AtomicUsize,
puts: Mutex<Vec<(String, String, Vec<u8>)>>,
}
impl BuildServices for FakeServices {
fn caller_account(&self) -> futures_util::future::BoxFuture<'_, Result<String, Error>> {
self.account_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Ok(ACCOUNT.to_string()) })
}
fn put_object<'a>(
&'a self,
bucket: &'a str,
key: &'a str,
bytes: Vec<u8>,
) -> futures_util::future::BoxFuture<'a, Result<(), Error>> {
self.puts.lock().expect("not poisoned").push((
bucket.to_string(),
key.to_string(),
bytes,
));
Box::pin(async { Ok(()) })
}
}
fn sandbox(fake: &Arc<FakeControlPlane>, services: &Arc<FakeServices>) -> Sandbox {
let plane =
ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()));
Sandbox::with_control_plane(plane).with_build_services(services.clone())
}
fn dockerfile() -> String {
super::super::artifact::wrap_dockerfile(
"FROM python:3.12-slim\nWORKDIR /app\n",
&super::super::artifact::WrapOptions::default(),
)
.expect("wraps")
}
fn request() -> EnsureImageRequest {
let mut request =
EnsureImageRequest::new("task", b"daemon".to_vec(), dockerfile(), BUCKET, ROLE);
request.s3_key_prefix = Some("harbor/images".to_string());
request
}
fn expected_name() -> String {
let request = request();
let base = BaseImage::from_dockerfile(&request.dockerfile).expect("a FROM");
let hash = super::super::artifact::artifact_content_hash_with_context(
&request.binary,
&request.dockerfile,
None,
None,
);
ensured_image_name("task", &image_identity_hash(&hash, &base, request.size))
.expect("a legal name")
}
fn ready(name: &str) -> String {
format!(
r#"{{"imageArn": "arn:aws:lambda:us-east-1:{ACCOUNT}:microvm-image:{name}",
"name": "{name}", "state": "CREATED", "latestActiveImageVersion": "1",
"createdAt": 1754524800, "updatedAt": 1754528400, "tags": {{}}}}"#
)
}
fn state(name: &str, state: &str) -> Answer {
Answer::ok(fake::get_image_response(name, state))
}
fn absent() -> Answer {
Answer::failure(404, "Image not found")
}
fn uploads(services: &FakeServices) -> Vec<(String, String)> {
services
.puts
.lock()
.expect("not poisoned")
.iter()
.map(|(bucket, key, _)| (bucket.clone(), key.clone()))
.collect()
}
#[test]
fn the_plan_table() {
for (found, force, expected) in [
(Found::Absent, false, Plan::Build),
(Found::Absent, true, Plan::Build),
(Found::Ready, false, Plan::Reuse),
(Found::Ready, true, Plan::Delete),
(Found::Building, false, Plan::Wait),
(Found::Building, true, Plan::Wait),
(Found::Failed, false, Plan::Delete),
(Found::Failed, true, Plan::Delete),
(Found::Deleting, false, Plan::AwaitAbsent),
(Found::Deleting, true, Plan::AwaitAbsent),
] {
assert_eq!(plan(found, force), expected, "{found:?} force={force}");
}
for (spelling, found) in [
("CREATING", Found::Building),
("UPDATING", Found::Building),
("CREATED", Found::Ready),
("UPDATED", Found::Ready),
("ACTIVE", Found::Ready),
("CREATE_FAILED", Found::Failed),
("UPDATE_FAILED", Found::Failed),
("DELETE_FAILED", Found::Failed),
("DELETING", Found::Deleting),
("DELETED", Found::Absent),
] {
assert_eq!(Found::from_state(spelling), found, "{spelling}");
}
}
#[test]
fn the_name_is_the_sanitized_prefix_and_twelve_hex() {
let hash = "0123456789abcdef".repeat(4);
assert_eq!(
ensured_image_name("harbor/task:1", &hash).expect("legal"),
"harbor-task-1-0123456789ab"
);
let long = ensured_image_name(&"p".repeat(100), &hash).expect("legal");
assert_eq!(long.len(), 64, "IMAGE-6: {long}");
assert!(long.ends_with("-0123456789ab"));
super::super::require_valid_image_name(&long).expect("the service's pattern");
for empty in ["", "///", " "] {
let error = ensured_image_name(empty, &hash).expect_err("nothing left");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
}
}
#[test]
fn the_identity_covers_the_base_and_the_size() {
let hash = "a".repeat(64);
let base = BaseImage::al2023();
let identity = image_identity_hash(&hash, &base, SizeClass::DEFAULT);
assert_eq!(
identity,
image_identity_hash(&hash, &base, SizeClass::DEFAULT)
);
assert_eq!(identity.len(), 64);
let other_size = SizeClass::from_baseline_mib(4096).expect("a class");
assert_ne!(
identity,
image_identity_hash(&hash, &base, other_size),
"IMAGE-6: size"
);
let other_base = BaseImage {
name: "al2023-2".to_string(),
..BaseImage::al2023()
};
assert_ne!(
identity,
image_identity_hash(&hash, &other_base, SizeClass::DEFAULT)
);
assert_ne!(
identity,
image_identity_hash(&"b".repeat(64), &base, SizeClass::DEFAULT)
);
}
#[test]
fn the_artifact_key_is_content_addressed() {
assert_eq!(
artifact_key(Some("harbor/images"), "task-0123456789ab"),
"harbor/images/task-0123456789ab/artifact.zip"
);
assert_eq!(artifact_key(Some("/harbor/"), "n"), "harbor/n/artifact.zip");
assert_eq!(artifact_key(None, "n"), "n/artifact.zip");
assert_eq!(artifact_key(Some(""), "n"), "n/artifact.zip");
}
#[tokio::test]
async fn a_ready_image_is_reused_with_no_upload_and_no_create() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", Answer::ok(ready(&name)));
let services = Arc::new(FakeServices::default());
let ensured = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect("reused");
assert!(ensured.reused, "IMAGE-9");
assert!(!ensured.uploaded);
assert_eq!(ensured.image.name, name);
assert_eq!(ensured.image.version, "1");
assert_eq!(fake.operations(), ["GetMicrovmImage"], "IMAGE-9");
assert!(uploads(&services).is_empty());
assert!(
fake.paths()[0].contains(&paths_encoded(&format!(
"arn:aws:lambda:us-east-1:{ACCOUNT}:microvm-image:{name}"
))),
"IMAGE-8: the describe names the ARN built from the account: {:?}",
fake.paths()
);
}
fn paths_encoded(arn: &str) -> String {
crate::control::transport::paths::encode_segment(arn)
}
#[tokio::test]
async fn a_running_build_is_waited_out_and_reused() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)));
let services = Arc::new(FakeServices::default());
let ensured = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect("waited");
assert!(ensured.reused, "IMAGE-9");
assert_eq!(fake.call_count("CreateMicrovmImage"), 0, "IMAGE-9");
assert!(uploads(&services).is_empty(), "IMAGE-9");
}
#[tokio::test]
async fn an_absent_image_is_uploaded_to_its_key_and_built() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer(
"CreateMicrovmImage",
Answer::created(fake::create_image_response(&name)),
);
let services = Arc::new(FakeServices::default());
let mut request = request();
request
.tags
.insert("harbor:task".to_string(), "t".to_string());
let ensured = sandbox(&fake, &services)
.ensure_image(request)
.await
.expect("built");
assert!(!ensured.reused);
assert!(ensured.uploaded);
let key = format!("harbor/images/{name}/artifact.zip");
assert_eq!(
uploads(&services),
[(BUCKET.to_string(), key.clone())],
"IMAGE-8"
);
assert_eq!(ensured.artifact_uri, format!("s3://{BUCKET}/{key}"));
let body = fake.first_body("CreateMicrovmImage");
assert_eq!(body["name"], name.as_str());
assert_eq!(body["codeArtifact"]["uri"], ensured.artifact_uri.as_str());
assert_eq!(body["tags"]["harbor:task"], "t");
assert_eq!(
body["baseImageArn"], "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
"the derived base keeps the managed name"
);
assert_eq!(services.account_calls.load(Ordering::SeqCst), 1);
let (_, _, bytes) = services.puts.lock().expect("not poisoned")[0].clone();
let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a zip");
assert_eq!(archive.len(), 2);
}
#[tokio::test]
async fn a_failed_image_is_deleted_then_rebuilt() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", state(&name, "CREATE_FAILED"))
.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer(
"ListMicrovmImageVersions",
Answer::ok(fake::list_versions_response("1")),
)
.answer(
"DeleteMicrovmImage",
Answer::ok(fake::delete_image_response()),
)
.answer(
"CreateMicrovmImage",
Answer::created(fake::create_image_response(&name)),
);
let services = Arc::new(FakeServices::default());
let ensured = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect("rebuilt");
assert!(!ensured.reused);
let operations = fake.operations();
let deleted = operations
.iter()
.position(|op| *op == "DeleteMicrovmImage")
.expect("IMAGE-10: deleted");
let created = operations
.iter()
.position(|op| *op == "CreateMicrovmImage")
.expect("rebuilt");
assert!(deleted < created, "IMAGE-10: {operations:?}");
}
#[tokio::test]
async fn a_ready_image_is_rebuilt_only_under_force() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer(
"ListMicrovmImageVersions",
Answer::ok(fake::list_versions_response("1")),
)
.answer(
"DeleteMicrovmImage",
Answer::ok(fake::delete_image_response()),
)
.answer(
"CreateMicrovmImage",
Answer::created(fake::create_image_response(&name)),
);
let services = Arc::new(FakeServices::default());
let mut forced = request();
forced.force = true;
let ensured = sandbox(&fake, &services)
.ensure_image(forced)
.await
.expect("rebuilt");
assert!(!ensured.reused, "IMAGE-10");
assert_eq!(fake.call_count("DeleteMicrovmImage"), 1, "IMAGE-10");
assert_eq!(fake.call_count("CreateMicrovmImage"), 1);
}
#[tokio::test]
async fn a_deletion_under_way_is_awaited_then_built() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", state(&name, "DELETING"))
.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer(
"CreateMicrovmImage",
Answer::created(fake::create_image_response(&name)),
);
let services = Arc::new(FakeServices::default());
let ensured = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect("built");
assert!(!ensured.reused);
assert_eq!(
fake.call_count("DeleteMicrovmImage"),
0,
"not this caller's delete"
);
}
#[tokio::test]
async fn a_refused_create_joins_the_winners_build() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer("GetMicrovmImage", Answer::ok(ready(&name)))
.answer(
"CreateMicrovmImage",
Answer::failure(409, "An image with this name already exists"),
);
let services = Arc::new(FakeServices::default());
let ensured = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect("IMAGE-11: joined");
assert!(
ensured.reused,
"IMAGE-11: this caller's create did not build it"
);
assert_eq!(
ensured.image.state, "CREATED",
"IMAGE-11: returned only once ready"
);
assert_eq!(fake.call_count("CreateMicrovmImage"), 1);
}
#[tokio::test]
async fn a_refused_create_with_no_winner_surfaces_the_refusal() {
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", absent()).answer(
"CreateMicrovmImage",
Answer::failure(400, "buildRoleArn is invalid"),
);
let services = Arc::new(FakeServices::default());
let error = sandbox(&fake, &services)
.ensure_image(request())
.await
.expect_err("no winner");
assert!(
error.to_string().contains("buildRoleArn is invalid"),
"{error}"
);
}
#[tokio::test]
async fn the_account_is_resolved_once_per_sandbox() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", Answer::ok(ready(&name)));
let services = Arc::new(FakeServices::default());
let mut sandbox = sandbox(&fake, &services);
sandbox.ensure_image(request()).await.expect("first");
sandbox.ensure_image(request()).await.expect("second");
assert_eq!(services.account_calls.load(Ordering::SeqCst), 1, "IMAGE-8");
}
#[tokio::test]
async fn a_locally_refused_request_makes_no_call() {
let fake = Arc::new(FakeControlPlane::new());
let services = Arc::new(FakeServices::default());
let mut sandbox = sandbox(&fake, &services);
for (mutate, cause) in [
(
Box::new(|r: &mut EnsureImageRequest| {
r.dockerfile = "FROM x\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n".into()
}) as Box<dyn Fn(&mut EnsureImageRequest)>,
"ENTRYPOINT",
),
(Box::new(|r| r.name_prefix = "//".into()), "prefix"),
(Box::new(|r| r.s3_bucket = String::new()), "bucket"),
(
Box::new(|r| r.build_role_arn = "not-an-arn".into()),
"buildRoleArn",
),
(
Box::new(|r| r.s3_key_prefix = Some("a\nb".into())),
"key prefix",
),
] {
let mut request = request();
mutate(&mut request);
let error = sandbox.ensure_image(request).await.expect_err(cause);
assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
assert!(error.to_string().contains(cause), "{cause}: {error}");
}
assert!(fake.calls().is_empty());
assert_eq!(services.account_calls.load(Ordering::SeqCst), 0);
assert!(uploads(&services).is_empty());
}
#[tokio::test]
async fn the_wait_timeout_bounds_the_build_wait() {
let name = expected_name();
let fake = Arc::new(FakeControlPlane::new());
fake.answer("GetMicrovmImage", absent())
.answer("GetMicrovmImage", state(&name, "CREATING"))
.answer(
"CreateMicrovmImage",
Answer::created(fake::create_image_response(&name)),
)
.answer(
"ListMicrovmImageBuilds",
Answer::ok(fake::list_builds_response("IN_PROGRESS")),
)
.answer(
"ListMicrovmImageVersions",
Answer::ok(fake::list_versions_response("1")),
);
let services = Arc::new(FakeServices::default());
let mut request = request();
request.wait_timeout = Some(Duration::from_secs(60));
let error = sandbox(&fake, &services)
.ensure_image(request)
.await
.expect_err("never settles");
assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
assert!(
fake.call_count("GetMicrovmImage") < 10,
"bounded by 60s, not 45 minutes"
);
}
}