Skip to content

Commit 87cdd9b

Browse files
committed
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
1 parent f4b4626 commit 87cdd9b

File tree

12 files changed

+637
-0
lines changed

12 files changed

+637
-0
lines changed

.github/workflows/wiremock.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: LocalStack WireMock Extension Tests
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
paths:
8+
- 'wiremock/**'
9+
push:
10+
branches:
11+
- main
12+
paths:
13+
- 'wiremock/**'
14+
workflow_dispatch:
15+
16+
env:
17+
LOCALSTACK_DISABLE_EVENTS: "1"
18+
LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
19+
20+
jobs:
21+
integration-tests:
22+
name: Run WireMock Extension Tests
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 10
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v4
28+
29+
- name: Set up Terraform
30+
uses: hashicorp/setup-terraform@v3
31+
32+
- name: Set up LocalStack and extension
33+
run: |
34+
cd wiremock
35+
36+
docker pull localstack/localstack-pro &
37+
docker pull wiremock/wiremock &
38+
docker pull public.ecr.aws/lambda/python:3.9 &
39+
pip install localstack terraform-local awscli-local[ver1]
40+
41+
make install
42+
make dist
43+
localstack extensions -v install file://$(ls ./dist/localstack_wiremock-*.tar.gz)
44+
45+
DEBUG=1 localstack start -d
46+
localstack wait
47+
48+
- name: Run sample app test
49+
run: |
50+
cd wiremock
51+
make sample
52+
53+
- name: Print logs
54+
if: always()
55+
run: |
56+
localstack logs
57+
localstack stop

wiremock/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.venv
2+
dist
3+
build
4+
**/*.egg-info
5+
.eggs
6+
.terraform*
7+
terraform.tfstate*
8+
*.zip

wiremock/Makefile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
VENV_BIN = python3 -m venv
2+
VENV_DIR ?= .venv
3+
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
4+
VENV_RUN = . $(VENV_ACTIVATE)
5+
6+
usage: ## Shows usage for this Makefile
7+
@cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
8+
9+
venv: $(VENV_ACTIVATE)
10+
11+
$(VENV_ACTIVATE): pyproject.toml
12+
test -d .venv || $(VENV_BIN) .venv
13+
$(VENV_RUN); pip install --upgrade pip setuptools plux
14+
$(VENV_RUN); pip install -e .[dev]
15+
touch $(VENV_DIR)/bin/activate
16+
17+
clean:
18+
rm -rf .venv/
19+
rm -rf build/
20+
rm -rf .eggs/
21+
rm -rf *.egg-info/
22+
23+
install: venv ## Install dependencies
24+
$(VENV_RUN); python -m plux entrypoints
25+
26+
dist: venv ## Create distribution
27+
$(VENV_RUN); python -m build
28+
29+
publish: clean-dist venv dist ## Publish extension to pypi
30+
$(VENV_RUN); pip install --upgrade twine; twine upload dist/*
31+
32+
entrypoints: venv # Generate plugin entrypoints for Python package
33+
$(VENV_RUN); python -m plux entrypoints
34+
35+
format: ## Run ruff to format the whole codebase
36+
$(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix .
37+
38+
test: ## Run integration tests (requires LocalStack running with the Extension installed)
39+
$(VENV_RUN); pytest tests $(PYTEST_ARGS)
40+
41+
sample: ## Deploy sample app
42+
echo "Creating stubs in WireMock ..."
43+
bin/create-stubs.sh
44+
echo "Deploying sample app into LocalStack via Terraform ..."
45+
(cd sample-app; tflocal init; tflocal apply -auto-approve)
46+
apiId=$$(awslocal apigateway get-rest-apis | jq -r '.items[0].id'); \
47+
endpoint=https://$$apiId.execute-api.us-east-1.localhost.localstack.cloud/dev/time-off; \
48+
echo "Invoking local API Gateway endpoint: $$endpoint"; \
49+
curl -k -v $$endpoint | grep time_off_date
50+
51+
clean-dist: clean
52+
rm -rf dist/
53+
54+
.PHONY: clean clean-dist dist install publish usage venv format test

wiremock/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
WireMock on LocalStack
2+
========================
3+
4+
This repo contains a [LocalStack Extension](https://github.com/localstack/localstack-extensions) that facilitates developing [WireMock](https://wiremock.org)-based applications locally.
5+
6+
## Prerequisites
7+
8+
* Docker
9+
* LocalStack Pro (free trial available)
10+
* `localstack` CLI
11+
* `make`
12+
13+
## Install from GitHub repository
14+
15+
This extension can be installed directly from this Github repo via:
16+
17+
```bash
18+
localstack extensions install "git+https://github.com/whummer/localstack-utils.git#egg=localstack-wiremock&subdirectory=localstack-wiremock"
19+
```
20+
21+
## Install local development version
22+
23+
To install the extension into localstack in developer mode, you will need Python 3.11, and create a virtual environment in the extensions project.
24+
25+
In the newly generated project, simply run
26+
27+
```bash
28+
make install
29+
```
30+
31+
Then, to enable the extension for LocalStack, run
32+
33+
```bash
34+
localstack extensions dev enable .
35+
```
36+
37+
You can then start LocalStack with `EXTENSION_DEV_MODE=1` to load all enabled extensions:
38+
39+
```bash
40+
EXTENSION_DEV_MODE=1 localstack start
41+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
name = "localstack_wiremock"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import os
2+
3+
from localstack.utils.container_utils.container_client import Util
4+
from localstack_wiremock.utils.docker import ProxiedDockerContainerExtension
5+
6+
7+
# Environment variable for WireMock Cloud API token - note: if this value is specified, then the
8+
# `wiremock/wiremock-runner` image is being used, otherwise the `wiremock/wiremock` OSS image.
9+
ENV_WIREMOCK_API_TOKEN = "WIREMOCK_API_TOKEN"
10+
# container port for WireMock endpoint - TODO make configurable over time
11+
PORT = 8080
12+
13+
14+
class WireMockExtension(ProxiedDockerContainerExtension):
15+
name = "localstack-wiremock"
16+
17+
HOST = "wiremock.<domain>"
18+
# name of the OSS Docker image
19+
DOCKER_IMAGE = "wiremock/wiremock"
20+
# name of the WireMock Cloud runner Docker image
21+
DOCKER_IMAGE_RUNNER = "wiremock/wiremock-runner"
22+
# name of the container
23+
CONTAINER_NAME = "ls-wiremock"
24+
25+
def __init__(self):
26+
env_vars = {}
27+
image_name = self.DOCKER_IMAGE
28+
kwargs = {}
29+
if api_token := os.getenv(ENV_WIREMOCK_API_TOKEN):
30+
env_vars["WMC_ADMIN_PORT"] = str(PORT)
31+
# TODO remove?
32+
# env_vars["WMC_DEFAULT_MODE"] = "record-many"
33+
env_vars["WMC_API_TOKEN"] = api_token
34+
env_vars["WMC_RUNNER_ENABLED"] = "true"
35+
image_name = self.DOCKER_IMAGE_RUNNER
36+
settings_file = Util.mountable_tmp_file()
37+
# TODO: set configs in YAML file
38+
kwargs["volumes"] = ([(settings_file, "/work/.wiremock/wiremock.yaml")],)
39+
super().__init__(
40+
image_name=image_name,
41+
container_ports=[PORT],
42+
container_name=self.CONTAINER_NAME,
43+
host=self.HOST,
44+
**kwargs,
45+
)

wiremock/localstack_wiremock/utils/__init__.py

Whitespace-only changes.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import re
2+
import logging
3+
from functools import cache
4+
from typing import Callable
5+
import requests
6+
7+
from localstack.utils.docker_utils import DOCKER_CLIENT
8+
from localstack.extensions.api import Extension, http
9+
from localstack.http import Request
10+
from localstack.utils.container_utils.container_client import (
11+
PortMappings,
12+
SimpleVolumeBind,
13+
)
14+
from localstack.utils.net import get_addressable_container_host
15+
from localstack.utils.sync import retry
16+
from rolo import route
17+
from rolo.proxy import Proxy
18+
from rolo.routing import RuleAdapter, WithHost
19+
20+
LOG = logging.getLogger(__name__)
21+
logging.basicConfig()
22+
23+
# TODO: merge utils with code in TypeDB extension over time ...
24+
25+
26+
class ProxiedDockerContainerExtension(Extension):
27+
name: str
28+
"""Name of this extension"""
29+
image_name: str
30+
"""Docker image name"""
31+
container_name: str | None
32+
"""Name of the Docker container spun up by the extension"""
33+
container_ports: list[int]
34+
"""List of network ports of the Docker container spun up by the extension"""
35+
host: str | None
36+
"""
37+
Optional host on which to expose the container endpoints.
38+
Can be either a static hostname, or a pattern like `<regex("(.+\.)?"):subdomain>myext.<domain>`
39+
"""
40+
path: str | None
41+
"""Optional path on which to expose the container endpoints."""
42+
command: list[str] | None
43+
"""Optional command (and flags) to execute in the container."""
44+
45+
request_to_port_router: Callable[[Request], int] | None
46+
"""Callable that returns the target port for a given request, for routing purposes"""
47+
http2_ports: list[int] | None
48+
"""List of ports for which HTTP2 proxy forwarding into the container should be enabled."""
49+
50+
volumes: list[SimpleVolumeBind] | None = (None,)
51+
"""Optional volumes to mount into the container host."""
52+
53+
def __init__(
54+
self,
55+
image_name: str,
56+
container_ports: list[int],
57+
host: str | None = None,
58+
path: str | None = None,
59+
container_name: str | None = None,
60+
command: list[str] | None = None,
61+
request_to_port_router: Callable[[Request], int] | None = None,
62+
http2_ports: list[int] | None = None,
63+
volumes: list[SimpleVolumeBind] | None = None,
64+
):
65+
self.image_name = image_name
66+
self.container_ports = container_ports
67+
self.host = host
68+
self.path = path
69+
self.container_name = container_name
70+
self.command = command
71+
self.request_to_port_router = request_to_port_router
72+
self.http2_ports = http2_ports
73+
self.volumes = volumes
74+
75+
def update_gateway_routes(self, router: http.Router[http.RouteHandler]):
76+
if self.path:
77+
raise NotImplementedError(
78+
"Path-based routing not yet implemented for this extension"
79+
)
80+
self.start_container()
81+
# add resource for HTTP/1.1 requests
82+
resource = RuleAdapter(ProxyResource(self))
83+
if self.host:
84+
resource = WithHost(self.host, [resource])
85+
router.add(resource)
86+
87+
def on_platform_shutdown(self):
88+
self._remove_container()
89+
90+
def _get_container_name(self) -> str:
91+
if self.container_name:
92+
return self.container_name
93+
name = f"ls-ext-{self.name}"
94+
name = re.sub(r"\W", "-", name)
95+
return name
96+
97+
@cache
98+
def start_container(self) -> None:
99+
container_name = self._get_container_name()
100+
LOG.debug("Starting extension container %s", container_name)
101+
102+
ports = PortMappings()
103+
for port in self.container_ports:
104+
ports.add(port)
105+
106+
kwargs = {}
107+
if self.command:
108+
kwargs["command"] = self.command
109+
110+
try:
111+
DOCKER_CLIENT.run_container(
112+
self.image_name,
113+
detach=True,
114+
remove=True,
115+
name=container_name,
116+
ports=ports,
117+
volumes=self.volumes,
118+
**kwargs,
119+
)
120+
except Exception as e:
121+
LOG.debug("Failed to start container %s: %s", container_name, e)
122+
raise
123+
124+
main_port = self.container_ports[0]
125+
container_host = get_addressable_container_host()
126+
127+
def _ping_endpoint():
128+
# TODO: allow defining a custom healthcheck endpoint ...
129+
response = requests.get(
130+
f"http://{container_host}:{main_port}/__admin/health"
131+
)
132+
assert response.ok
133+
134+
try:
135+
retry(_ping_endpoint, retries=40, sleep=1)
136+
except Exception as e:
137+
LOG.info("Failed to connect to container %s: %s", container_name, e)
138+
self._remove_container()
139+
raise
140+
141+
LOG.debug("Successfully started extension container %s", container_name)
142+
143+
def _remove_container(self):
144+
container_name = self._get_container_name()
145+
LOG.debug("Stopping extension container %s", container_name)
146+
DOCKER_CLIENT.remove_container(
147+
container_name, force=True, check_existence=False
148+
)
149+
150+
151+
class ProxyResource:
152+
"""
153+
Simple proxy resource that forwards incoming requests from the
154+
LocalStack Gateway to the target Docker container.
155+
"""
156+
157+
extension: ProxiedDockerContainerExtension
158+
159+
def __init__(self, extension: ProxiedDockerContainerExtension):
160+
self.extension = extension
161+
162+
@route("/<path:path>")
163+
def index(self, request: Request, path: str, *args, **kwargs):
164+
return self._proxy_request(request, forward_path=f"/{path}")
165+
166+
def _proxy_request(self, request: Request, forward_path: str, *args, **kwargs):
167+
self.extension.start_container()
168+
169+
port = self.extension.container_ports[0]
170+
container_host = get_addressable_container_host()
171+
base_url = f"http://{container_host}:{port}"
172+
proxy = Proxy(forward_base_url=base_url)
173+
174+
# update content length (may have changed due to content compression)
175+
if request.method not in ("GET", "OPTIONS"):
176+
request.headers["Content-Length"] = str(len(request.data))
177+
178+
# make sure we're forwarding the correct Host header
179+
request.headers["Host"] = f"localhost:{port}"
180+
181+
# forward the request to the target
182+
result = proxy.forward(request, forward_path=forward_path)
183+
184+
return result

0 commit comments

Comments
 (0)