diff --git a/.github/workflows/rust-port-convergence.yml b/.github/workflows/rust-port-convergence.yml index 6c2370346..641379847 100644 --- a/.github/workflows/rust-port-convergence.yml +++ b/.github/workflows/rust-port-convergence.yml @@ -92,6 +92,14 @@ jobs: real-engine-lifecycle-smoke: name: Real Engine Lifecycle Smoke runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - runner: docker + smoke_target: real-engine-lifecycle-smoke-docker + - runner: podman + smoke_target: real-engine-lifecycle-smoke-podman steps: - name: Checkout uses: actions/checkout@v4 @@ -101,14 +109,24 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Build release binary - run: cargo build --release --manifest-path cmd/devcontainer/Cargo.toml - - - name: Docker version - run: docker version + - name: Install Podman Compose + if: matrix.runner == 'podman' + run: | + sudo apt-get update + sudo apt-get install -y podman podman-compose + + - name: Runtime version + run: | + if [[ "${{ matrix.runner }}" == "podman" ]]; then + podman version + podman-compose version + else + docker version + docker compose version + fi - name: Real engine lifecycle smoke - run: ./scripts/standalone/real-engine-smoke.sh ./cmd/devcontainer/target/release/devcontainer + run: make ${{ matrix.smoke_target }} upstream-convergence: name: Upstream Convergence Baseline diff --git a/Makefile b/Makefile index 853f5c281..50230f642 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ rust-check \ rust-tests \ build-release \ + real-engine-lifecycle-smoke \ + real-engine-lifecycle-smoke-docker \ + real-engine-lifecycle-smoke-podman \ standalone-artifact-smoke \ pypi-wheel-smoke \ native-only-startup-contract \ @@ -44,6 +47,14 @@ rust-tests: build-release: cargo build --release --manifest-path $(RUST_MANIFEST) +real-engine-lifecycle-smoke: real-engine-lifecycle-smoke-docker real-engine-lifecycle-smoke-podman + +real-engine-lifecycle-smoke-docker: build-release + ./scripts/standalone/real-engine-smoke.sh $(RELEASE_BINARY) + +real-engine-lifecycle-smoke-podman: build-release + ./scripts/standalone/real-engine-smoke.sh $(RELEASE_BINARY) --docker-path podman --docker-compose-path podman-compose + standalone-artifact-smoke: build-release ./scripts/standalone/smoke.sh $(RELEASE_BINARY) diff --git a/cmd/devcontainer/src/runtime/compose/args.rs b/cmd/devcontainer/src/runtime/compose/args.rs index 10a372252..90b479443 100644 --- a/cmd/devcontainer/src/runtime/compose/args.rs +++ b/cmd/devcontainer/src/runtime/compose/args.rs @@ -2,27 +2,9 @@ use std::path::PathBuf; -use crate::commands::common; - use super::ComposeSpec; -pub(super) fn compose_args(spec: &ComposeSpec, subcommand: &str, tail: &[&str]) -> Vec { - compose_args_with_override(spec, subcommand, tail, None) -} - -pub(super) fn compose_args_with_override( - spec: &ComposeSpec, - subcommand: &str, - tail: &[&str], - override_file: Option<&PathBuf>, -) -> Vec { - compose_args_owned( - spec, - subcommand, - override_file, - tail.iter().map(|value| value.to_string()).collect(), - ) -} +use crate::commands::common; pub(super) fn compose_args_owned( spec: &ComposeSpec, diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index 897319c23..f1e1b40ff 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -15,6 +15,9 @@ use crate::commands::configuration; use super::context::ResolvedConfig; use super::engine; +const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project"; +const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service"; + pub(crate) struct ComposeSpec { pub(crate) files: Vec, pub(crate) service: String, @@ -182,19 +185,6 @@ pub(crate) fn up_service( Ok(()) } -pub(crate) fn remove_service(resolved: &ResolvedConfig, args: &[String]) -> Result<(), String> { - let spec = load_compose_spec(resolved)? - .ok_or_else(|| "Compose configuration was expected but not found".to_string())?; - let result = engine::run_compose( - args, - args::compose_args(&spec, "rm", &["-s", "-f", &spec.service]), - )?; - if result.status_code != 0 { - return Err(engine::stderr_or_stdout(&result)); - } - Ok(()) -} - pub(crate) fn resolve_container_id( resolved: &ResolvedConfig, args: &[String], @@ -216,12 +206,19 @@ fn resolve_container_id_with_options( ) -> Result, String> { let spec = load_compose_spec(resolved)? .ok_or_else(|| "Compose configuration was expected but not found".to_string())?; - let mut ps_args = vec!["-q"]; + let mut ps_args = vec!["ps".to_string(), "-q".to_string()]; if include_stopped { - ps_args.push("-a"); + ps_args.push("-a".to_string()); } - ps_args.push(&spec.service); - let result = engine::run_compose(args, args::compose_args(&spec, "ps", &ps_args))?; + ps_args.push("--filter".to_string()); + ps_args.push(format!( + "label={COMPOSE_PROJECT_LABEL}={}", + spec.project_name + )); + ps_args.push("--filter".to_string()); + ps_args.push(format!("label={COMPOSE_SERVICE_LABEL}={}", spec.service)); + + let result = engine::run_engine(args, ps_args)?; if result.status_code != 0 { return Err(engine::stderr_or_stdout(&result)); } diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index abf5928ae..ee47fc6c6 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -90,7 +90,7 @@ fn ensure_compose_up_container( let remove_existing = common::has_flag(args, "--remove-existing-container"); if let Some(container_id) = compose::resolve_container_id(resolved, args)? { if remove_existing { - compose::remove_service(resolved, args)?; + remove_container(args, &container_id)?; return create_compose_container(resolved, args, image_name, remote_workspace_folder); } return refresh_compose_container( @@ -105,7 +105,7 @@ fn ensure_compose_up_container( if let Some(container_id) = compose::resolve_container_id_including_stopped(resolved, args)? { if remove_existing { - compose::remove_service(resolved, args)?; + remove_container(args, &container_id)?; return create_compose_container(resolved, args, image_name, remote_workspace_folder); } return refresh_compose_container( diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs index 26d843ce3..3fd33f1a2 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs @@ -50,6 +50,24 @@ fn generated_override_contents(harness: &RuntimeHarness) -> String { content } +fn write_executable(path: &Path, contents: String) { + fs::write(path, contents).expect("executable wrapper"); + let mut permissions = fs::metadata(path) + .expect("executable wrapper metadata") + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + fs::set_permissions(path, permissions).expect("executable wrapper permissions"); +} + +fn compose_label_lookup_args(project_name: &str, service: &str, include_stopped: bool) -> String { + let all_arg = if include_stopped { " -a" } else { "" }; + format!("ps -q{all_arg} --filter label=com.docker.compose.project={project_name} --filter label=com.docker.compose.service={service}") +} + #[test] fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { let harness = RuntimeHarness::new(); @@ -106,7 +124,11 @@ fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { assert!(invocations.contains("compose --project-name workspace_devcontainer -f ")); assert!(invocations.contains(" up -d")); assert!(!invocations.contains(" up -d app")); - assert!(invocations.contains(" ps -q app")); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + false + ))); assert!(invocations.contains("exec -i --workdir /workspace --user vscode")); assert!(invocations.contains("-e HOME=/home/vscode")); assert!(invocations.contains("fake-compose-container-id /bin/echo hello-from-compose")); @@ -239,8 +261,8 @@ fn up_re_resolves_recreated_compose_container_ids() { workspace.to_string_lossy().as_ref(), ], &[ - ("FAKE_PODMAN_COMPOSE_PS_OUTPUT_BEFORE_UP", "old-compose-id"), - ("FAKE_PODMAN_COMPOSE_PS_OUTPUT_AFTER_UP", "new-compose-id"), + ("FAKE_PODMAN_PS_OUTPUT_BEFORE_UP", "old-compose-id"), + ("FAKE_PODMAN_PS_OUTPUT_AFTER_UP", "new-compose-id"), ], ); @@ -249,7 +271,11 @@ fn up_re_resolves_recreated_compose_container_ids() { assert_eq!(payload["containerId"], "new-compose-id"); let invocations = harness.read_invocations(); - assert!(invocations.contains(" ps -q app")); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + false + ))); assert!(invocations.contains("exec --workdir /workspace")); assert!(invocations.contains("-e HOME=/root")); assert!(invocations.contains("new-compose-id /bin/sh -lc echo recreated-post-create")); @@ -293,6 +319,21 @@ fn exec_accepts_custom_compose_binary_for_compose_workspaces() { fs::set_permissions(&compose_wrapper, permissions).expect("compose wrapper permissions"); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let up_output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--docker-compose-path", + compose_wrapper.to_string_lossy().as_ref(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[], + ); + + assert!(up_output.status.success(), "{up_output:?}"); + let output = harness.run( &[ "exec", @@ -305,7 +346,7 @@ fn exec_accepts_custom_compose_binary_for_compose_workspaces() { "/bin/echo", "hello-from-custom-compose", ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "fake-compose-container-id")], + &[], ); assert!(output.status.success(), "{output:?}"); @@ -316,12 +357,79 @@ fn exec_accepts_custom_compose_binary_for_compose_workspaces() { let invocations = harness.read_invocations(); assert!(invocations.contains("compose --project-name workspace_devcontainer -f ")); - assert!(invocations.contains(" ps -q app")); + assert!(invocations.contains(" up -d")); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + false + ))); assert!(invocations.contains("exec -i --workdir /workspace --user vscode")); assert!(invocations.contains("-e HOME=/home/vscode")); assert!(invocations.contains("fake-compose-container-id /bin/echo hello-from-custom-compose")); } +#[test] +fn exec_with_standalone_podman_compose_uses_engine_label_lookup() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let compose_wrapper = harness.root.join("podman-compose"); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\",\n \"remoteUser\": \"vscode\"\n}\n", + ); + write_executable( + &compose_wrapper, + format!( + "#!/bin/sh\nexec \"{}\" compose \"$@\"\n", + harness.fake_podman.display() + ), + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "exec", + "--docker-path", + fake_podman.as_str(), + "--docker-compose-path", + compose_wrapper.to_string_lossy().as_ref(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "/bin/echo", + "hello-from-podman-compose", + ], + &[ + ("FAKE_PODMAN_COMPOSE_PS_REJECT_SERVICE_ARGUMENT", "1"), + ( + "FAKE_PODMAN_PS_REQUIRE_LABELS", + "com.docker.compose.project=workspace_devcontainer\ncom.docker.compose.service=app", + ), + ("FAKE_PODMAN_PS_OUTPUT", "fake-compose-container-id"), + ], + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8 stdout"), + "hello-from-podman-compose\n" + ); + + let invocations = harness.read_invocations(); + assert!(!invocations.contains(" ps -q app")); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + false + ))); + assert!(invocations.contains("fake-compose-container-id /bin/echo hello-from-podman-compose")); +} + #[test] fn up_reused_compose_service_preserves_legacy_devcontainer_id() { let harness = RuntimeHarness::new(); @@ -369,7 +477,7 @@ fn up_reused_compose_service_preserves_legacy_devcontainer_id() { workspace.to_string_lossy().as_ref(), ], &[ - ("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "existing-compose-id"), + ("FAKE_PODMAN_PS_OUTPUT", "existing-compose-id"), ( "FAKE_PODMAN_INSPECT_FILE", inspect_path.to_string_lossy().as_ref(), @@ -409,7 +517,7 @@ fn up_expect_existing_compose_container_fails_when_missing() { workspace.to_string_lossy().as_ref(), "--expect-existing-container", ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "")], + &[("FAKE_PODMAN_PS_OUTPUT", "")], ); assert!(!output.status.success(), "{output:?}"); @@ -422,7 +530,7 @@ fn up_expect_existing_compose_container_fails_when_missing() { } #[test] -fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { +fn up_remove_existing_compose_container_uses_engine_rm() { let harness = RuntimeHarness::new(); let workspace = harness.workspace(); fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); @@ -433,7 +541,7 @@ fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { .expect("compose"); write_devcontainer_config( &workspace, - "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\",\n \"onCreateCommand\": \"echo on-create\",\n \"updateContentCommand\": \"echo update-content\",\n \"postCreateCommand\": \"echo post-create\",\n \"postStartCommand\": \"echo post-start\",\n \"postAttachCommand\": \"echo post-attach\"\n}\n", + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", ); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); @@ -444,13 +552,53 @@ fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { fake_podman.as_str(), "--workspace-folder", workspace.to_string_lossy().as_ref(), + "--remove-existing-container", ], &[ + ("FAKE_PODMAN_PS_OUTPUT_BEFORE_UP", "existing-compose-id"), ( - "FAKE_PODMAN_COMPOSE_PS_OUTPUT", - "stopped-compose-container-id", + "FAKE_PODMAN_PS_OUTPUT_AFTER_UP", + "fake-compose-container-id", ), - ("FAKE_PODMAN_COMPOSE_PS_REQUIRE_ALL", "1"), + ], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["containerId"], "fake-compose-container-id"); + + let invocations = harness.read_invocations(); + assert!(invocations.contains("rm -f existing-compose-id")); + assert!(!invocations.contains(" rm -s -f app")); +} + +#[test] +fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\",\n \"onCreateCommand\": \"echo on-create\",\n \"updateContentCommand\": \"echo update-content\",\n \"postCreateCommand\": \"echo post-create\",\n \"postStartCommand\": \"echo post-start\",\n \"postAttachCommand\": \"echo post-attach\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[ + ("FAKE_PODMAN_PS_OUTPUT", "stopped-compose-container-id"), + ("FAKE_PODMAN_PS_REQUIRE_ALL", "1"), ], ); @@ -459,8 +607,16 @@ fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { assert_eq!(payload["containerId"], "stopped-compose-container-id"); let invocations = harness.read_invocations(); - assert!(invocations.contains(" ps -q app")); - assert!(invocations.contains(" ps -q -a app")); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + false + ))); + assert!(invocations.contains(&compose_label_lookup_args( + "workspace_devcontainer", + "app", + true + ))); assert!(invocations.contains(" up -d --no-recreate")); assert!(!invocations.contains(" up -d app")); @@ -496,7 +652,7 @@ fn up_reuses_existing_compose_container_with_no_recreate() { "--workspace-folder", workspace.to_string_lossy().as_ref(), ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "fake-compose-container-id")], + &[("FAKE_PODMAN_PS_OUTPUT", "fake-compose-container-id")], ); assert!(output.status.success(), "{output:?}"); @@ -529,7 +685,7 @@ fn up_expect_existing_compose_container_uses_no_recreate() { workspace.to_string_lossy().as_ref(), "--expect-existing-container", ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "fake-compose-container-id")], + &[("FAKE_PODMAN_PS_OUTPUT", "fake-compose-container-id")], ); assert!(output.status.success(), "{output:?}"); diff --git a/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs b/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs index ea3928f92..7051b89ce 100644 --- a/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs +++ b/cmd/devcontainer/tests/runtime_lifecycle_smoke/commands.rs @@ -211,7 +211,7 @@ fn compose_lifecycle_commands_honor_explicit_container_id() { "fake-compose-container-id", "--include-configuration", ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "")], + &[], ); assert!(set_up_output.status.success(), "{set_up_output:?}"); @@ -229,7 +229,7 @@ fn compose_lifecycle_commands_honor_explicit_container_id() { "--container-id", "fake-compose-container-id", ], - &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "")], + &[], ); assert!( diff --git a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs index 950218398..b4489dbc7 100644 --- a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs +++ b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs @@ -99,6 +99,25 @@ ${2:-}" exit 0 ;; ps) + if [ "${FAKE_PODMAN_COMPOSE_PS_REJECT_SERVICE_ARGUMENT:-0}" = "1" ]; then + while [ "$#" -gt 0 ]; do + case "${1:-}" in + -q|--quiet) + shift + ;; + -f|--format) + shift 2 + ;; + -*) + shift + ;; + *) + echo "podman-compose: error: unrecognized arguments: ${1:-}" >&2 + exit 2 + ;; + esac + done + fi if [ -n "${FAKE_PODMAN_COMPOSE_PS_OUTPUT_BEFORE_UP:-}" ] || [ -n "${FAKE_PODMAN_COMPOSE_PS_OUTPUT_AFTER_UP:-}" ]; then if [ -f "$LOG_DIR/compose-up-called" ]; then printf '%s\n' "${FAKE_PODMAN_COMPOSE_PS_OUTPUT_AFTER_UP:-}" @@ -229,18 +248,28 @@ ${2:-}" exit 0 ;; ps) + original_args="$*" + has_compose_project_label=0 + has_compose_service_label=0 + case " $original_args " in + *" --filter label=com.docker.compose.project="*) has_compose_project_label=1 ;; + esac + case " $original_args " in + *" --filter label=com.docker.compose.service="*) has_compose_service_label=1 ;; + esac if [ "${FAKE_PODMAN_PS_REQUIRE_ALL:-0}" = "1" ]; then - case " $* " in - *" -a "*) ;; - *) exit 0 ;; - esac + if [ ! -f "$LOG_DIR/compose-service-running" ]; then + case " $* " in + *" -a "*) ;; + *) exit 0 ;; + esac + fi fi required_labels="${FAKE_PODMAN_PS_REQUIRE_LABELS:-}" if [ -z "$required_labels" ] && [ -n "${FAKE_PODMAN_PS_REQUIRE_LABEL:-}" ]; then required_labels="${FAKE_PODMAN_PS_REQUIRE_LABEL}" fi if [ -n "$required_labels" ]; then - original_args="$*" old_ifs="${IFS- }" IFS=' ' @@ -262,9 +291,23 @@ ${2:-}" printf '%s\n' "${FAKE_PODMAN_PS_OUTPUT}" exit 0 fi + if [ -n "${FAKE_PODMAN_PS_OUTPUT_BEFORE_UP:-}" ] || [ -n "${FAKE_PODMAN_PS_OUTPUT_AFTER_UP:-}" ]; then + if [ -f "$LOG_DIR/compose-up-called" ]; then + printf '%s\n' "${FAKE_PODMAN_PS_OUTPUT_AFTER_UP:-}" + else + printf '%s\n' "${FAKE_PODMAN_PS_OUTPUT_BEFORE_UP:-}" + fi + exit 0 + fi if [ "${FAKE_PODMAN_PS_DISABLE_DEFAULT:-0}" = "1" ]; then exit 0 fi + if [ "$has_compose_project_label" = "1" ] && [ "$has_compose_service_label" = "1" ]; then + if [ -f "$LOG_DIR/compose-service-running" ]; then + echo "fake-compose-container-id" + fi + exit 0 + fi echo "fake-container-id" exit 0 ;; diff --git a/scripts/standalone/real-engine-smoke.sh b/scripts/standalone/real-engine-smoke.sh index 83411f03b..44310c63f 100755 --- a/scripts/standalone/real-engine-smoke.sh +++ b/scripts/standalone/real-engine-smoke.sh @@ -1,21 +1,45 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -ne 1 ]]; then - echo "usage: $0 " >&2 +if [[ $# -lt 1 ]]; then + echo "usage: $0 [--docker-path ] [--docker-compose-path ]" >&2 exit 2 fi binary="$1" +shift if [[ ! -x "$binary" ]]; then echo "standalone binary not found or not executable: $binary" >&2 exit 2 fi -if ! command -v docker >/dev/null 2>&1; then - echo "docker is required for real-engine smoke" >&2 - exit 2 -fi +engine_path="docker" +runtime_args=() +while [[ $# -gt 0 ]]; do + case "$1" in + --docker-path) + if [[ $# -lt 2 ]]; then + echo "--docker-path requires a value" >&2 + exit 2 + fi + engine_path="$2" + runtime_args+=("--docker-path" "$2") + shift 2 + ;; + --docker-compose-path) + if [[ $# -lt 2 ]]; then + echo "--docker-compose-path requires a value" >&2 + exit 2 + fi + runtime_args+=("--docker-compose-path" "$2") + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done assert_file_contains() { local file="$1" @@ -27,20 +51,58 @@ assert_file_contains() { fi } +run_devcontainer() { + local subcommand="$1" + shift + local command=("$binary" "$subcommand") + if [[ ${#runtime_args[@]} -gt 0 ]]; then + command+=("${runtime_args[@]}") + fi + command+=("$@") + "${command[@]}" +} + +container_id_from_json() { + local file="$1" + sed -n 's/.*"containerId":"\([^"]*\)".*/\1/p' "$file" +} + +assert_lifecycle_markers() { + local workspace="$1" + shift + local marker + for marker in "$@"; do + if [[ ! -f "$workspace/$marker" ]]; then + echo "expected lifecycle marker $marker" >&2 + ls -la "$workspace" >&2 + exit 1 + fi + done +} + +if ! command -v "$engine_path" >/dev/null 2>&1; then + echo "$engine_path is required for real-engine smoke" >&2 + exit 2 +fi + tmp_dir="$(mktemp -d)" -workspace="$tmp_dir/workspace" -container_id="" +image_workspace="$tmp_dir/image-workspace" +compose_workspace="$tmp_dir/compose-workspace" +container_ids=() cleanup() { - if [[ -n "$container_id" ]]; then - docker rm -f "$container_id" >/dev/null 2>&1 || true + local container_id + if [[ ${#container_ids[@]} -gt 0 ]]; then + for container_id in "${container_ids[@]}"; do + "$engine_path" rm -f "$container_id" >/dev/null 2>&1 || true + done fi rm -rf "$tmp_dir" } trap cleanup EXIT -mkdir -p "$workspace/.devcontainer" -cat >"$workspace/.devcontainer/devcontainer.json" <<'EOF' +mkdir -p "$image_workspace/.devcontainer" +cat >"$image_workspace/.devcontainer/devcontainer.json" <<'EOF' { "image": "alpine:3.20", "workspaceFolder": "/workspace", @@ -53,31 +115,76 @@ cat >"$workspace/.devcontainer/devcontainer.json" <<'EOF' } EOF -"$binary" up --workspace-folder "$workspace" >"$tmp_dir/up.json" -assert_file_contains "$tmp_dir/up.json" '"outcome":"success"' -container_id="$(sed -n 's/.*"containerId":"\([^"]*\)".*/\1/p' "$tmp_dir/up.json")" -if [[ -z "$container_id" ]]; then +run_devcontainer up --workspace-folder "$image_workspace" >"$tmp_dir/image-up.json" +assert_file_contains "$tmp_dir/image-up.json" '"outcome":"success"' +image_container_id="$(container_id_from_json "$tmp_dir/image-up.json")" +if [[ -z "$image_container_id" ]]; then echo "container id missing from up output" >&2 - cat "$tmp_dir/up.json" >&2 + cat "$tmp_dir/image-up.json" >&2 exit 1 fi +container_ids+=("$image_container_id") -for marker in .on-create .update-content .ready .started .attached; do - if [[ ! -f "$workspace/$marker" ]]; then - echo "expected lifecycle marker $marker" >&2 - ls -la "$workspace" >&2 - exit 1 - fi -done +assert_lifecycle_markers "$image_workspace" .on-create .update-content .ready .started .attached -exec_output="$("$binary" exec --workspace-folder "$workspace" /bin/cat /workspace/.ready)" +exec_output="$(run_devcontainer exec --workspace-folder "$image_workspace" /bin/cat /workspace/.ready)" if [[ "$exec_output" != "ready" ]]; then echo "unexpected exec output: $exec_output" >&2 exit 1 fi -"$binary" run-user-commands --workspace-folder "$workspace" >"$tmp_dir/run-user-commands.json" -assert_file_contains "$tmp_dir/run-user-commands.json" '"outcome":"success"' +run_devcontainer run-user-commands --workspace-folder "$image_workspace" >"$tmp_dir/image-run-user-commands.json" +assert_file_contains "$tmp_dir/image-run-user-commands.json" '"outcome":"success"' + +run_devcontainer set-up --workspace-folder "$image_workspace" >"$tmp_dir/image-set-up.json" +assert_file_contains "$tmp_dir/image-set-up.json" '"outcome":"success"' + +mkdir -p "$compose_workspace/.devcontainer" +cat >"$compose_workspace/.devcontainer/docker-compose.yml" <<'EOF' +services: + app: + image: alpine:3.20 + command: sh -c "while sleep 3600; do :; done" +EOF +cat >"$compose_workspace/.devcontainer/devcontainer.json" <<'EOF' +{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "updateRemoteUserUID": false, + "onCreateCommand": "printf compose-on-create > /workspace/.compose-on-create", + "updateContentCommand": "printf compose-update-content > /workspace/.compose-update-content", + "postCreateCommand": "printf compose-ready > /workspace/.compose-ready", + "postStartCommand": "printf compose-started > /workspace/.compose-started", + "postAttachCommand": "printf compose-attached > /workspace/.compose-attached" +} +EOF + +run_devcontainer up --workspace-folder "$compose_workspace" >"$tmp_dir/compose-up.json" +assert_file_contains "$tmp_dir/compose-up.json" '"outcome":"success"' +compose_container_id="$(container_id_from_json "$tmp_dir/compose-up.json")" +if [[ -z "$compose_container_id" ]]; then + echo "compose container id missing from up output" >&2 + cat "$tmp_dir/compose-up.json" >&2 + exit 1 +fi +container_ids+=("$compose_container_id") + +assert_lifecycle_markers "$compose_workspace" \ + .compose-on-create \ + .compose-update-content \ + .compose-ready \ + .compose-started \ + .compose-attached + +compose_exec_output="$(run_devcontainer exec --workspace-folder "$compose_workspace" /bin/cat /workspace/.compose-ready)" +if [[ "$compose_exec_output" != "compose-ready" ]]; then + echo "unexpected compose exec output: $compose_exec_output" >&2 + exit 1 +fi + +run_devcontainer run-user-commands --workspace-folder "$compose_workspace" >"$tmp_dir/compose-run-user-commands.json" +assert_file_contains "$tmp_dir/compose-run-user-commands.json" '"outcome":"success"' -"$binary" set-up --workspace-folder "$workspace" >"$tmp_dir/set-up.json" -assert_file_contains "$tmp_dir/set-up.json" '"outcome":"success"' +run_devcontainer set-up --workspace-folder "$compose_workspace" >"$tmp_dir/compose-set-up.json" +assert_file_contains "$tmp_dir/compose-set-up.json" '"outcome":"success"'