From de4eb5d17f69b73b864b7c4a3e22273740734e7c Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Tue, 13 Jan 2026 22:09:13 +0530 Subject: [PATCH] add wiremock extension * Add LocalStack WireMock extension that runs/proxies a WireMock container * Add sample app for integration testing * Add GitHub Actions workflow for extension tests --- .github/workflows/wiremock.yml | 57 ++++++ wiremock/.gitignore | 8 + wiremock/Makefile | 54 +++++ wiremock/README.md | 41 ++++ wiremock/bin/create-stubs.sh | 22 +++ wiremock/localstack_wiremock/__init__.py | 1 + wiremock/localstack_wiremock/extension.py | 45 +++++ .../localstack_wiremock/utils/__init__.py | 0 wiremock/localstack_wiremock/utils/docker.py | 184 ++++++++++++++++++ wiremock/pyproject.toml | 36 ++++ wiremock/sample-app/main.tf | 152 +++++++++++++++ wiremock/sample-app/src/handler.py | 58 ++++++ wiremock/sample-app/src/requirements.txt | 1 + 13 files changed, 659 insertions(+) create mode 100644 .github/workflows/wiremock.yml create mode 100644 wiremock/.gitignore create mode 100644 wiremock/Makefile create mode 100644 wiremock/README.md create mode 100755 wiremock/bin/create-stubs.sh create mode 100644 wiremock/localstack_wiremock/__init__.py create mode 100644 wiremock/localstack_wiremock/extension.py create mode 100644 wiremock/localstack_wiremock/utils/__init__.py create mode 100644 wiremock/localstack_wiremock/utils/docker.py create mode 100644 wiremock/pyproject.toml create mode 100644 wiremock/sample-app/main.tf create mode 100644 wiremock/sample-app/src/handler.py create mode 100644 wiremock/sample-app/src/requirements.txt diff --git a/.github/workflows/wiremock.yml b/.github/workflows/wiremock.yml new file mode 100644 index 0000000..5758fa3 --- /dev/null +++ b/.github/workflows/wiremock.yml @@ -0,0 +1,57 @@ +name: LocalStack WireMock Extension Tests + +on: + pull_request: + branches: + - main + paths: + - 'wiremock/**' + push: + branches: + - main + paths: + - 'wiremock/**' + workflow_dispatch: + +env: + LOCALSTACK_DISABLE_EVENTS: "1" + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + +jobs: + integration-tests: + name: Run WireMock Extension Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Set up LocalStack and extension + run: | + cd wiremock + + docker pull localstack/localstack-pro & + docker pull wiremock/wiremock & + docker pull public.ecr.aws/lambda/python:3.9 & + pip install localstack terraform-local awscli-local[ver1] + + make install + make dist + localstack extensions -v install file://$(ls ./dist/localstack_wiremock-*.tar.gz) + + DEBUG=1 localstack start -d + localstack wait + + - name: Run sample app test + run: | + cd wiremock + make sample + + - name: Print logs + if: always() + run: | + localstack logs + localstack stop diff --git a/wiremock/.gitignore b/wiremock/.gitignore new file mode 100644 index 0000000..2f3380d --- /dev/null +++ b/wiremock/.gitignore @@ -0,0 +1,8 @@ +.venv +dist +build +**/*.egg-info +.eggs +.terraform* +terraform.tfstate* +*.zip diff --git a/wiremock/Makefile b/wiremock/Makefile new file mode 100644 index 0000000..28a654f --- /dev/null +++ b/wiremock/Makefile @@ -0,0 +1,54 @@ +VENV_BIN = python3 -m venv +VENV_DIR ?= .venv +VENV_ACTIVATE = $(VENV_DIR)/bin/activate +VENV_RUN = . $(VENV_ACTIVATE) + +usage: ## Shows usage for this Makefile + @cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +venv: $(VENV_ACTIVATE) + +$(VENV_ACTIVATE): pyproject.toml + test -d .venv || $(VENV_BIN) .venv + $(VENV_RUN); pip install --upgrade pip setuptools plux + $(VENV_RUN); pip install -e .[dev] + touch $(VENV_DIR)/bin/activate + +clean: + rm -rf .venv/ + rm -rf build/ + rm -rf .eggs/ + rm -rf *.egg-info/ + +install: venv ## Install dependencies + $(VENV_RUN); python -m plux entrypoints + +dist: venv ## Create distribution + $(VENV_RUN); python -m build + +publish: clean-dist venv dist ## Publish extension to pypi + $(VENV_RUN); pip install --upgrade twine; twine upload dist/* + +entrypoints: venv # Generate plugin entrypoints for Python package + $(VENV_RUN); python -m plux entrypoints + +format: ## Run ruff to format the whole codebase + $(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix . + +test: ## Run integration tests (requires LocalStack running with the Extension installed) + $(VENV_RUN); pytest tests $(PYTEST_ARGS) + +sample: ## Deploy sample app + echo "Creating stubs in WireMock ..." + bin/create-stubs.sh + echo "Deploying sample app into LocalStack via Terraform ..." + (cd sample-app; tflocal init; tflocal apply -auto-approve) + apiId=$$(awslocal apigateway get-rest-apis | jq -r '.items[0].id'); \ + endpoint=https://$$apiId.execute-api.us-east-1.localhost.localstack.cloud/dev/time-off; \ + echo "Invoking local API Gateway endpoint: $$endpoint"; \ + curl -k -v $$endpoint | grep time_off_date + +clean-dist: clean + rm -rf dist/ + +.PHONY: clean clean-dist dist install publish usage venv format test diff --git a/wiremock/README.md b/wiremock/README.md new file mode 100644 index 0000000..f00b1fa --- /dev/null +++ b/wiremock/README.md @@ -0,0 +1,41 @@ +WireMock on LocalStack +======================== + +This repo contains a [LocalStack Extension](https://github.com/localstack/localstack-extensions) that facilitates developing [WireMock](https://wiremock.org)-based applications locally. + +## Prerequisites + +* Docker +* LocalStack Pro (free trial available) +* `localstack` CLI +* `make` + +## Install from GitHub repository + +This extension can be installed directly from this Github repo via: + +```bash +localstack extensions install "git+https://github.com/whummer/localstack-utils.git#egg=localstack-wiremock&subdirectory=localstack-wiremock" +``` + +## Install local development version + +To install the extension into localstack in developer mode, you will need Python 3.11, and create a virtual environment in the extensions project. + +In the newly generated project, simply run + +```bash +make install +``` + +Then, to enable the extension for LocalStack, run + +```bash +localstack extensions dev enable . +``` + +You can then start LocalStack with `EXTENSION_DEV_MODE=1` to load all enabled extensions: + +```bash +EXTENSION_DEV_MODE=1 localstack start +``` diff --git a/wiremock/bin/create-stubs.sh b/wiremock/bin/create-stubs.sh new file mode 100755 index 0000000..af12287 --- /dev/null +++ b/wiremock/bin/create-stubs.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +echo "Downloading WireMock stub definitions..." + +# Define the URL for the stub definitions and the temporary file path +STUBS_URL="https://library.wiremock.org/catalog/api/p/personio.de/personio-de-personnel/personio.de-personnel-stubs.json" +TMP_STUBS_FILE="/tmp/personio-stubs.json" + +# Define the WireMock server URL +WIREMOCK_URL="http://localhost:8080" + +# Download the stub definitions +curl -s -o "$TMP_STUBS_FILE" "$STUBS_URL" + +echo "Download complete. Stubs saved to $TMP_STUBS_FILE" +echo "Importing stubs into WireMock..." + +# Send a POST request to WireMock's import endpoint with the downloaded file +curl -v -X POST -H "Content-Type: application/json" --data-binary "@$TMP_STUBS_FILE" "$WIREMOCK_URL/__admin/mappings/import" + +echo "" +echo "WireMock stub import request sent." diff --git a/wiremock/localstack_wiremock/__init__.py b/wiremock/localstack_wiremock/__init__.py new file mode 100644 index 0000000..a802809 --- /dev/null +++ b/wiremock/localstack_wiremock/__init__.py @@ -0,0 +1 @@ +name = "localstack_wiremock" diff --git a/wiremock/localstack_wiremock/extension.py b/wiremock/localstack_wiremock/extension.py new file mode 100644 index 0000000..7061ac1 --- /dev/null +++ b/wiremock/localstack_wiremock/extension.py @@ -0,0 +1,45 @@ +import os + +from localstack.utils.container_utils.container_client import Util +from localstack_wiremock.utils.docker import ProxiedDockerContainerExtension + + +# Environment variable for WireMock Cloud API token - note: if this value is specified, then the +# `wiremock/wiremock-runner` image is being used, otherwise the `wiremock/wiremock` OSS image. +ENV_WIREMOCK_API_TOKEN = "WIREMOCK_API_TOKEN" +# container port for WireMock endpoint - TODO make configurable over time +PORT = 8080 + + +class WireMockExtension(ProxiedDockerContainerExtension): + name = "localstack-wiremock" + + HOST = "wiremock." + # name of the OSS Docker image + DOCKER_IMAGE = "wiremock/wiremock" + # name of the WireMock Cloud runner Docker image + DOCKER_IMAGE_RUNNER = "wiremock/wiremock-runner" + # name of the container + CONTAINER_NAME = "ls-wiremock" + + def __init__(self): + env_vars = {} + image_name = self.DOCKER_IMAGE + kwargs = {} + if api_token := os.getenv(ENV_WIREMOCK_API_TOKEN): + env_vars["WMC_ADMIN_PORT"] = str(PORT) + # TODO remove? + # env_vars["WMC_DEFAULT_MODE"] = "record-many" + env_vars["WMC_API_TOKEN"] = api_token + env_vars["WMC_RUNNER_ENABLED"] = "true" + image_name = self.DOCKER_IMAGE_RUNNER + settings_file = Util.mountable_tmp_file() + # TODO: set configs in YAML file + kwargs["volumes"] = ([(settings_file, "/work/.wiremock/wiremock.yaml")],) + super().__init__( + image_name=image_name, + container_ports=[PORT], + container_name=self.CONTAINER_NAME, + host=self.HOST, + **kwargs, + ) diff --git a/wiremock/localstack_wiremock/utils/__init__.py b/wiremock/localstack_wiremock/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiremock/localstack_wiremock/utils/docker.py b/wiremock/localstack_wiremock/utils/docker.py new file mode 100644 index 0000000..7a6fcb4 --- /dev/null +++ b/wiremock/localstack_wiremock/utils/docker.py @@ -0,0 +1,184 @@ +import re +import logging +from functools import cache +from typing import Callable +import requests + +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.extensions.api import Extension, http +from localstack.http import Request +from localstack.utils.container_utils.container_client import ( + PortMappings, + SimpleVolumeBind, +) +from localstack.utils.net import get_addressable_container_host +from localstack.utils.sync import retry +from rolo import route +from rolo.proxy import Proxy +from rolo.routing import RuleAdapter, WithHost + +LOG = logging.getLogger(__name__) +logging.basicConfig() + +# TODO: merge utils with code in TypeDB extension over time ... + + +class ProxiedDockerContainerExtension(Extension): + name: str + """Name of this extension""" + image_name: str + """Docker image name""" + container_name: str | None + """Name of the Docker container spun up by the extension""" + container_ports: list[int] + """List of network ports of the Docker container spun up by the extension""" + host: str | None + """ + Optional host on which to expose the container endpoints. + Can be either a static hostname, or a pattern like `myext.` + """ + path: str | None + """Optional path on which to expose the container endpoints.""" + command: list[str] | None + """Optional command (and flags) to execute in the container.""" + + request_to_port_router: Callable[[Request], int] | None + """Callable that returns the target port for a given request, for routing purposes""" + http2_ports: list[int] | None + """List of ports for which HTTP2 proxy forwarding into the container should be enabled.""" + + volumes: list[SimpleVolumeBind] | None = (None,) + """Optional volumes to mount into the container host.""" + + def __init__( + self, + image_name: str, + container_ports: list[int], + host: str | None = None, + path: str | None = None, + container_name: str | None = None, + command: list[str] | None = None, + request_to_port_router: Callable[[Request], int] | None = None, + http2_ports: list[int] | None = None, + volumes: list[SimpleVolumeBind] | None = None, + ): + self.image_name = image_name + self.container_ports = container_ports + self.host = host + self.path = path + self.container_name = container_name + self.command = command + self.request_to_port_router = request_to_port_router + self.http2_ports = http2_ports + self.volumes = volumes + + def update_gateway_routes(self, router: http.Router[http.RouteHandler]): + if self.path: + raise NotImplementedError( + "Path-based routing not yet implemented for this extension" + ) + self.start_container() + # add resource for HTTP/1.1 requests + resource = RuleAdapter(ProxyResource(self)) + if self.host: + resource = WithHost(self.host, [resource]) + router.add(resource) + + def on_platform_shutdown(self): + self._remove_container() + + def _get_container_name(self) -> str: + if self.container_name: + return self.container_name + name = f"ls-ext-{self.name}" + name = re.sub(r"\W", "-", name) + return name + + @cache + def start_container(self) -> None: + container_name = self._get_container_name() + LOG.debug("Starting extension container %s", container_name) + + ports = PortMappings() + for port in self.container_ports: + ports.add(port) + + kwargs = {} + if self.command: + kwargs["command"] = self.command + + try: + DOCKER_CLIENT.run_container( + self.image_name, + detach=True, + remove=True, + name=container_name, + ports=ports, + volumes=self.volumes, + **kwargs, + ) + except Exception as e: + LOG.debug("Failed to start container %s: %s", container_name, e) + raise + + main_port = self.container_ports[0] + container_host = get_addressable_container_host() + + def _ping_endpoint(): + # TODO: allow defining a custom healthcheck endpoint ... + response = requests.get( + f"http://{container_host}:{main_port}/__admin/health" + ) + assert response.ok + + try: + retry(_ping_endpoint, retries=40, sleep=1) + except Exception as e: + LOG.info("Failed to connect to container %s: %s", container_name, e) + self._remove_container() + raise + + LOG.debug("Successfully started extension container %s", container_name) + + def _remove_container(self): + container_name = self._get_container_name() + LOG.debug("Stopping extension container %s", container_name) + DOCKER_CLIENT.remove_container( + container_name, force=True, check_existence=False + ) + + +class ProxyResource: + """ + Simple proxy resource that forwards incoming requests from the + LocalStack Gateway to the target Docker container. + """ + + extension: ProxiedDockerContainerExtension + + def __init__(self, extension: ProxiedDockerContainerExtension): + self.extension = extension + + @route("/") + def index(self, request: Request, path: str, *args, **kwargs): + return self._proxy_request(request, forward_path=f"/{path}") + + def _proxy_request(self, request: Request, forward_path: str, *args, **kwargs): + self.extension.start_container() + + port = self.extension.container_ports[0] + container_host = get_addressable_container_host() + base_url = f"http://{container_host}:{port}" + proxy = Proxy(forward_base_url=base_url) + + # update content length (may have changed due to content compression) + if request.method not in ("GET", "OPTIONS"): + request.headers["Content-Length"] = str(len(request.data)) + + # make sure we're forwarding the correct Host header + request.headers["Host"] = f"localhost:{port}" + + # forward the request to the target + result = proxy.forward(request, forward_path=forward_path) + + return result diff --git a/wiremock/pyproject.toml b/wiremock/pyproject.toml new file mode 100644 index 0000000..2ee19c8 --- /dev/null +++ b/wiremock/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools", "wheel", "plux>=1.3.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "localstack-wiremock" +version = "0.1.0" +description = "WireMock Extension for LocalStack" +readme = {file = "README.md", content-type = "text/markdown; charset=UTF-8"} +requires-python = ">=3.9" +authors = [ + { name = "LocalStack + WireMock team"} +] +keywords = ["LocalStack", "WireMock"] +classifiers = [] +dependencies = [ + "priority" +] + +[project.urls] +Homepage = "https://github.com/localstack/localstack-extensions/tree/main/wiremock" + +[project.optional-dependencies] +dev = [ + "boto3", + "build", + "jsonpatch", + "localstack", + "pytest", + "rolo", + "ruff", + "twisted" +] + +[project.entry-points."localstack.extensions"] +localstack-wiremock = "localstack_wiremock.extension:WireMockExtension" diff --git a/wiremock/sample-app/main.tf b/wiremock/sample-app/main.tf new file mode 100644 index 0000000..c37dfbd --- /dev/null +++ b/wiremock/sample-app/main.tf @@ -0,0 +1,152 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +output "api_endpoint" { + description = "The invoke URL for the deployed API stage." + value = "${aws_api_gateway_stage.dev_stage.invoke_url}/${aws_api_gateway_resource.time_off_resource.path_part}" +} + +variable "aws_region" { + description = "The AWS region to deploy the resources in." + type = string + default = "us-east-1" +} + +# 1. Package the Lambda function code +resource "null_resource" "package_lambda" { + triggers = { + handler_hash = filebase64sha256("${path.module}/src/handler.py") + requirements_hash = filebase64sha256("${path.module}/src/requirements.txt") + } + + provisioner "local-exec" { + command = <<-EOT + rm -rf "${path.module}/build" + mkdir -p "${path.module}/build" + cp "${path.module}/src/handler.py" "${path.module}/build/" + pip install -r "${path.module}/src/requirements.txt" -t "${path.module}/build" + EOT + interpreter = ["/bin/bash", "-c"] + } +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/build" + output_path = "${path.module}/lambda_function.zip" + depends_on = [null_resource.package_lambda] +} + +# 2. Create an IAM role for the Lambda function +resource "aws_iam_role" "lambda_exec_role" { + name = "hr_info_lambda_exec_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +# 3. Attach the basic Lambda execution policy to the role +resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { + role = aws_iam_role.lambda_exec_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# 4. Create the Lambda function +resource "aws_lambda_function" "hr_info_lambda" { + function_name = "hr_info_lambda" + role = aws_iam_role.lambda_exec_role.arn + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + handler = "handler.get_time_off" + runtime = "python3.9" + + # Add a timeout for the function + timeout = 10 +} + +# 5. Create an API Gateway REST API +resource "aws_api_gateway_rest_api" "hr_api" { + name = "hr_info_api" + description = "API for retrieving company HR information" +} + +# 6. Create a resource in the API (e.g., /time-off) +resource "aws_api_gateway_resource" "time_off_resource" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + parent_id = aws_api_gateway_rest_api.hr_api.root_resource_id + path_part = "time-off" +} + +# 7. Create a GET method for the /time-off resource +resource "aws_api_gateway_method" "get_method" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + resource_id = aws_api_gateway_resource.time_off_resource.id + http_method = "GET" + authorization = "NONE" +} + +# 8. Integrate the GET method with the Lambda function +resource "aws_api_gateway_integration" "lambda_integration" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + resource_id = aws_api_gateway_resource.time_off_resource.id + http_method = aws_api_gateway_method.get_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.hr_info_lambda.invoke_arn +} + +# 9. Grant API Gateway permission to invoke the Lambda function +resource "aws_lambda_permission" "api_gateway_permission" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.hr_info_lambda.function_name + principal = "apigateway.amazonaws.com" + + source_arn = "${aws_api_gateway_rest_api.hr_api.execution_arn}/*/${aws_api_gateway_method.get_method.http_method}${aws_api_gateway_resource.time_off_resource.path}" +} + +# 10. Deploy the API +resource "aws_api_gateway_deployment" "api_deployment" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + + # Terraform needs a trigger to create a new deployment + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.time_off_resource.id, + aws_api_gateway_method.get_method.id, + aws_api_gateway_integration.lambda_integration.id, + ])) + } + + lifecycle { + create_before_destroy = true + } +} + +# 11. Create a stage for the deployment +resource "aws_api_gateway_stage" "dev_stage" { + deployment_id = aws_api_gateway_deployment.api_deployment.id + rest_api_id = aws_api_gateway_rest_api.hr_api.id + stage_name = "dev" +} diff --git a/wiremock/sample-app/src/handler.py b/wiremock/sample-app/src/handler.py new file mode 100644 index 0000000..fc31bb1 --- /dev/null +++ b/wiremock/sample-app/src/handler.py @@ -0,0 +1,58 @@ +import json +import requests + + +def get_time_off(event, context): + """ + Handles the API Gateway event, fetches data from the mock Personio API, + and returns a transformed JSON response. + """ + try: + # Define the mock API endpoint URL (hardcoding `wiremock.localhost.localstack.cloud` for + # local dev for now - could be injected via env variables in the future ...) + url = "http://wiremock.localhost.localstack.cloud:4566/company/time-offs/534813865" + + # Make a GET request to the mock API + response = requests.get(url, timeout=5) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + + data = response.json() + + # Safely extract data using .get() to avoid KeyErrors + employee_data = data.get("data", {}).get("employee", {}).get("attributes", {}) + time_off_attributes = data.get("data", {}).get("attributes", {}) + + # Transform the data into the desired response format + transformed_data = { + "employee_name": f"{employee_data.get('first_name', 'N/A')} {employee_data.get('last_name', 'N/A')}", + "time_off_date": f"{time_off_attributes.get('start_date', 'N/A')} to {time_off_attributes.get('end_date', 'N/A')}", + "approval_status": time_off_attributes.get("status", "N/A"), + } + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(transformed_data), + } + + except requests.exceptions.RequestException as e: + # Handle network-related errors (e.g., connection refused, timeout) + error_message = { + "message": "Could not connect to the downstream HR service.", + "error": str(e), + } + print("Error:", error_message) + return { + "statusCode": 503, # Service Unavailable + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(error_message), + } + except Exception as e: + # Handle other unexpected errors (e.g., JSON parsing issues, programming errors) + error_message = {"message": "An unexpected error occurred.", "error": str(e)} + print("Error:", error_message) + return { + "statusCode": 500, # Internal Server Error + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(error_message), + } diff --git a/wiremock/sample-app/src/requirements.txt b/wiremock/sample-app/src/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/wiremock/sample-app/src/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0