pub mod artifact;
pub mod connector;
pub mod context;
pub mod ensure;
pub mod image;
pub mod microvm;
pub mod ops;
pub mod project;
pub mod services;
pub mod sweep;
pub mod token;
pub mod transport;
use std::sync::Arc;
use std::time::Duration;
pub use artifact::{
BaseImage, Ecosystem, ProjectFiles, WrapOptions, artifact_content_hash, build_artifact,
default_dockerfile, wrap_dockerfile, wrap_dockerfile_preserving_user,
};
pub use connector::{
ConnectorIntent, EgressPosture, PLATFORM_HONOURS_OMITTED_EGRESS, egress_posture_for,
};
pub use context::{BuildContext, ContextEntry};
pub use ensure::{EnsureImageRequest, EnsuredImage};
pub use image::{Image, WaitOpts};
pub use microvm::{Microvm, MicrovmFilter, ProxyToken, RunHookPayload};
pub use services::BuildServices;
pub use sweep::{SweepFailure, SweepOptions, SweepReport};
use crate::error::{Error, ErrorKind};
struct ReportedReply {
reply: transport::Reply,
details: transport::RetryDetails,
}
impl ReportedReply {
fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
self.reply
.json(operation)
.map_err(|error| self.error(error))
}
fn error(&self, error: Error) -> Error {
error.with_retry_details(self.details.attempt_count, self.details.backoff)
}
}
use crate::hooks::{BuildHookTimeout, RunHookTimeout};
use crate::region::Region;
use crate::sizing::SizeClass;
pub const DEFAULT_AGENT_PORT: u16 = protocol::DEFAULT_AGENT_PORT;
use crate::adapters::Adapters;
pub use crate::clock::Clock;
use crate::entropy::Entropy;
pub struct ControlPlane {
transport: Arc<dyn transport::Transport>,
region: Region,
clock: Arc<dyn Clock>,
entropy: Arc<dyn Entropy>,
adapters: Arc<dyn Adapters>,
port: u16,
retry: Option<transport::RetryPolicy>,
}
impl std::fmt::Debug for ControlPlane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ControlPlane")
.field("region", &self.region)
.field("port", &self.port)
.finish_non_exhaustive()
}
}
impl ControlPlane {
pub fn from_ports(
transport: Arc<dyn transport::Transport>,
region: Region,
clock: Arc<dyn Clock>,
entropy: Arc<dyn Entropy>,
adapters: Arc<dyn Adapters>,
) -> Self {
Self {
transport,
region,
clock,
entropy,
adapters,
port: DEFAULT_AGENT_PORT,
retry: None,
}
}
#[must_use]
pub fn with_retry(mut self, retry: transport::RetryPolicy) -> Self {
self.retry = Some(retry);
self
}
async fn send(&self, call: transport::Call) -> Result<ReportedReply, Error> {
self.send_reported(call, &[]).await
}
async fn send_reported(
&self,
call: transport::Call,
accept: &[u16],
) -> Result<ReportedReply, Error> {
let (reply, details) = transport::send_configured(
self.transport(),
call,
accept,
self.retry,
self.clock.as_ref(),
)
.await?;
Ok(ReportedReply { reply, details })
}
#[must_use]
pub fn with_entropy(mut self, entropy: Arc<dyn Entropy>) -> Self {
self.entropy = entropy;
self
}
pub fn with_port(mut self, port: u16) -> Result<Self, Error> {
require_valid_port("port", port)?;
self.port = port;
Ok(self)
}
pub fn region(&self) -> &Region {
&self.region
}
pub fn port(&self) -> u16 {
self.port
}
pub(crate) fn transport(&self) -> &dyn transport::Transport {
self.transport.as_ref()
}
pub(crate) fn clock(&self) -> &dyn Clock {
self.clock.as_ref()
}
pub fn entropy(&self) -> &dyn Entropy {
self.entropy.as_ref()
}
pub(crate) fn shared_clock(&self) -> Arc<dyn Clock> {
Arc::clone(&self.clock)
}
pub(crate) fn adapters(&self) -> &dyn Adapters {
self.adapters.as_ref()
}
}
#[derive(Clone, Debug)]
pub struct CreateImageRequest {
pub name: String,
pub base_image: BaseImage,
pub base_image_version: Option<String>,
pub dockerfile: Option<String>,
pub binary: Vec<u8>,
pub project_files: Option<ProjectFiles>,
pub code_artifact_uri: String,
pub build_role_arn: String,
pub size: SizeClass,
pub repair_guest_identity: bool,
pub inherit_workdir: bool,
pub run_hook_timeout: RunHookTimeout,
pub build_hook_timeout: BuildHookTimeout,
pub tags: std::collections::BTreeMap<String, String>,
pub log_group: Option<String>,
pub log_stream: Option<String>,
pub token_scope: Option<String>,
}
impl CreateImageRequest {
pub fn new(
name: impl Into<String>,
binary: Vec<u8>,
code_artifact_uri: impl Into<String>,
build_role_arn: impl Into<String>,
) -> Self {
Self {
name: name.into(),
base_image: BaseImage::al2023(),
base_image_version: None,
dockerfile: None,
binary,
project_files: None,
code_artifact_uri: code_artifact_uri.into(),
build_role_arn: build_role_arn.into(),
size: SizeClass::DEFAULT,
repair_guest_identity: false,
inherit_workdir: false,
run_hook_timeout: RunHookTimeout::try_new(30).expect("30s is under the 60s ceiling"),
build_hook_timeout: BuildHookTimeout::try_new(30)
.expect("30s is under the 3600s ceiling"),
tags: std::collections::BTreeMap::new(),
log_group: None,
log_stream: None,
token_scope: None,
}
}
}
#[derive(Clone, Debug)]
pub struct RunMicrovmRequest {
pub image_identifier: String,
pub image_version: Option<String>,
pub execution_role_arn: Option<String>,
pub connectors: Vec<ConnectorIntent>,
pub egress_network_connectors: Vec<String>,
pub run_hook_payload: RunHookPayload,
pub max_duration_sec: u32,
pub max_idle_sec: u32,
pub suspended_sec: u32,
pub auto_resume: bool,
pub token_scope: Option<String>,
pub client_token: Option<String>,
pub logging: Option<ops::Logging>,
}
impl RunMicrovmRequest {
pub fn new(image_identifier: impl Into<String>, run_hook_payload: RunHookPayload) -> Self {
Self {
image_identifier: image_identifier.into(),
image_version: None,
execution_role_arn: None,
connectors: vec![ConnectorIntent::AllIngress],
egress_network_connectors: Vec::new(),
run_hook_payload,
max_duration_sec: 3_600,
max_idle_sec: 600,
suspended_sec: 600,
auto_resume: false,
token_scope: None,
client_token: None,
logging: None,
}
}
#[must_use]
pub fn with_egress(mut self) -> Self {
if !self.connectors.contains(&ConnectorIntent::Egress) {
self.connectors.push(ConnectorIntent::Egress);
}
self
}
#[must_use]
pub fn with_egress_network_connector(mut self, arn: impl Into<String>) -> Self {
self.egress_network_connectors.push(arn.into());
self
}
#[must_use]
pub fn with_shell(mut self) -> Self {
self.connectors
.retain(|intent| *intent != ConnectorIntent::AllIngress);
for wanted in [ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress] {
if !self.connectors.contains(&wanted) {
self.connectors.push(wanted);
}
}
self
}
#[must_use]
pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
self.image_version = Some(version.into());
self
}
}
pub fn require_duration_in_range(seconds: u32) -> Result<u32, Error> {
if (1..=crate::constants::MAX_DURATION_SEC).contains(&seconds) {
return Ok(seconds);
}
Err(Error::invalid_arg(format!(
"maximumDurationInSeconds={seconds} is outside the accepted range 1..{} (service model \
{}) — {} seconds is eight hours, the hard ceiling on any one VM's life. A longer \
session needs a second VM, not a larger number.",
crate::constants::MAX_DURATION_SEC,
crate::constants::MODEL_API_VERSION,
crate::constants::MAX_DURATION_SEC,
)))
}
pub fn require_valid_version(member: &str, version: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if version.is_empty() {
return Err(Error::invalid_arg(format!(
"{member} is empty, but the Version shape requires at least 1 character (service \
model {model}). Omit the member entirely to let the service choose — an absent \
version and a blank one are different requests, and only the first is legal."
)));
}
if version.len() > crate::constants::MAX_VERSION_LEN {
return Err(Error::invalid_arg(format!(
"{member} is {} characters, over the Version ceiling of {} (service model {model}).",
version.len(),
crate::constants::MAX_VERSION_LEN,
)));
}
if let Some(found) = version.chars().find(char::is_ascii_whitespace) {
return Err(Error::invalid_arg(format!(
"{member} {version:?} contains whitespace ({found:?}), which the Version pattern \
{:?} forbids anywhere in the value (service model {model}). A version pasted from \
a terminal carries a trailing newline and looks fine; this is that.",
crate::constants::VERSION_PATTERN,
)));
}
Ok(())
}
pub fn require_non_blank(member: &str, value: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if value.is_empty() {
return Err(Error::invalid_arg(format!(
"{member} is empty, but the NonBlankString shape requires at least 1 character \
(service model {model}). The shape's own documentation reads 'a string which is not \
empty or blank (only whitespace)'."
)));
}
if value.len() > crate::constants::MAX_NON_BLANK_LEN {
return Err(Error::invalid_arg(format!(
"{member} is {} characters, over the NonBlankString ceiling of {} (service model \
{model}).",
value.len(),
crate::constants::MAX_NON_BLANK_LEN,
)));
}
if let Some(found) = value.chars().find(char::is_ascii_whitespace) {
return Err(Error::invalid_arg(format!(
"{member} {value:?} contains whitespace ({found:?}), which the NonBlankString \
pattern {:?} forbids anywhere in the value (service model {model}). A URI pasted \
from a console carries a trailing newline and looks fine; this is that.",
crate::constants::NON_BLANK_PATTERN,
)));
}
Ok(())
}
pub fn require_valid_identifier(member: &str, identifier: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if identifier.is_empty() {
return Err(Error::invalid_arg(format!(
"{member} is empty, but the identifier shapes require at least 1 character (service \
model {model}). An empty identifier is not refused as a blank field — most of these \
are URI parameters, so it collapses the path onto the collection: \
`/microvms/` is the listing rather than one VM, and a request that addresses the \
wrong resource can succeed."
)));
}
if identifier.len() > crate::constants::MAX_IDENTIFIER_LEN {
return Err(Error::invalid_arg(format!(
"{member} is {} characters, over the {} the MicrovmIdentifier and \
MicrovmImageIdentifier shapes allow (service model {model}). Note the model \
disagrees with itself here: MicrovmImageArn permits {}, and that is the shape the \
service answers with on GetMicrovm's imageArn — so if this value came back from a \
response rather than being chosen here, it is a service-side inconsistency worth \
reporting rather than a length to shorten.",
identifier.len(),
crate::constants::MAX_IDENTIFIER_LEN,
crate::constants::MAX_IMAGE_ARN_LEN,
)));
}
Ok(())
}
pub fn require_valid_role_arn(member: &str, arn: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if arn.len() < crate::constants::MIN_ROLE_ARN_LEN {
return Err(Error::invalid_arg(format!(
"{member} {arn:?} is {} characters, under the RoleArn minimum of {} (service model \
{model}). A value this short is almost always a role *name* where the ARN was \
wanted — `arn:aws:iam::<12-digit-account>:role/{arn}` is the shape.",
arn.len(),
crate::constants::MIN_ROLE_ARN_LEN,
)));
}
if arn.len() > crate::constants::MAX_ROLE_ARN_LEN {
return Err(Error::invalid_arg(format!(
"{member} is {} characters, over the RoleArn ceiling of {} (service model {model}).",
arn.len(),
crate::constants::MAX_ROLE_ARN_LEN,
)));
}
if !crate::constants::is_valid_role_arn(arn) {
return Err(Error::invalid_arg(format!(
"{member} {arn:?} does not match the RoleArn pattern {:?} (service model {model}). \
It wants `arn:aws:iam::` then **exactly twelve digits** of account id then `:role/` \
and a name — so the three things this catches are a role name passed as an ARN, an \
ARN for some other service, and an account id with a digit dropped. Rejected here \
rather than by AWS because for buildRoleArn the create call happens *after* the \
artifact upload, so the service's answer costs you the upload first. Whether the \
role exists and grants logs on {}/* is not checkable locally and is still the \
service's answer to give.",
crate::constants::ROLE_ARN_PATTERN,
image::BUILD_LOG_GROUP_PREFIX,
)));
}
Ok(())
}
pub fn require_valid_log_group(group: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if group.is_empty() {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logGroup is empty, but the CloudWatchLoggingLogGroupString \
shape requires at least 1 character (service model {model}). Omit the setting \
entirely to take the service default — a service-created group under \
{}/<image-name>.",
image::BUILD_LOG_GROUP_PREFIX,
)));
}
if group.len() > crate::constants::MAX_LOG_GROUP_LEN {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logGroup is {} characters, over the \
CloudWatchLoggingLogGroupString ceiling of {} (service model {model}).",
group.len(),
crate::constants::MAX_LOG_GROUP_LEN,
)));
}
if !crate::constants::is_valid_log_group(group) {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logGroup {group:?} does not match the \
CloudWatchLoggingLogGroupString pattern {:?} (service model {model}) — letters, \
digits, and `_ - / . #` only. A colon usually means an ARN was pasted where the \
group *name* was wanted. Rejected here rather than by AWS because the create call \
happens after the artifact upload, so the service's answer costs you the upload \
first.",
crate::constants::LOG_GROUP_PATTERN,
)));
}
Ok(())
}
pub fn require_valid_log_stream(stream: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if stream.is_empty() {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logStream is empty, but the CloudWatchLoggingLogStreamString \
shape requires at least 1 character (service model {model}). Omit the setting to \
let the service name the streams inside the configured group."
)));
}
if stream.len() > crate::constants::MAX_USER_LOG_STREAM_LEN {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logStream is {} characters, over this client's ceiling of {}. \
The shape's own ceiling is {} (service model {model}), and the client always \
appends a 17-character per-build discriminator (`/` + 16 hex) — one image build \
is three VMs writing three streams, and the member is an exact stream name, so a \
fixed configured name would collapse every build's streams into one. The \
discriminator is what keeps them tellable apart, and it needs its room.",
stream.len(),
crate::constants::MAX_USER_LOG_STREAM_LEN,
crate::constants::MAX_LOG_STREAM_LEN,
)));
}
if let Some(found) = stream.chars().find(|c| matches!(c, ':' | '*')) {
return Err(Error::invalid_arg(format!(
"logging.cloudWatch.logStream {stream:?} contains {found:?}, which the \
CloudWatchLoggingLogStreamString pattern {:?} forbids anywhere in the value \
(service model {model}). A `*` usually means a prefix or glob was intended — the \
member is an exact stream name, prefixes are unsupported, and this client's own \
per-build suffix is how a configured name behaves like one.",
crate::constants::LOG_STREAM_PATTERN,
)));
}
Ok(())
}
pub fn require_valid_port(member: &str, port: u16) -> Result<(), Error> {
if port >= crate::constants::MIN_PORT {
return Ok(());
}
Err(Error::invalid_arg(format!(
"{member} is {port}, under the PortNumber minimum of {} (service model {}). Port 0 means \
'let the kernel choose' to a listener and is not a port the platform can forward to — a \
proxy token minted for it authorizes nothing, and an image whose hooks block names it has \
no reachable hook endpoint. Name the port the daemon actually listens on \
({DEFAULT_AGENT_PORT} by default).",
crate::constants::MIN_PORT,
crate::constants::MODEL_API_VERSION,
)))
}
pub fn require_idle_duration(seconds: u32) -> Result<(), Error> {
if seconds >= crate::constants::MIN_IDLE_DURATION_SEC {
return Ok(());
}
Err(Error::invalid_arg(format!(
"idlePolicy.maxIdleDurationSeconds={seconds} is under the minimum of {} (service model \
{}). This used to be documented as a constraint botocore enforced locally, which was \
true of the deleted Python client and never of this one — it signs with aws-sigv4 and \
sends with reqwest, so nothing validates a request before the wire except this crate.",
crate::constants::MIN_IDLE_DURATION_SEC,
crate::constants::MODEL_API_VERSION,
)))
}
pub fn require_valid_tags(tags: &std::collections::BTreeMap<String, String>) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
for (key, value) in tags {
if key.is_empty() {
return Err(Error::invalid_arg(format!(
"a tag key is empty, but TagKey requires at least 1 character (service model \
{model}). Note TagValue's minimum is 0, so an empty tag *value* is legal and an \
empty key is not."
)));
}
if key.len() > crate::constants::MAX_TAG_KEY_LEN {
return Err(Error::invalid_arg(format!(
"the tag key {key:?} is {} characters, over the TagKey ceiling of {} (service \
model {model}). TagValue allows {}, so the two halves are not \
interchangeable.",
key.len(),
crate::constants::MAX_TAG_KEY_LEN,
crate::constants::MAX_TAG_VALUE_LEN,
)));
}
if !crate::constants::is_valid_tag_component(key) {
return Err(Error::invalid_arg(format!(
"the tag key {key:?} has a character outside the TagKey pattern {:?} (service \
model {model}). Letters, digits, separators, and `_ . : / = + - @` — spaces and \
non-Latin scripts are fine, commas and `#` and `%` are not, and neither is a \
newline (it is a control character, not a separator).",
crate::constants::TAG_COMPONENT_PATTERN,
)));
}
if value.len() > crate::constants::MAX_TAG_VALUE_LEN {
return Err(Error::invalid_arg(format!(
"the value of tag {key:?} is {} characters, over the TagValue ceiling of {} \
(service model {model}).",
value.len(),
crate::constants::MAX_TAG_VALUE_LEN,
)));
}
if !crate::constants::is_valid_tag_component(value) {
return Err(Error::invalid_arg(format!(
"the value of tag {key:?} has a character outside the TagValue pattern {:?} \
(service model {model}). It is the same character set the key takes; the value \
differs only in allowing an empty string.",
crate::constants::TAG_COMPONENT_PATTERN,
)));
}
}
Ok(())
}
pub fn require_valid_image_name(name: &str) -> Result<(), Error> {
let model = crate::constants::MODEL_API_VERSION;
if name.is_empty() {
return Err(Error::invalid_arg(format!(
"the image name is empty, but ImageName requires at least 1 character (service \
model {model})."
)));
}
if name.len() > crate::constants::MAX_IMAGE_NAME_LEN {
return Err(Error::invalid_arg(format!(
"the image name is {} characters, over the ImageName ceiling of {} (service model \
{model}). Rejected here rather than by AWS, because the create call happens \
*after* the artifact upload — so the service's answer costs you the upload first.",
name.len(),
crate::constants::MAX_IMAGE_NAME_LEN,
)));
}
if !crate::constants::is_valid_image_name(name) {
return Err(Error::invalid_arg(format!(
"the image name {name:?} does not match the ImageName pattern {:?} (service model \
{model}). Letters, digits, hyphen, and underscore only — no dots and no slashes, \
which are the two separators a namespaced name reaches for first.",
crate::constants::IMAGE_NAME_PATTERN,
)));
}
Ok(())
}
pub fn hooks_block(port: u16, run: RunHookTimeout, build: BuildHookTimeout) -> ops::Hooks {
const ENABLED: ops::HookState = ops::HookState::Enabled;
ops::Hooks {
port,
microvm_hooks: ops::MicrovmHooks {
run: ENABLED,
run_timeout_in_seconds: run.as_secs(),
resume: ENABLED,
resume_timeout_in_seconds: run.as_secs(),
suspend: ENABLED,
suspend_timeout_in_seconds: run.as_secs(),
terminate: ENABLED,
terminate_timeout_in_seconds: run.as_secs(),
},
microvm_image_hooks: ops::MicrovmImageHooks {
ready: ENABLED,
ready_timeout_in_seconds: build.as_secs(),
validate: ENABLED,
validate_timeout_in_seconds: build.as_secs(),
},
}
}
pub(crate) fn timed_out(what: &str, waited: Duration) -> Error {
Error::new(
ErrorKind::Timeout,
format!(
"{what} within {:.0}s. This is a client-side deadline: nothing was cancelled, so \
the resource is in whatever state the service last reported.",
waited.as_secs_f64()
),
)
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) mod fake;
#[cfg(test)]
mod posture_fuzz;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_image_name_rejection_explains_its_own_case() {
let empty = require_valid_image_name("").expect_err("min is 1");
assert!(
empty.to_string().contains("at least 1 character"),
"{empty}"
);
let long = require_valid_image_name(&"a".repeat(65)).expect_err("max is 64");
let message = long.to_string();
assert!(message.contains("65 characters"), "{message}");
assert!(
message.contains("after* the artifact upload"),
"the reason to check locally is the cost: {message}"
);
assert!(
!message.contains("no dots"),
"the pattern wording is misleading for a merely-long name: {message}"
);
let dotted = require_valid_image_name("my.image").expect_err("dots are not in the pattern");
let message = dotted.to_string();
assert!(message.contains("no dots and no slashes"), "{message}");
assert!(message.contains("[a-zA-Z0-9-_]+"), "{message}");
}
#[test]
fn an_image_name_at_the_ceiling_is_accepted_and_one_past_it_is_not() {
require_valid_image_name(&"a".repeat(64)).expect("64 is the ceiling");
require_valid_image_name("a").expect("1 is the minimum");
for bad in ["", &"a".repeat(65), "my.image", "team/image", "a b"] {
let error = require_valid_image_name(bad).expect_err("rejected");
assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
}
}
#[test]
fn the_duration_range_is_inclusive_and_names_the_eight_hour_ceiling() {
assert_eq!(require_duration_in_range(1).expect("1 fits"), 1);
assert_eq!(
require_duration_in_range(28_800).expect("the ceiling fits"),
28_800
);
for bad in [0, 28_801, u32::MAX] {
let error = require_duration_in_range(bad).expect_err("outside 1..28800");
assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad}");
let message = error.to_string();
assert!(message.contains("eight hours"), "{message}");
assert!(
message.contains("a second VM, not a larger number"),
"{message}"
);
}
}
#[test]
fn the_hooks_block_enables_all_six_with_the_two_families_kept_apart() {
let hooks = hooks_block(
9000,
RunHookTimeout::try_new(45).expect("45s is a legal run timeout"),
BuildHookTimeout::try_new(1800).expect("1800s is a legal build timeout"),
);
assert_eq!(hooks.port, 9000);
for state in [
hooks.microvm_hooks.run,
hooks.microvm_hooks.resume,
hooks.microvm_hooks.suspend,
hooks.microvm_hooks.terminate,
hooks.microvm_image_hooks.ready,
hooks.microvm_image_hooks.validate,
] {
assert_eq!(state, ops::HookState::Enabled, "all six hooks are served");
assert_eq!(state.as_str(), "ENABLED");
}
assert_eq!(hooks.microvm_hooks.run_timeout_in_seconds, 45);
assert_eq!(hooks.microvm_hooks.terminate_timeout_in_seconds, 45);
assert_eq!(hooks.microvm_image_hooks.ready_timeout_in_seconds, 1800);
assert_eq!(
hooks.microvm_image_hooks.validate_timeout_in_seconds, 1800,
"1800 is legal for the build family and illegal for the run one, which is why \
one shared number cannot serve both"
);
}
#[test]
fn the_default_create_request_repairs_no_identity_and_takes_the_default_size() {
let request = CreateImageRequest::new("img", vec![1, 2, 3], "s3://b/k", "arn:role");
assert!(!request.repair_guest_identity);
assert!(!request.inherit_workdir);
assert_eq!(request.size, SizeClass::DEFAULT);
assert_eq!(request.base_image, BaseImage::al2023());
assert_eq!(request.token_scope, None);
assert_eq!(request.dockerfile, None);
}
#[test]
fn the_default_launch_requests_ingress_only() {
let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
let request = RunMicrovmRequest::new("arn:image", payload);
assert_eq!(request.connectors, vec![ConnectorIntent::AllIngress]);
let with_egress = request.with_egress();
assert_eq!(
with_egress.connectors,
vec![ConnectorIntent::AllIngress, ConnectorIntent::Egress]
);
}
#[test]
fn asking_for_egress_twice_adds_it_once() {
let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
let request = RunMicrovmRequest::new("arn:image", payload)
.with_egress()
.with_egress();
assert_eq!(request.connectors.len(), 2);
}
#[test]
fn with_shell_replaces_all_ingress_with_the_measured_pair() {
let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
let request = RunMicrovmRequest::new("arn:image", payload).with_shell();
assert_eq!(
request.connectors,
vec![ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress]
);
let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
let both = RunMicrovmRequest::new("arn:image", payload)
.with_egress()
.with_shell()
.with_shell();
assert_eq!(
both.connectors,
vec![
ConnectorIntent::Egress,
ConnectorIntent::HttpIngress,
ConnectorIntent::ShellIngress
],
"egress survives, and asking twice adds nothing"
);
}
#[test]
fn a_client_side_deadline_says_the_resource_is_untouched() {
let error = timed_out("the image did not become usable", Duration::from_secs(300));
assert_eq!(error.kind(), ErrorKind::Timeout);
let message = error.to_string();
assert!(message.contains("300s"), "{message}");
assert!(message.contains("nothing was cancelled"), "{message}");
}
#[test]
fn the_agent_port_defaults_to_nine_thousand_and_is_overridable() {
let plane = crate::testing::control_plane(
Arc::new(fake::FakeControlPlane::new()),
Region::UsEast1,
Arc::new(fake::TestClock::new()),
);
assert_eq!(plane.port(), DEFAULT_AGENT_PORT);
assert_eq!(plane.port(), 9000);
assert_eq!(
plane.with_port(8080).expect("8080 is a legal port").port(),
8080
);
}
#[test]
fn image_requests_forbid_tokens_and_run_requests_require_explicit_opt_in() {
let CreateImageRequest {
name: _,
base_image: _,
base_image_version: _,
dockerfile: _,
binary: _,
project_files: _,
code_artifact_uri: _,
build_role_arn: _,
size: _,
repair_guest_identity: _,
inherit_workdir: _,
run_hook_timeout: _,
build_hook_timeout: _,
tags: _,
log_group: _,
log_stream: _,
token_scope: _,
} = CreateImageRequest::new("img", Vec::new(), "s3://b/k", "arn:role");
let RunMicrovmRequest {
image_identifier: _,
image_version: _,
execution_role_arn: _,
connectors: _,
egress_network_connectors: _,
run_hook_payload: _,
max_duration_sec: _,
max_idle_sec: _,
suspended_sec: _,
auto_resume: _,
token_scope: _,
logging: _,
client_token,
} = RunMicrovmRequest::new(
"arn:image",
RunHookPayload::for_agent_token("t").expect("fits"),
);
assert!(client_token.is_none());
}
#[test]
fn the_wire_types_token_field_is_crate_private_and_minted() {
let entropy = crate::entropy::testing::SequenceEntropy::new();
let wire = ops::RunMicrovmWire {
image_identifier: "arn:image".to_string(),
image_version: None,
execution_role_arn: None,
ingress_network_connectors: Vec::new(),
egress_network_connectors: None,
idle_policy: ops::IdlePolicy {
max_idle_duration_seconds: 600,
suspended_duration_seconds: 600,
auto_resume_enabled: false,
},
maximum_duration_in_seconds: 3_600,
run_hook_payload: String::new(),
logging: None,
client_token: token::run_token_with("arn:image", &entropy).expect("scripted"),
};
assert!(wire.client_token.starts_with("run-"));
assert!(wire.client_token.len() <= crate::constants::MAX_CLIENT_TOKEN_LEN);
assert_ne!(
wire.client_token,
token::run_token_with("arn:image", &entropy).expect("scripted"),
"two mints of the same scope differ, which is the property the narrowing protects"
);
}
#[test]
fn each_non_blank_rejection_explains_its_own_case() {
let blank = require_non_blank("codeArtifact.uri", "").expect_err("min is 1");
assert_eq!(blank.kind(), ErrorKind::InvalidArg);
assert!(
blank.to_string().contains("at least 1 character"),
"{blank}"
);
assert!(
blank.to_string().contains("NonBlankString"),
"the shape has to be named: {blank}"
);
let long = require_non_blank("baseImageArn", &"a".repeat(2049)).expect_err("max is 2048");
let message = long.to_string();
assert!(message.contains("2049 characters"), "{message}");
assert!(message.contains("ceiling of 2048"), "{message}");
let newline = require_non_blank("codeArtifact.uri", "s3://bucket/key.zip\n")
.expect_err("whitespace anywhere is refused");
let message = newline.to_string();
assert!(
message.contains(r"'\n'"),
"the message must name the character it found: {message}"
);
assert!(message.contains("trailing newline"), "{message}");
let inner =
require_non_blank("nameFilter", "coding agents").expect_err("an inner space too");
assert!(
inner.to_string().contains("anywhere in the value"),
"{inner}"
);
require_non_blank("codeArtifact.uri", "s3://bucket/agentd.zip").expect("a real URI");
require_non_blank(
"baseImageArn",
"arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
)
.expect("a real base ARN");
require_non_blank("nameFilter", "coding-agents").expect("a real filter");
require_non_blank("imageVersion", &"9".repeat(2048)).expect("2048 is the ceiling");
}
#[test]
fn the_identifier_guard_names_the_collapsed_path_and_the_models_contradiction() {
let blank = require_valid_identifier("microvmIdentifier", "").expect_err("min is 1");
assert_eq!(blank.kind(), ErrorKind::InvalidArg);
let message = blank.to_string();
assert!(message.contains("at least 1 character"), "{message}");
assert!(
message.contains("collapses the path"),
"the reason an empty URI parameter is dangerous has to be in the message: {message}"
);
let long =
require_valid_identifier("imageIdentifier", &"a".repeat(257)).expect_err("max is 256");
let message = long.to_string();
assert!(message.contains("257 characters"), "{message}");
assert!(message.contains("256"), "{message}");
assert!(
message.contains("MicrovmImageArn permits 2048"),
"the model's own contradiction has to be named, or the advice is 'shorten a value \
the service gave you': {message}"
);
require_valid_identifier(
"imageIdentifier",
"arn:aws:lambda:us-east-1:1:microvm-image:i",
)
.expect("a real ARN");
require_valid_identifier("microvmIdentifier", "mvm-abc123").expect("a real id");
require_valid_identifier("imageIdentifier", &"a".repeat(256)).expect("256 is the ceiling");
}
#[test]
fn the_role_arn_guard_guesses_the_mistake_a_short_value_is() {
let name = require_valid_role_arn("buildRoleArn", "build-role").expect_err("min is 20");
assert_eq!(name.kind(), ErrorKind::InvalidArg);
let message = name.to_string();
assert!(message.contains("role *name*"), "{message}");
assert!(
message.contains("arn:aws:iam::<12-digit-account>:role/build-role"),
"the message renders the ARN they probably meant: {message}"
);
let long = require_valid_role_arn(
"executionRoleArn",
&format!("arn:aws:iam::123456789012:role/{}", "a".repeat(2048)),
)
.expect_err("max is 2048");
assert!(long.to_string().contains("ceiling of 2048"), "{long}");
let eleven = require_valid_role_arn("buildRoleArn", "arn:aws:iam::12345678901:role/build")
.expect_err("the account id is eleven digits");
let message = eleven.to_string();
assert!(
message.contains("exactly twelve digits"),
"the digit count is the whole value of this check: {message}"
);
assert!(
message.contains("after* the artifact upload"),
"the reason to check locally is the cost: {message}"
);
assert!(
message.contains("/aws/lambda-microvms/*"),
"and the message says what is still the service's answer to give: {message}"
);
require_valid_role_arn(
"buildRoleArn",
"arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
)
.expect("the conformance account's real build role");
}
#[test]
fn the_port_guard_refuses_zero_and_says_what_zero_means() {
let zero = require_valid_port("allowedPorts[].port", 0).expect_err("min is 1");
assert_eq!(zero.kind(), ErrorKind::InvalidArg);
let message = zero.to_string();
assert!(
message.contains("let the kernel choose"),
"0 is a value a caller passes on purpose, so the message has to say why it is not a \
port: {message}"
);
assert!(
message.contains("authorizes nothing"),
"the proxy-token consequence: {message}"
);
assert!(message.contains("9000"), "the default is named: {message}");
require_valid_port("port", 1).expect("1 is the minimum");
require_valid_port("port", 9000).expect("the agent port");
require_valid_port("port", 65_535).expect("65535 is the ceiling");
}
#[test]
fn the_idle_guard_refuses_fifty_nine_and_names_the_premise_that_did_not_transfer() {
let under = require_idle_duration(59).expect_err("min is 60");
assert_eq!(under.kind(), ErrorKind::InvalidArg);
let message = under.to_string();
assert!(message.contains("59"), "{message}");
assert!(message.contains("minimum of 60"), "{message}");
assert!(
message.contains("botocore"),
"the message has to name the premise, because the premise is what was wrong: \
{message}"
);
assert!(
message.contains("aws-sigv4"),
"and say what this client actually does instead: {message}"
);
require_idle_duration(60).expect("60 is the minimum");
require_idle_duration(600).expect("the default");
require_idle_duration(u32::MAX).expect("the model states no ceiling");
}
#[test]
fn every_tag_rejection_names_the_key_and_the_two_halves_keep_their_own_rules() {
use std::collections::BTreeMap;
let one = |key: &str, value: &str| {
let mut tags = BTreeMap::new();
tags.insert(key.to_string(), value.to_string());
tags
};
require_valid_tags(&one("owner", "")).expect("an empty tag value is legal");
let blank = require_valid_tags(&one("", "conformance")).expect_err("TagKey min is 1");
assert_eq!(blank.kind(), ErrorKind::InvalidArg);
assert!(
blank.to_string().contains("TagValue's minimum is 0"),
"the asymmetry is the thing to say: {blank}"
);
require_valid_tags(&one("owner", &"v".repeat(256))).expect("TagValue max is 256");
let long_value = require_valid_tags(&one("owner", &"v".repeat(257)))
.expect_err("257 is one past TagValue");
assert!(long_value.to_string().contains("\"owner\""), "{long_value}");
assert!(
long_value.to_string().contains("257 characters"),
"{long_value}"
);
require_valid_tags(&one(&"k".repeat(128), "v")).expect("TagKey max is 128");
let long_key =
require_valid_tags(&one(&"k".repeat(129), "v")).expect_err("129 is one past TagKey");
let message = long_key.to_string();
assert!(message.contains("129 characters"), "{message}");
assert!(
message.contains("TagValue allows 256"),
"the message says the two halves are not interchangeable: {message}"
);
let bad_key = require_valid_tags(&one("cost,centre", "x")).expect_err("a comma");
let message = bad_key.to_string();
assert!(message.contains("\"cost,centre\""), "{message}");
assert!(
message.contains("commas and `#` and `%` are not"),
"{message}"
);
let bad_value = require_valid_tags(&one("owner", "50%")).expect_err("a percent");
let message = bad_value.to_string();
assert!(
message.contains("the value of tag \"owner\""),
"the message says which half as well as which key: {message}"
);
let newline = require_valid_tags(&one("owner\n", "x")).expect_err("a control character");
assert!(
newline.to_string().contains(r#""owner\n""#),
"a key with a stray newline is indistinguishable from a good one until it is quoted: \
{newline}"
);
let mut real = BTreeMap::new();
real.insert("owner".to_string(), "conformance".to_string());
real.insert("cost centre".to_string(), "team/agents".to_string());
real.insert("所有者".to_string(), "らいす".to_string());
real.insert("empty".to_string(), String::new());
require_valid_tags(&real).expect("a realistic tag set");
require_valid_tags(&BTreeMap::new()).expect("no tags is not an invalid tag");
}
#[test]
fn the_hook_state_enum_is_the_models_two_values_and_nothing_else() {
assert_eq!(ops::HookState::Disabled.as_str(), "DISABLED");
assert_eq!(ops::HookState::Enabled.as_str(), "ENABLED");
assert_eq!(ops::HookState::Enabled.to_string(), "ENABLED");
assert_eq!(
serde_json::to_value(ops::HookState::Enabled).expect("serialises"),
serde_json::json!("ENABLED")
);
assert_eq!(
serde_json::to_value(ops::HookState::Disabled).expect("serialises"),
serde_json::json!("DISABLED")
);
let spelled = [ops::HookState::Disabled, ops::HookState::Enabled];
let mut wire: Vec<&str> = spelled.iter().map(|state| state.as_str()).collect();
wire.sort_unstable();
let mut pinned = crate::constants::HOOK_STATES.to_vec();
pinned.sort_unstable();
assert_eq!(wire, pinned);
for typo in ["\"ENABLE\"", "\"Enabled\"", "\"ACTIVE\"", "\"enabled\""] {
serde_json::from_str::<ops::HookState>(typo)
.expect_err(&format!("{typo} is not a HookState"));
}
}
}