use std::collections::HashMap;
use std::sync::Mutex;
use super::transport::{Call, Reply, Transport};
use crate::error::{Error, ErrorKind};
#[derive(Clone, Debug)]
pub struct Answer {
pub status: u16,
pub body: String,
pub error_type: Option<String>,
}
impl Answer {
pub fn ok(body: impl Into<String>) -> Self {
Self {
status: 200,
body: body.into(),
error_type: None,
}
}
pub fn created(body: impl Into<String>) -> Self {
Self {
status: 201,
body: body.into(),
error_type: None,
}
}
pub fn failure(status: u16, message: &str) -> Self {
Self {
status,
body: format!(r#"{{"message": {}}}"#, json_string(message)),
error_type: None,
}
}
pub fn throttled_as_400() -> Self {
Self {
status: 400,
body: r#"{"message":null,"throttlingReasons":null}"#.to_string(),
error_type: Some("ThrottlingException".to_string()),
}
}
}
fn json_string(text: &str) -> String {
serde_json::to_string(text).expect("a string always serializes")
}
#[derive(Debug, Default)]
pub struct FakeControlPlane {
inner: Mutex<Inner>,
}
#[derive(Debug, Default)]
struct Inner {
queued: HashMap<&'static str, std::collections::VecDeque<Answer>>,
calls: Vec<Call>,
transport_errors: HashMap<&'static str, usize>,
credentials_error: Option<String>,
}
impl FakeControlPlane {
pub fn new() -> Self {
Self::default()
}
pub fn answer(&self, operation: &'static str, answer: Answer) -> &Self {
self.inner
.lock()
.expect("no test panics while holding this")
.queued
.entry(operation)
.or_default()
.push_back(answer);
self
}
pub fn fail_transport(&self, operation: &'static str, count: usize) -> &Self {
self.inner
.lock()
.expect("not poisoned")
.transport_errors
.insert(operation, count);
self
}
pub fn fail_credentials(&self, message: impl Into<String>) -> &Self {
self.inner.lock().expect("not poisoned").credentials_error = Some(message.into());
self
}
pub fn calls(&self) -> Vec<Call> {
self.inner.lock().expect("not poisoned").calls.clone()
}
pub fn call_count(&self, operation: &str) -> usize {
self.inner
.lock()
.expect("not poisoned")
.calls
.iter()
.filter(|call| call.operation == operation)
.count()
}
pub fn operations(&self) -> Vec<&'static str> {
self.inner
.lock()
.expect("not poisoned")
.calls
.iter()
.map(|call| call.operation)
.collect()
}
pub fn body_of(&self, operation: &str, nth: usize) -> serde_json::Value {
let inner = self.inner.lock().expect("not poisoned");
let call = inner
.calls
.iter()
.filter(|call| call.operation == operation)
.nth(nth)
.unwrap_or_else(|| panic!("no call {nth} to {operation}; calls: {:?}", inner.calls));
let body = call
.body
.as_deref()
.unwrap_or_else(|| panic!("{operation} sent no body"));
serde_json::from_slice(body)
.unwrap_or_else(|error| panic!("{operation} body is not JSON: {error}"))
}
pub fn first_body(&self, operation: &str) -> serde_json::Value {
self.body_of(operation, 0)
}
pub fn client_tokens(&self) -> Vec<String> {
self.inner
.lock()
.expect("not poisoned")
.calls
.iter()
.filter_map(|call| {
let body = call.body.as_deref()?;
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
Some(value.get("clientToken")?.as_str()?.to_string())
})
.collect()
}
pub fn paths(&self) -> Vec<String> {
self.inner
.lock()
.expect("not poisoned")
.calls
.iter()
.map(|call| call.path.clone())
.collect()
}
pub fn bodies_as_text(&self) -> Vec<String> {
self.inner
.lock()
.expect("not poisoned")
.calls
.iter()
.filter_map(|call| call.body.as_deref().map(String::from_utf8_lossy))
.map(|text| text.to_string())
.collect()
}
}
impl Transport for FakeControlPlane {
fn send(
&self,
call: Call,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>
{
let result = {
let mut inner = self.inner.lock().expect("not poisoned");
let operation = call.operation;
inner.calls.push(call);
if let Some(remaining) = inner.transport_errors.get_mut(operation)
&& *remaining > 0
{
*remaining -= 1;
Err(Error::new(
ErrorKind::Retryable,
format!("the {operation} request did not complete: connection reset"),
))
} else {
let queue = inner
.queued
.get_mut(operation)
.unwrap_or_else(|| panic!("the fake has no answer queued for {operation}"));
let answer = if queue.len() > 1 {
queue.pop_front().expect("non-empty")
} else {
queue
.front()
.cloned()
.unwrap_or_else(|| panic!("the fake's queue for {operation} is empty"))
};
Ok(Reply {
status: answer.status,
body: answer.body.into_bytes(),
error_type: answer.error_type,
})
}
};
Box::pin(async move { result })
}
fn resolve_credentials(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
let result = match &self.inner.lock().expect("not poisoned").credentials_error {
Some(message) => Err(Error::new(ErrorKind::Credentials, message.clone())),
None => Ok(()),
};
Box::pin(async move { result })
}
}
#[cfg(test)]
pub(crate) use crate::clock::testing::TestClock;
pub fn create_image_response(name: &str) -> String {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
"name": "{name}",
"state": "CREATING",
"createdAt": 1754524800,
"baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
"buildRoleArn": "arn:aws:iam::123456789012:role/build",
"codeArtifact": {{"uri": "s3://bucket/{name}.zip"}},
"imageVersion": "1"
}}"#
)
}
pub fn get_image_response(name: &str, state: &str) -> String {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
"name": "{name}",
"state": "{state}",
"createdAt": 1754524800,
"updatedAt": 1754528400,
"tags": {{}}
}}"#
)
}
pub fn get_image_response_failed(name: &str, state: &str, failed_version: &str) -> String {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
"name": "{name}",
"state": "{state}",
"latestFailedImageVersion": "{failed_version}",
"createdAt": 1754524800,
"updatedAt": 1754528400,
"tags": {{"owner": "conformance"}}
}}"#
)
}
pub fn list_versions_response(version: &str) -> String {
list_versions_page(&[version], None)
}
pub fn list_versions_response_failed(
version: &str,
state: &str,
state_reason: Option<&str>,
) -> String {
let reason = match state_reason {
Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
None => String::new(),
};
format!(
r#"{{"items": [{}]}}"#,
version_item(version, state, &reason)
)
}
pub fn list_versions_page(versions: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = versions
.iter()
.map(|version| version_item(version, "IN_PROGRESS", ""))
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
fn version_item(version: &str, state: &str, extra: &str) -> String {
version_item_with_status(version, state, "ACTIVE", extra)
}
fn version_item_with_status(version: &str, state: &str, status: &str, extra: &str) -> String {
format!(
r#"{{
"baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
"buildRoleArn": "arn:aws:iam::123456789012:role/build",
"codeArtifact": {{"uri": "s3://bucket/img.zip"}},
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"imageVersion": "{version}",
"state": "{state}",
"status": "{status}",
"createdAt": 1754524800{extra}
}}"#
)
}
pub fn get_image_version_response(version: &str, state: &str, status: &str) -> String {
version_item_with_status(version, state, status, "")
}
pub fn get_image_version_response_with_reason(
version: &str,
state: &str,
status: &str,
state_reason: &str,
) -> String {
version_item_with_status(
version,
state,
status,
&format!(r#", "stateReason": {}"#, json_string(state_reason)),
)
}
pub fn get_image_build_response(
build_id: &str,
build_state: &str,
generation: &str,
state_reason: Option<&str>,
snapshot_build: Option<&str>,
) -> String {
let reason = match state_reason {
Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
None => String::new(),
};
let sizes = match snapshot_build {
Some(fragment) => format!(r#", "snapshotBuild": {fragment}"#),
None => String::new(),
};
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"imageVersion": "1",
"buildId": "{build_id}",
"buildState": "{build_state}",
"architecture": "ARM_64",
"chipset": "GRAVITON",
"chipsetGeneration": "{generation}",
"createdAt": 1754524800{reason}{sizes}
}}"#
)
}
pub fn list_managed_versions_page(versions: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = versions
.iter()
.map(|version| {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
"imageVersion": "{version}",
"createdAt": 1750000000,
"updatedAt": 1753000000
}}"#
)
})
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
pub fn list_managed_images_page(names: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = names
.iter()
.map(|name| {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:{name}",
"createdAt": 1750000000,
"updatedAt": 1753000000
}}"#
)
})
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
pub fn list_builds_response(build_state: &str) -> String {
list_builds_page(&[("build-1", build_state), ("build-2", build_state)], None)
}
pub fn list_builds_page(builds: &[(&str, &str)], next_token: Option<&str>) -> String {
list_builds_page_with_reasons(
&builds
.iter()
.map(|(id, state)| (*id, *state, None))
.collect::<Vec<_>>(),
next_token,
)
}
pub fn list_builds_page_with_reasons(
builds: &[(&str, &str, Option<&str>)],
next_token: Option<&str>,
) -> String {
let items: Vec<String> = builds
.iter()
.map(|(build_id, build_state, state_reason)| {
let reason = match state_reason {
Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
None => String::new(),
};
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"imageVersion": "1",
"buildId": "{build_id}",
"buildState": "{build_state}",
"architecture": "ARM_64",
"chipset": "GRAVITON",
"chipsetGeneration": "1",
"createdAt": 1754524800{reason}
}}"#
)
})
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
pub fn microvm_response(state: &str, state_reason: Option<&str>) -> String {
let reason = match state_reason {
Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
None => String::new(),
};
format!(
r#"{{
"microvmId": "mvm-abc123",
"state": "{state}",
"endpoint": "https://mvm-abc123.microvm.us-east-1.amazonaws.com",
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"imageVersion": "1",
"idlePolicy": {{
"maxIdleDurationSeconds": 1800,
"suspendedDurationSeconds": 600,
"autoResumeEnabled": false
}},
"maximumDurationInSeconds": 3600,
"startedAt": 1754524800{reason}
}}"#
)
}
pub fn list_microvms_page(ids: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = ids
.iter()
.map(|id| {
format!(
r#"{{
"microvmId": "{id}",
"state": "RUNNING",
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"imageVersion": "1",
"startedAt": 1754524800
}}"#
)
})
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
pub fn auth_token_response(token: &str) -> String {
format!(
r#"{{"authToken": {{"X-aws-proxy-auth": {}}}}}"#,
json_string(token)
)
}
pub fn list_images_response(names: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = names
.iter()
.map(|name| {
format!(
r#"{{
"imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
"name": "{name}",
"state": "ACTIVE",
"latestActiveImageVersion": "1",
"createdAt": 1754524800
}}"#
)
})
.collect();
let token = match next_token {
Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
None => String::new(),
};
format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}
pub fn delete_image_response() -> String {
delete_image_response_in("DELETING")
}
pub fn delete_image_response_in(state: &str) -> String {
format!(
r#"{{"imageIdentifier": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
"state": "{state}"}}"#
)
}
pub fn empty_response() -> String {
"{}".to_string()
}