Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 136 additions & 8 deletions crates/fakecloud-core/src/container_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ const MAX_PULL_ATTEMPTS: u32 = 5;
/// Delay before the first retry; doubles on each further retry.
const BASE_RETRY_DELAY: Duration = Duration::from_secs(1);

/// How the registry a pulled reference names is reached.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryTransport {
/// TLS, as every upstream registry (ECR Public, Docker Hub, ...) serves.
Https,
/// Plain HTTP: fakecloud's own OCI registry, which an AWS ECR URI is
/// rewritten to. Docker treats a loopback registry as insecure on its
/// own, but Podman insists on TLS for every registry unless told
/// otherwise, so its pull fails with "server gave HTTP response to HTTPS
/// client" without `--tls-verify=false`.
PlainHttp,
}

impl RegistryTransport {
/// The transport for a pull whose reference was (`true`) or was not
/// rewritten to fakecloud's registry by
/// [`crate::ecr_uri::translate_to_local_at`].
pub fn for_local_rewrite(rewritten: bool) -> Self {
if rewritten {
Self::PlainHttp
} else {
Self::Https
}
}
}

/// How an image became available for a launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PulledImage {
Expand All @@ -48,15 +74,17 @@ pub enum PulledImage {
/// falls back to a locally cached copy, or is retried with backoff when
/// nothing is cached; any other failure is returned at once. `docker_config`
/// is exported as `DOCKER_CONFIG` for the pull so registry credentials
/// resolve.
/// resolve. `transport` says whether the registry is fakecloud's own
/// plain-HTTP one.
///
/// Returns the pull's stderr as the error.
pub async fn pull_image(
cli: &str,
docker_config: Option<&Path>,
reference: &str,
transport: RegistryTransport,
) -> Result<PulledImage, String> {
pull_image_with(cli, docker_config, reference, BASE_RETRY_DELAY).await
pull_image_with(cli, docker_config, reference, transport, BASE_RETRY_DELAY).await
}

/// Make `reference` available locally, pulling it only when it is not
Expand All @@ -81,13 +109,21 @@ async fn ensure_image_with(
if image_cached(cli, docker_config, reference).await {
return Ok(PulledImage::Present);
}
pull_image_with(cli, docker_config, reference, base_delay).await
pull_image_with(
cli,
docker_config,
reference,
RegistryTransport::Https,
base_delay,
)
.await
}

async fn pull_image_with(
cli: &str,
docker_config: Option<&Path>,
reference: &str,
transport: RegistryTransport,
base_delay: Duration,
) -> Result<PulledImage, String> {
let mut delay = base_delay;
Expand All @@ -98,7 +134,7 @@ async fn pull_image_with(
cmd.env("DOCKER_CONFIG", p);
}
let out = cmd
.args(["pull", reference])
.args(pull_args(cli, reference, transport))
.output()
.await
.map_err(|e| format!("{cli} pull: {e}"))?;
Expand Down Expand Up @@ -132,6 +168,18 @@ async fn pull_image_with(
}
}

/// The `pull` arguments for `cli`. Only Podman needs telling that a
/// plain-HTTP registry is one: Docker accepts it for a loopback registry, and
/// its `pull` has no `--tls-verify` flag at all.
fn pull_args(cli: &str, reference: &str, transport: RegistryTransport) -> Vec<String> {
let mut args = vec!["pull".to_string()];
if transport == RegistryTransport::PlainHttp && crate::container_net::is_podman_binary(cli) {
args.push("--tls-verify=false".to_string());
}
args.push(reference.to_string());
args
}

/// Whether `reference` resolves to an image in the local cache. Runs with
/// the same `DOCKER_CONFIG` as the pull: the config also selects the Docker
/// context, so without it the check could consult a different daemon than
Expand Down Expand Up @@ -276,10 +324,17 @@ mod tests {
/// when `cached`. Every invocation is appended to `calls.log`.
struct FakeCli {
dir: tempfile::TempDir,
name: String,
}

impl FakeCli {
fn new(pull_failures: u32, pull_stderr: &str, cached: bool) -> Self {
Self::named("cli", pull_failures, pull_stderr, cached)
}

/// A fake installed under `name`, which is what decides whether
/// fakecloud drives it as Podman.
fn named(name: &str, pull_failures: u32, pull_stderr: &str, cached: bool) -> Self {
let dir = tempfile::tempdir().unwrap();
let script = format!(
r#"#!/bin/sh
Expand All @@ -304,7 +359,7 @@ exit 2
"#,
dir = dir.path().display(),
);
let path = dir.path().join("cli");
let path = dir.path().join(name);
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
// Executing a file that another process holds open for writing
Expand All @@ -327,11 +382,14 @@ exit 2
}
}
let _ = std::fs::remove_file(dir.path().join("calls.log"));
Self { dir }
Self {
dir,
name: name.to_string(),
}
}

fn cli(&self) -> String {
self.dir.path().join("cli").display().to_string()
self.dir.path().join(&self.name).display().to_string()
}

fn calls(&self) -> Vec<String> {
Expand All @@ -347,7 +405,22 @@ exit 2
}

async fn pull_ref(&self, reference: &str) -> Result<PulledImage, String> {
pull_image_with(&self.cli(), None, reference, Duration::from_millis(1)).await
self.pull_via(reference, RegistryTransport::Https).await
}

async fn pull_via(
&self,
reference: &str,
transport: RegistryTransport,
) -> Result<PulledImage, String> {
pull_image_with(
&self.cli(),
None,
reference,
transport,
Duration::from_millis(1),
)
.await
}

async fn ensure(&self) -> Result<PulledImage, String> {
Expand Down Expand Up @@ -384,6 +457,61 @@ exit 2
assert_eq!(cli.ensure().await, Err(missing.to_string()));
}

#[tokio::test]
async fn podman_pulls_fakecloud_registry_over_plain_http() {
// Podman defaults to TLS even for a loopback registry, and fakecloud's
// serves plain HTTP (issue #2585).
let cli = FakeCli::named("podman", 0, "", false);
let got = cli
.pull_via("127.0.0.1:4566/test:healthy", RegistryTransport::PlainHttp)
.await;
assert_eq!(got, Ok(PulledImage::Pulled));
assert_eq!(
cli.calls(),
["pull --tls-verify=false 127.0.0.1:4566/test:healthy"]
);
}

#[tokio::test]
async fn podman_keeps_tls_for_upstream_registries() {
let cli = FakeCli::named("podman", 0, "", false);
let got = cli
.pull_via(
"public.ecr.aws/docker/library/alpine:3.20",
RegistryTransport::Https,
)
.await;
assert_eq!(got, Ok(PulledImage::Pulled));
assert_eq!(
cli.calls(),
["pull public.ecr.aws/docker/library/alpine:3.20"]
);
}

#[tokio::test]
async fn docker_pulls_fakecloud_registry_without_a_tls_flag() {
// `docker pull` has no --tls-verify; the daemon already treats a
// loopback registry as insecure.
let cli = FakeCli::named("docker", 0, "", false);
let got = cli
.pull_via("127.0.0.1:4566/test:healthy", RegistryTransport::PlainHttp)
.await;
assert_eq!(got, Ok(PulledImage::Pulled));
assert_eq!(cli.calls(), ["pull 127.0.0.1:4566/test:healthy"]);
}

#[test]
fn transport_follows_the_local_rewrite() {
assert_eq!(
RegistryTransport::for_local_rewrite(true),
RegistryTransport::PlainHttp
);
assert_eq!(
RegistryTransport::for_local_rewrite(false),
RegistryTransport::Https
);
}

#[tokio::test]
async fn a_successful_pull_needs_no_cache_check() {
let cli = FakeCli::new(0, "", false);
Expand Down
12 changes: 8 additions & 4 deletions crates/fakecloud-core/src/ecr_uri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
//! fakecloud can't pull from AWS — there is no AWS account. Instead we
//! translate the URI to `127.0.0.1:<server-port>/<repo>:<tag>` and pull
//! from fakecloud's own OCI v2 registry (which is just another route on
//! the same HTTP server). Docker treats `127.0.0.1:<port>` as an insecure
//! registry automatically on both Linux and Docker Desktop, so no daemon
//! config is required.
//! the same HTTP server), which serves plain HTTP. Docker treats
//! `127.0.0.1:<port>` as an insecure registry automatically on both Linux and
//! Docker Desktop, so no daemon config is required; Podman does not, so its
//! pulls are told explicitly (see
//! [`crate::container_image::RegistryTransport`]).

/// Detect whether `image` is an AWS private-ECR URI. Match shape:
/// `<any>.dkr.ecr.<any>.amazonaws.com/<path>[:<tag>|@sha256:<digest>]`.
Expand All @@ -27,7 +29,9 @@ pub fn is_aws_ecr_uri(image: &str) -> bool {
/// go straight to the upstream daemon.
///
/// Docker's localhost-registry behaviour means the daemon on both Linux
/// and macOS Docker Desktop accepts `127.0.0.1:<port>` over plain HTTP.
/// and macOS Docker Desktop accepts `127.0.0.1:<port>` over plain HTTP;
/// Podman needs `--tls-verify=false`, which
/// [`crate::container_image::pull_image`] adds for a rewritten reference.
pub fn translate_to_local(image: &str, server_port: u16) -> Option<String> {
translate_to_local_at(image, "127.0.0.1", server_port)
}
Expand Down
24 changes: 15 additions & 9 deletions crates/fakecloud-e2e/src/bin/e2e_nextest_partitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ use serde_json::{json, Value};

const PACKAGE: &str = "fakecloud-e2e";
const USAGE: &str = "usage: e2e_nextest_partitions [matrix|check]";
// Everything outside the Lambda binaries and the podman-only ECS binary, which
// needs the podman install only the podman partition pays for.
const GENERAL_FILTER: &str = concat!(
"package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke) ",
"and not binary(ecs_podman_ecr)",
);
const LAMBDA_RUNTIME_FAMILY_PARTITIONS: [&str; 6] = [
"lambda-runtimes-python",
"lambda-runtimes-nodejs",
Expand Down Expand Up @@ -90,49 +96,49 @@ struct Partition {
const PARTITIONS: [Partition; 19] = [
Partition {
name: "general-1",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:1/8"),
install_podman: false,
},
Partition {
name: "general-2",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:2/8"),
install_podman: false,
},
Partition {
name: "general-3",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:3/8"),
install_podman: false,
},
Partition {
name: "general-4",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:4/8"),
install_podman: false,
},
Partition {
name: "general-5",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:5/8"),
install_podman: false,
},
Partition {
name: "general-6",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:6/8"),
install_podman: false,
},
Partition {
name: "general-7",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:7/8"),
install_podman: false,
},
Partition {
name: "general-8",
filter: "package(fakecloud-e2e) and not binary(lambda) and not binary(lambda_invoke)",
filter: GENERAL_FILTER,
partition: Some("hash:8/8"),
install_podman: false,
},
Expand Down Expand Up @@ -199,7 +205,7 @@ const PARTITIONS: [Partition; 19] = [
},
Partition {
name: "lambda-container-podman",
filter: "binary(lambda) and test(lambda_invoke_podman)",
filter: "(binary(lambda) and test(lambda_invoke_podman)) or binary(ecs_podman_ecr)",
partition: None,
install_podman: true,
},
Expand Down
Loading
Loading