use std::io::Write as _;
use std::time::Duration;
use crate::error::{Error, ErrorKind};
const DOCKERFILE_ENTRY: &str = "Dockerfile";
const AGENTD_ENTRY: &str = "agentd";
const AGENTD_MODE: u32 = 0o755;
pub fn build_artifact(binary: &[u8], dockerfile: &str) -> Result<Vec<u8>, Error> {
use zip::write::{SimpleFileOptions, ZipWriter};
let mut writer = ZipWriter::new(std::io::Cursor::new(Vec::new()));
let deflated =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut write_entry = |name: &str, bytes: &[u8], options: SimpleFileOptions| {
writer
.start_file(name, options)
.and_then(|()| writer.write_all(bytes).map_err(Into::into))
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not add {name} to the build artifact: {error}"),
)
})
};
write_entry(DOCKERFILE_ENTRY, dockerfile.as_bytes(), deflated)?;
write_entry(AGENTD_ENTRY, binary, deflated.unix_permissions(AGENTD_MODE))?;
let bytes = writer
.finish()
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not finish the build artifact: {error}"),
)
})?
.into_inner();
Ok(bytes)
}
pub fn artifact_content_hash(binary: &[u8], dockerfile: &str) -> String {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hasher.update((binary.len() as u64).to_be_bytes());
hasher.update(binary);
hasher.update((dockerfile.len() as u64).to_be_bytes());
hasher.update(dockerfile.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BaseImage {
pub name: String,
pub docker_ref: String,
pub working_dir: String,
}
impl BaseImage {
pub fn al2023() -> Self {
Self {
name: "al2023-1".to_string(),
docker_ref: "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal".to_string(),
working_dir: String::new(),
}
}
pub fn arn(&self, region: &crate::region::Region) -> String {
format!(
"arn:aws:lambda:{}:aws:microvm-image:{}",
region.as_str(),
self.name
)
}
}
impl Default for BaseImage {
fn default() -> Self {
Self::al2023()
}
}
pub fn default_dockerfile(port: u16, workdir: Option<&str>, base: &BaseImage) -> String {
let mut lines = vec![
format!("FROM {}", base.docker_ref),
"COPY agentd /agentd".to_string(),
"RUN chmod 0755 /agentd".to_string(),
];
if let Some(workdir) = workdir.filter(|dir| !dir.is_empty()) {
lines.push(format!("RUN mkdir -p {workdir}"));
lines.push(format!("WORKDIR {workdir}"));
}
lines.extend([
format!("ENV AGENTD_PORT={port}"),
"ENV AGENTD_LOG=info".to_string(),
format!("EXPOSE {port}"),
"ENTRYPOINT []".to_string(),
r#"CMD ["/agentd"]"#.to_string(),
String::new(),
]);
lines.join("\n")
}
pub fn dockerfile_from_ref(dockerfile: &str) -> Option<&str> {
for line in dockerfile.lines() {
let mut words = line.split_whitespace();
let Some(first) = words.next() else { continue };
if !first.eq_ignore_ascii_case("FROM") {
continue;
}
return words.find(|word| !word.starts_with("--"));
}
None
}
pub fn require_workdir(base: &BaseImage, dockerfile: Option<&str>) -> Result<(), Error> {
if !base.working_dir.is_empty() {
return Ok(());
}
let declares_workdir = dockerfile.is_some_and(|text| {
text.lines().any(|line| {
let mut words = line.split_whitespace();
words
.next()
.is_some_and(|first| first.eq_ignore_ascii_case("WORKDIR"))
&& words.next().is_some()
})
});
if declares_workdir {
return Ok(());
}
Err(Error::invalid_arg(format!(
"inherit_workdir was requested but base image {:?} declares no WorkingDir and the \
Dockerfile sets none. Most public ARM64 base images leave it empty \
(docs/PLATFORM.md, 'Most public ARM64 base images have no WORKDIR'), so there is \
nothing to inherit and every relative path would resolve against `/`. Pass a workdir \
to default_dockerfile, or set WORKDIR in your own Dockerfile.",
base.name
)))
}
pub fn dockerfile_agentd_port(dockerfile: &str) -> Option<u16> {
let mut found = None;
for line in dockerfile.lines() {
let mut words = line.split_whitespace();
let Some(first) = words.next() else { continue };
if !first.eq_ignore_ascii_case("ENV") {
continue;
}
let Some(assignment) = words.next() else {
continue;
};
let value = match assignment.split_once('=') {
Some(("AGENTD_PORT", value)) => value,
Some(_) => continue,
None if assignment == "AGENTD_PORT" => match words.next() {
Some(value) => value,
None => continue,
},
None => continue,
};
if let Ok(port) = value.parse() {
found = Some(port);
}
}
found
}
pub fn require_matching_agentd_port(port: u16, dockerfile: &str) -> Result<(), Error> {
let Some(found) = dockerfile_agentd_port(dockerfile) else {
if port == super::DEFAULT_AGENT_PORT {
return Ok(());
}
return Err(Error::invalid_arg(format!(
"this client sends hooks.port={port} but the Dockerfile sets no usable \
AGENTD_PORT, so the daemon will listen on its own default of {default}. These \
must agree: the platform calls the build-time ready/validate hooks on the port \
in the create call, and a daemon listening on {default} answers none of them — \
the docker build succeeds, the daemon logs that it is listening, and the image \
still fails with CREATE_FAILED naming no port. Add ENV AGENTD_PORT={port} to \
the Dockerfile, or build it with default_dockerfile, which derives the value \
from the same port.",
default = super::DEFAULT_AGENT_PORT,
)));
};
if found == port {
return Ok(());
}
Err(Error::invalid_arg(format!(
"the Dockerfile sets ENV AGENTD_PORT={found} but this client sends hooks.port={port}. \
These must agree: the platform calls the build-time ready/validate hooks on the port \
in the create call, and a daemon listening on {found} answers none of them — the \
docker build succeeds, the daemon logs that it is listening, and the image still \
fails with CREATE_FAILED naming no port. Set ENV AGENTD_PORT={port} in the \
Dockerfile, or build it with default_dockerfile, which derives the value from the \
same port."
)))
}
pub fn dockerfile_env_u64(dockerfile: &str, key: &str) -> Option<u64> {
let mut found = None;
for line in dockerfile.lines() {
let mut words = line.split_whitespace();
let Some(first) = words.next() else { continue };
if !first.eq_ignore_ascii_case("ENV") {
continue;
}
let Some(assignment) = words.next() else {
continue;
};
let value = match assignment.split_once('=') {
Some((name, value)) if name == key => value,
Some(_) => continue,
None if assignment == key => match words.next() {
Some(value) => value,
None => continue,
},
None => continue,
};
if let Ok(parsed) = value.parse() {
found = Some(parsed);
}
}
found
}
pub fn require_keepalive_under_idle_timeout(
idle_timeout: Duration,
dockerfile: &str,
) -> Result<(), Error> {
let Some(secs) = dockerfile_env_u64(dockerfile, "AGENTD_SSE_KEEPALIVE_SECS") else {
return Ok(());
};
if secs < idle_timeout.as_secs() {
return Ok(());
}
Err(Error::invalid_arg(format!(
"the Dockerfile sets ENV AGENTD_SSE_KEEPALIVE_SECS={secs} but this client treats a \
stream as dead after {timeout}s of silence. The keepalive must be shorter: the \
daemon sends nothing between events except that keepalive, so an interval of \
{secs}s makes a healthy stream look dead, and every attach reconnects until it \
gives up. The error it raises then reports the client's own {timeout}s as though it \
were the keepalive interval, so it names neither the Dockerfile nor {secs}. Leave \
the variable unset for the daemon's 15s default, or keep it under {timeout}.",
timeout = idle_timeout.as_secs(),
)))
}
pub fn dockerfile_entrypoint(dockerfile: &str) -> Option<&str> {
last_instruction_value(dockerfile, "ENTRYPOINT")
}
pub fn dockerfile_cmd(dockerfile: &str) -> Option<&str> {
last_instruction_value(dockerfile, "CMD")
}
fn last_instruction_value<'a>(dockerfile: &'a str, keyword: &str) -> Option<&'a str> {
let mut found = None;
for line in dockerfile.lines() {
let trimmed = line.trim_start();
let Some(first) = trimmed.split_whitespace().next() else {
continue;
};
if first.eq_ignore_ascii_case(keyword) {
found = Some(trimmed[first.len()..].trim());
}
}
found
}
fn is_empty_exec_form(value: &str) -> bool {
let mut meaningful = value.chars().filter(|c| !c.is_whitespace());
meaningful.next() == Some('[') && meaningful.next() == Some(']') && meaningful.next().is_none()
}
pub fn require_daemon_cmd(dockerfile: &str) -> Result<(), Error> {
if let Some(entrypoint) = dockerfile_entrypoint(dockerfile)
&& !is_empty_exec_form(entrypoint)
{
return Err(Error::invalid_arg(format!(
"the Dockerfile sets ENTRYPOINT {entrypoint}, which makes any CMD its arguments \
rather than the container's process — so the daemon this client uploads never \
starts. The build succeeds anyway: the ready/validate hooks just go unanswered \
and the image fails as CREATE_FAILED, or the launch dies as a run-hook timeout, \
and neither symptom names ENTRYPOINT. Set `ENTRYPOINT []` alongside \
`CMD [\"/agentd\"]`, or build with default_dockerfile, which sets both."
)));
}
match dockerfile_cmd(dockerfile) {
Some(cmd) if !cmd.is_empty() && !is_empty_exec_form(cmd) => Ok(()),
_ => Err(Error::invalid_arg(
"the Dockerfile has no CMD, so the container runs the base image's default \
process and the daemon this client uploads never starts. The build succeeds \
anyway: the ready/validate hooks just go unanswered and the image fails as \
CREATE_FAILED, or the launch dies as a run-hook timeout, and neither symptom \
names CMD. Add `ENTRYPOINT []` and `CMD [\"/agentd\"]`, or build with \
default_dockerfile, which sets both."
.to_string(),
)),
}
}
pub fn require_matching_from(base: &BaseImage, dockerfile: &str) -> Result<(), Error> {
let Some(found) = dockerfile_from_ref(dockerfile) else {
return Ok(());
};
if found == base.docker_ref {
return Ok(());
}
Err(Error::invalid_arg(format!(
"the Dockerfile's FROM is {found:?} but base image {:?} pairs with {:?}. These must \
agree: baseImageArn and the FROM select the same base, and a mismatch builds against \
a base none of the measured platform behaviour applies to. Use default_dockerfile \
with this base, or pass a BaseImage whose docker_ref matches.",
base.name, base.docker_ref
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_artifact_holds_a_dockerfile_and_the_daemon() {
let bytes = build_artifact(b"\x7fELF fake daemon", "FROM scratch\n").expect("zips");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
let mut names: Vec<String> = (0..archive.len())
.map(|index| archive.by_index(index).expect("entry").name().to_string())
.collect();
names.sort();
assert_eq!(names, ["Dockerfile", "agentd"]);
}
#[test]
fn the_daemon_entry_carries_the_execute_bit() {
let bytes = build_artifact(b"binary", "FROM scratch\n").expect("zips");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
let mode = archive
.by_name("agentd")
.expect("the daemon entry")
.unix_mode()
.expect("a mode was recorded");
assert_eq!(
mode & 0o777,
AGENTD_MODE,
"a non-executable binary surfaces as a run-hook timeout, not a permission error \
(mode was {mode:o})"
);
assert!(
mode & 0o111 != 0,
"the execute bit is the whole point: {mode:o}"
);
}
#[test]
fn the_daemon_bytes_survive_the_round_trip() {
use std::io::Read as _;
let binary: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
let bytes = build_artifact(&binary, "FROM scratch\n").expect("zips");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
let mut read = Vec::new();
archive
.by_name("agentd")
.expect("entry")
.read_to_end(&mut read)
.expect("reads");
assert_eq!(read, binary);
}
#[test]
fn the_artifact_never_carries_the_agent_token() {
use std::io::Read as _;
let token = "s3cr3t-agent-token-do-not-bake-me";
let dockerfile = default_dockerfile(9000, Some("/opt/work"), &BaseImage::al2023());
assert!(
!dockerfile.contains(token),
"the default Dockerfile must not mention a token"
);
let bytes = build_artifact(b"binary", &dockerfile).expect("zips");
assert!(
!bytes
.windows(token.len())
.any(|window| window == token.as_bytes()),
"the token appears in the artifact's raw bytes"
);
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
for index in 0..archive.len() {
let mut entry = archive.by_index(index).expect("entry");
let name = entry.name().to_string();
let mut content = Vec::new();
entry.read_to_end(&mut content).expect("reads");
assert!(
!content
.windows(token.len())
.any(|window| window == token.as_bytes()),
"the token appears inside {name} — the image snapshot is shared by every VM \
launched from it, so a token here is a token shared with every VM"
);
}
}
#[test]
fn the_content_hash_follows_the_inputs_and_only_the_inputs() {
let hash = artifact_content_hash(b"binary-bytes", "FROM scratch\n");
assert_eq!(
hash,
artifact_content_hash(b"binary-bytes", "FROM scratch\n")
);
assert_eq!(hash.len(), 64, "sha256 as lowercase hex");
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_ne!(
hash,
artifact_content_hash(b"other-bytes", "FROM scratch\n")
);
assert_ne!(
hash,
artifact_content_hash(b"binary-bytes", "FROM scratch\nRUN true\n")
);
assert_ne!(
artifact_content_hash(b"ab", "c"),
artifact_content_hash(b"a", "bc"),
"the input boundary is part of the identity"
);
}
#[test]
fn the_default_dockerfile_derives_its_from_from_the_base_image() {
let base = BaseImage::al2023();
let dockerfile = default_dockerfile(9000, None, &base);
assert_eq!(
dockerfile_from_ref(&dockerfile),
Some(base.docker_ref.as_str())
);
assert!(dockerfile.contains(r#"CMD ["/agentd"]"#));
assert!(
dockerfile.contains("ENTRYPOINT []"),
"the trust boundary rests on this line"
);
assert!(dockerfile.contains("ENV AGENTD_PORT=9000"));
}
#[test]
fn a_workdir_is_created_and_set_or_absent_entirely() {
let base = BaseImage::al2023();
let with = default_dockerfile(9000, Some("/opt/baked-workdir"), &base);
assert!(with.contains("RUN mkdir -p /opt/baked-workdir"));
assert!(with.contains("WORKDIR /opt/baked-workdir"));
for none in [None, Some("")] {
let without = default_dockerfile(9000, none, &base);
assert!(!without.contains("WORKDIR"), "{without}");
}
}
#[test]
fn the_from_parser_ignores_decoration_rather_than_validating_syntax() {
assert_eq!(dockerfile_from_ref("FROM alpine\n"), Some("alpine"));
assert_eq!(dockerfile_from_ref(" from alpine:3\n"), Some("alpine:3"));
assert_eq!(
dockerfile_from_ref("FROM --platform=linux/arm64 alpine AS build\n"),
Some("alpine")
);
assert_eq!(dockerfile_from_ref("RUN echo from nowhere\n"), None);
assert_eq!(dockerfile_from_ref(""), None);
}
#[test]
fn a_dockerfile_from_that_disagrees_with_the_base_is_refused() {
let base = BaseImage::al2023();
let error = require_matching_from(&base, "FROM ubuntu:24.04\nCOPY agentd /agentd\n")
.expect_err("ubuntu is not the managed base");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("ubuntu:24.04"), "{message}");
assert!(message.contains(&base.docker_ref), "{message}");
assert!(
message.contains("none of the measured platform behaviour applies"),
"{message}"
);
require_matching_from(&base, &default_dockerfile(9000, None, &base))
.expect("the derived Dockerfile agrees with its own base");
}
#[test]
fn a_dockerfile_with_no_from_is_left_to_the_build() {
require_matching_from(&BaseImage::al2023(), "COPY agentd /agentd\n")
.expect("no FROM is not a disagreement");
}
#[test]
fn a_dockerfile_port_that_disagrees_with_the_hook_port_is_refused() {
let error = require_matching_agentd_port(
crate::control::DEFAULT_AGENT_PORT,
"FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
COPY agentd /agentd\n\
ENV AGENTD_PORT=8080\n\
EXPOSE 8080\n\
CMD [\"/agentd\"]\n",
)
.expect_err("8080 is not the port the create call sends");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("8080"), "{message}");
assert!(message.contains("9000"), "{message}");
assert!(message.contains("CREATE_FAILED"), "{message}");
assert!(message.contains("ready/validate"), "{message}");
require_matching_agentd_port(
crate::control::DEFAULT_AGENT_PORT,
&default_dockerfile(
crate::control::DEFAULT_AGENT_PORT,
Some("/work"),
&BaseImage::al2023(),
),
)
.expect("the derived Dockerfile agrees with the port it was derived from");
}
#[test]
fn the_port_scan_reads_both_env_spellings_and_takes_the_last() {
assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=9000\n"), Some(9000));
assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT 8080\n"), Some(8080));
assert_eq!(dockerfile_agentd_port("env agentd_port=7000\n"), None);
assert_eq!(
dockerfile_agentd_port("ENV AGENTD_PORT=8080\nENV AGENTD_PORT=9000\n"),
Some(9000),
);
assert_eq!(dockerfile_agentd_port("ENV AGENTD_LOG=info\n"), None);
assert_eq!(
dockerfile_agentd_port("FROM x\nCOPY agentd /agentd\n"),
None
);
assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=nine\n"), None);
require_matching_agentd_port(
crate::control::DEFAULT_AGENT_PORT,
"FROM x\nCOPY agentd /agentd\n",
)
.expect("no variable agrees with the default, which is what the daemon will use");
}
#[test]
fn a_dockerfile_naming_no_port_is_refused_when_the_client_moved_off_the_default() {
let error = require_matching_agentd_port(
8080,
"FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
COPY agentd /agentd\n\
CMD [\"/agentd\"]\n",
)
.expect_err("a silent Dockerfile leaves the daemon on 9000, not on 8080");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("8080"), "{message}");
assert!(
message.contains(&crate::control::DEFAULT_AGENT_PORT.to_string()),
"the daemon's default is the value the reader has to learn: {message}"
);
assert!(message.contains("CREATE_FAILED"), "{message}");
assert!(message.contains("ready/validate"), "{message}");
require_matching_agentd_port(8080, "FROM x\nENV AGENTD_PORT=nine\n")
.expect_err("a value the daemon cannot parse leaves it on its own default");
require_matching_agentd_port(
crate::control::DEFAULT_AGENT_PORT,
"FROM x\nCOPY agentd /agentd\n",
)
.expect("silence agrees with the default");
}
#[test]
fn a_keepalive_at_or_over_the_client_idle_timeout_is_refused() {
let timeout = crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT;
assert_eq!(timeout.as_secs(), 60, "the guard's arithmetic assumes this");
let error =
require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=90\n")
.expect_err("90s of scheduled silence exceeds a 60s tolerance");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("90"), "{message}");
assert!(message.contains("60"), "{message}");
require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=60\n")
.expect_err("an interval equal to the tolerance is a race, not a margin");
require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS 30\n")
.expect("30s leaves a margin, in the legacy spelling");
require_keepalive_under_idle_timeout(timeout, "FROM x\nCOPY agentd /agentd\n")
.expect("an unset keepalive leaves the daemon at 15s");
require_keepalive_under_idle_timeout(
timeout,
&default_dockerfile(
crate::control::DEFAULT_AGENT_PORT,
Some("/work"),
&BaseImage::al2023(),
),
)
.expect("the derived Dockerfile sets no keepalive");
}
#[test]
fn a_dockerfile_that_never_runs_the_daemon_is_refused_naming_the_symptom() {
let error = require_daemon_cmd(
"FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
COPY agentd /agentd\n\
ENV AGENTD_PORT=9000\n",
)
.expect_err("no CMD means the daemon never starts");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("no CMD"), "{message}");
assert!(message.contains("run-hook timeout"), "{message}");
assert!(message.contains("CREATE_FAILED"), "{message}");
let error = require_daemon_cmd(
"FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
COPY agentd /agentd\n\
ENTRYPOINT [\"/bin/sh\", \"-c\"]\n\
CMD [\"/agentd\"]\n",
)
.expect_err("a non-empty ENTRYPOINT makes CMD its arguments");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("ENTRYPOINT"), "{message}");
assert!(message.contains("run-hook timeout"), "{message}");
require_daemon_cmd("FROM x\nCMD []\n").expect_err("CMD [] runs nothing");
}
#[test]
fn the_deployment_invariant_passes_the_daemon_cmd_guard() {
require_daemon_cmd(&default_dockerfile(
9000,
Some("/work"),
&BaseImage::al2023(),
))
.expect("the derived Dockerfile is the invariant");
require_daemon_cmd("FROM x\nENTRYPOINT []\nCMD [\"/agentd\"]\n").expect("the invariant");
require_daemon_cmd("FROM x\nENTRYPOINT [ ]\nCMD [\"/agentd\"]\n")
.expect("interior whitespace is still the empty exec form");
require_daemon_cmd("FROM x\ncmd [\"/agentd\"]\n").expect("case-insensitive, no ENTRYPOINT");
require_daemon_cmd("FROM x\nENTRYPOINT [\"/bin/sh\"]\nENTRYPOINT []\nCMD [\"/agentd\"]\n")
.expect("the last ENTRYPOINT is the one the build uses");
require_daemon_cmd("FROM x\nENTRYPOINT []\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n")
.expect_err("the last ENTRYPOINT is non-empty");
}
#[test]
fn inheriting_a_workdir_nothing_declares_is_refused() {
let base = BaseImage::al2023();
assert!(base.working_dir.is_empty(), "the measured case");
let error = require_workdir(&base, None).expect_err("nothing declares a workdir");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
let message = error.to_string();
assert!(message.contains("no WorkingDir"), "{message}");
assert!(message.contains("nothing to inherit"), "{message}");
assert!(message.contains("docs/PLATFORM.md"), "{message}");
}
#[test]
fn either_the_dockerfile_or_the_base_may_supply_the_workdir() {
let base = BaseImage::al2023();
require_workdir(&base, Some("FROM x\nWORKDIR /srv\n"))
.expect("the Dockerfile declares one");
let purpose_built = BaseImage {
working_dir: "/app".to_string(),
..BaseImage::al2023()
};
require_workdir(&purpose_built, None).expect("the base image declares one");
}
#[test]
fn a_workdir_with_no_argument_does_not_count_as_a_declaration() {
let error = require_workdir(&BaseImage::al2023(), Some("FROM x\nWORKDIR\n"))
.expect_err("WORKDIR with no path declares nothing");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
}
#[test]
fn the_base_image_arn_names_the_request_region_with_a_colon_separator() {
assert_eq!(
BaseImage::al2023().arn(&crate::region::Region::UsEast1),
"arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
);
assert_eq!(
BaseImage::al2023().arn(&crate::region::Region::ApNortheast1),
"arn:aws:lambda:ap-northeast-1:aws:microvm-image:al2023-1"
);
assert!(
!BaseImage::al2023()
.arn(&crate::region::Region::UsEast1)
.contains("microvm-image/"),
"the slash form answers AccessDeniedException, not a validation error"
);
}
}