diff --git a/scripts/install-compat-tools.sh b/scripts/install-compat-tools.sh new file mode 100755 index 0000000..09b07a5 --- /dev/null +++ b/scripts/install-compat-tools.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +AWS_CLI_VERSION="2.37.6" +TERRAFORM_VERSION="1.16.4" +AWS_CLI_FINGERPRINT="FB5DB77FD5C118B80511ADA8A6310ACC4672475C" +HASHICORP_FINGERPRINT="798AEC654E5C15428C8E42EEAA16FCBCA621E701" + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi +if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then + echo "install-compat-tools.sh supports Linux x86_64 only" >&2 + exit 2 +fi +for command in curl gpg sha256sum unzip; do + command -v "$command" >/dev/null || { echo "missing required command: $command" >&2; exit 2; } +done + +TOOL_ROOT=$(mkdir -p "$1" && cd "$1" && pwd) +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT +GNUPGHOME="$TEMP_DIR/gnupg" +export GNUPGHOME +mkdir -m 700 "$GNUPGHOME" + +verify_key() { + local expected=$1 + local actual + actual=$(gpg --batch --with-colons --fingerprint "$expected" | awk -F: '$1 == "fpr" { print $10; exit }') + [[ "$actual" == "$expected" ]] || { echo "unexpected signing key fingerprint: $actual" >&2; exit 1; } +} + +gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$AWS_CLI_FINGERPRINT" +verify_key "$AWS_CLI_FINGERPRINT" +curl --fail --silent --show-error --location \ + "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-${AWS_CLI_VERSION}.zip" \ + --output "$TEMP_DIR/awscliv2.zip" +curl --fail --silent --show-error --location \ + "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-${AWS_CLI_VERSION}.zip.sig" \ + --output "$TEMP_DIR/awscliv2.zip.sig" +gpg --batch --verify "$TEMP_DIR/awscliv2.zip.sig" "$TEMP_DIR/awscliv2.zip" +unzip -q "$TEMP_DIR/awscliv2.zip" -d "$TEMP_DIR/aws" +"$TEMP_DIR/aws/aws/install" --install-dir "$TOOL_ROOT/aws-cli" --bin-dir "$TOOL_ROOT/aws" + +gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$HASHICORP_FINGERPRINT" +verify_key "$HASHICORP_FINGERPRINT" +TERRAFORM_BASE="https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}" +curl --fail --silent --show-error --location \ + "$TERRAFORM_BASE/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \ + --output "$TEMP_DIR/terraform.zip" +curl --fail --silent --show-error --location \ + "$TERRAFORM_BASE/terraform_${TERRAFORM_VERSION}_SHA256SUMS" \ + --output "$TEMP_DIR/terraform_SHA256SUMS" +curl --fail --silent --show-error --location \ + "$TERRAFORM_BASE/terraform_${TERRAFORM_VERSION}_SHA256SUMS.sig" \ + --output "$TEMP_DIR/terraform_SHA256SUMS.sig" +gpg --batch --verify "$TEMP_DIR/terraform_SHA256SUMS.sig" "$TEMP_DIR/terraform_SHA256SUMS" +(cd "$TEMP_DIR" && grep ' terraform_.*_linux_amd64.zip$' terraform_SHA256SUMS | sha256sum --check --status -) +mkdir -p "$TOOL_ROOT/terraform" +unzip -qo "$TEMP_DIR/terraform.zip" -d "$TOOL_ROOT/terraform" + +"$TOOL_ROOT/aws/aws" --version +"$TOOL_ROOT/terraform/terraform" --version diff --git a/test/compatibility/client_tools.py b/test/compatibility/client_tools.py new file mode 100644 index 0000000..19a5e5d --- /dev/null +++ b/test/compatibility/client_tools.py @@ -0,0 +1,54 @@ +"""Pinned external-client helpers for DevCloud compatibility tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from collections.abc import Mapping, Sequence +import subprocess + + +AWS_CLI_VERSION = "2.37.6" +TERRAFORM_VERSION = "1.16.4" + + +@dataclass(frozen=True) +class ToolPaths: + aws: Path + terraform: Path + + +def run_command( + argv: Sequence[str], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + argv, + cwd=cwd, + env=dict(env) if env is not None else None, + capture_output=True, + text=True, + check=False, + ) + + +def _require_version(path: Path, expected: str, label: str) -> None: + if not path.is_file() or not path.stat().st_mode & 0o111: + version = expected.removeprefix("aws-cli/").removeprefix("Terraform v") + raise RuntimeError(f"missing {label} {version} binary at {path}") + result = run_command([str(path), "--version"]) + if result.returncode != 0 or expected not in result.stdout: + actual = result.stdout.strip() or result.stderr.strip() or "no version output" + raise RuntimeError(f"{label} expected {expected}, got {actual}") + + +def resolve_tools(tool_root: Path) -> ToolPaths: + tools = ToolPaths( + aws=tool_root / "aws" / "aws", + terraform=tool_root / "terraform" / "terraform", + ) + _require_version(tools.aws, f"aws-cli/{AWS_CLI_VERSION}", "aws CLI") + _require_version(tools.terraform, f"Terraform v{TERRAFORM_VERSION}", "Terraform") + return tools diff --git a/test/compatibility/conftest.py b/test/compatibility/conftest.py index 389d631..66e8e36 100644 --- a/test/compatibility/conftest.py +++ b/test/compatibility/conftest.py @@ -8,6 +8,11 @@ import boto3 import os import signal +import json +from pathlib import Path +import shlex + +from client_tools import resolve_tools, run_command def _find_free_port(): @@ -160,6 +165,79 @@ def service_client(devcloud_server): return _make_client +def _client_env(): + env = os.environ.copy() + env.update( + { + "AWS_ACCESS_KEY_ID": "test", + "AWS_SECRET_ACCESS_KEY": "test", + "AWS_EC2_METADATA_DISABLED": "true", + "AWS_PAGER": "", + } + ) + return env + + +@pytest.fixture(scope="session") +def compat_tools(): + root = os.environ.get("DEVCLOUD_COMPAT_TOOLS") + if not root: + raise RuntimeError("DEVCLOUD_COMPAT_TOOLS must point to pinned client binaries") + return resolve_tools(Path(root)) + + +@pytest.fixture +def aws_cli(devcloud_server, compat_tools): + def invoke(command): + return run_command( + [ + str(compat_tools.aws), + "--endpoint-url", + DEVCLOUD_URL, + "--region", + "us-east-1", + "--output", + "json", + *shlex.split(command), + ], + env=_client_env(), + ) + + return invoke + + +@pytest.fixture +def terraform_workspace(devcloud_server, tmp_path): + template = Path(__file__).with_name("terraform") + + def create(module): + if module not in {"s3", "sqs", "dynamodb", "iam_lambda"}: + raise ValueError(f"unknown Terraform contract module: {module}") + workspace = tmp_path / "terraform" + shutil.copytree(template, workspace) + (workspace / "main.tf").write_text( + f'module "contract" {{ source = "./modules/{module}" }}\n' + ) + (workspace / "devcloud.auto.tfvars.json").write_text( + json.dumps({"devcloud_endpoint": DEVCLOUD_URL}) + ) + return workspace + + return create + + +@pytest.fixture +def terraform_cmd(compat_tools): + def invoke(workspace, *args): + return run_command( + [str(compat_tools.terraform), "-no-color", *args], + cwd=workspace, + env=_client_env(), + ) + + return invoke + + # --- Existing service fixtures --- diff --git a/test/compatibility/requirements.txt b/test/compatibility/requirements.txt index f5fb980..0839b8a 100644 --- a/test/compatibility/requirements.txt +++ b/test/compatibility/requirements.txt @@ -1,3 +1,4 @@ -boto3>=1.34.0 -pytest>=8.0.0 -cryptography>=42.0.0 +# Version upgrades are deliberate compatibility-contract changes. +boto3==1.43.105 +pytest==9.1.1 +cryptography==50.0.1 diff --git a/test/compatibility/terraform/.gitkeep b/test/compatibility/terraform/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/compatibility/terraform/modules/s3/.gitkeep b/test/compatibility/terraform/modules/s3/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/compatibility/terraform/providers.tf b/test/compatibility/terraform/providers.tf new file mode 100644 index 0000000..0e2686d --- /dev/null +++ b/test/compatibility/terraform/providers.tf @@ -0,0 +1,20 @@ +variable "devcloud_endpoint" { + type = string +} + +provider "aws" { + region = "us-east-1" + access_key = "test" + secret_key = "test" + skip_credentials_validation = true + skip_metadata_api_check = true + + endpoints { + s3 = var.devcloud_endpoint + sqs = var.devcloud_endpoint + dynamodb = var.devcloud_endpoint + lambda = var.devcloud_endpoint + iam = var.devcloud_endpoint + sts = var.devcloud_endpoint + } +} diff --git a/test/compatibility/terraform/versions.tf b/test/compatibility/terraform/versions.tf new file mode 100644 index 0000000..6b6c642 --- /dev/null +++ b/test/compatibility/terraform/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = "= 1.16.4" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.66.0" + } + } +} diff --git a/test/compatibility/test_client_harness.py b/test/compatibility/test_client_harness.py new file mode 100644 index 0000000..d408bed --- /dev/null +++ b/test/compatibility/test_client_harness.py @@ -0,0 +1,15 @@ +import json + + +def test_aws_cli_uses_devcloud_endpoint(aws_cli): + result = aws_cli("sts get-caller-identity") + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["Account"] + + +def test_terraform_workspace_is_isolated(terraform_workspace): + workspace = terraform_workspace("s3") + + assert (workspace / "devcloud.auto.tfvars.json").is_file() + assert 'source = "./modules/s3"' in (workspace / "main.tf").read_text() diff --git a/test/compatibility/test_client_tools.py b/test/compatibility/test_client_tools.py new file mode 100644 index 0000000..1991200 --- /dev/null +++ b/test/compatibility/test_client_tools.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import pytest + +from client_tools import resolve_tools + + +def _executable(path: Path, output: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"#!/bin/sh\nprintf '%s\\n' '{output}'\n") + path.chmod(0o755) + + +def test_resolve_tools_rejects_missing_aws_cli(tmp_path): + with pytest.raises(RuntimeError, match="aws CLI 2.37.6"): + resolve_tools(tmp_path) + + +def test_resolve_tools_rejects_an_unpinned_version(tmp_path): + _executable(tmp_path / "aws" / "aws", "aws-cli/2.36.43 Python/3.14") + _executable(tmp_path / "terraform" / "terraform", "Terraform v1.16.4") + + with pytest.raises(RuntimeError, match="expected.*2.37.6"): + resolve_tools(tmp_path) + + +def test_resolve_tools_returns_exact_pinned_binaries(tmp_path): + _executable(tmp_path / "aws" / "aws", "aws-cli/2.37.6 Python/3.14") + _executable(tmp_path / "terraform" / "terraform", "Terraform v1.16.4") + + tools = resolve_tools(tmp_path) + + assert tools.aws == tmp_path / "aws" / "aws" + assert tools.terraform == tmp_path / "terraform" / "terraform" + + +def test_installer_pins_the_contract_versions(): + installer = Path(__file__).parents[2] / "scripts" / "install-compat-tools.sh" + + content = installer.read_text() + + assert 'AWS_CLI_VERSION="2.37.6"' in content + assert 'TERRAFORM_VERSION="1.16.4"' in content + assert "awscli-exe-linux-x86_64-${AWS_CLI_VERSION}.zip.sig" in content + assert "terraform_${TERRAFORM_VERSION}_SHA256SUMS" in content diff --git a/test/compatibility/test_terraform.py b/test/compatibility/test_terraform.py new file mode 100644 index 0000000..431410d --- /dev/null +++ b/test/compatibility/test_terraform.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + + +def test_terraform_configuration_pins_aws_provider(): + content = (Path(__file__).parent / "terraform" / "versions.tf").read_text() + assert 'source = "hashicorp/aws"' in content + assert 'version = "6.66.0"' in content + + +def test_terraform_workspace_writes_local_endpoint(terraform_workspace): + workspace = terraform_workspace("s3") + + config = json.loads((workspace / "devcloud.auto.tfvars.json").read_text()) + assert config["devcloud_endpoint"].startswith("http://localhost:") + assert (workspace / "providers.tf").is_file()