Skip to content

Commit bd1956c

Browse files
authored
add initial version of wiremock LocalStack extension (#7)
1 parent a111f4e commit bd1956c

File tree

14 files changed

+576
-1
lines changed

14 files changed

+576
-1
lines changed

.github/workflows/test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ name: LocalStack TypeDB Extension Tests
22

33
on:
44
pull_request:
5+
branches:
6+
- main
7+
paths:
8+
- 'localstack-wiremock/**'
9+
push:
10+
branches:
11+
- main
12+
paths:
13+
- 'localstack-wiremock/**'
514
workflow_dispatch:
615

716
env:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ This repo contains miscellaneous utilities for LocalStack.
44

55
## License
66

7-
The code in this repo is available under the Apache 2.0 license.
7+
The code in this repo is available under the Apache 2.0 license. (unless stated otherwise)

localstack-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

localstack-wiremock/Makefile

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
bin/create-stubs.sh
43+
(cd sample-app; tflocal init; tflocal apply)
44+
45+
clean-dist: clean
46+
rm -rf dist/
47+
48+
.PHONY: clean clean-dist dist install publish usage venv format test

localstack-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-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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
3+
echo "Downloading WireMock stub definitions..."
4+
5+
# Define the URL for the stub definitions and the temporary file path
6+
STUBS_URL="https://library.wiremock.org/catalog/api/p/personio.de/personio-de-personnel/personio.de-personnel-stubs.json"
7+
TMP_STUBS_FILE="/tmp/personio-stubs.json"
8+
9+
# Define the WireMock server URL
10+
WIREMOCK_URL="http://localhost:8080"
11+
12+
# Download the stub definitions
13+
curl -s -o "$TMP_STUBS_FILE" "$STUBS_URL"
14+
15+
echo "Download complete. Stubs saved to $TMP_STUBS_FILE"
16+
echo "Importing stubs into WireMock..."
17+
18+
# Send a POST request to WireMock's import endpoint with the downloaded file
19+
curl -v -X POST -H "Content-Type: application/json" --data-binary "@$TMP_STUBS_FILE" "$WIREMOCK_URL/__admin/mappings/import"
20+
21+
echo ""
22+
echo "WireMock stub import request sent."
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
name = "localstack_wiremock"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from localstack_wiremock.utils.docker import ProxiedDockerContainerExtension
2+
3+
4+
class WireMockExtension(ProxiedDockerContainerExtension):
5+
name = "localstack-wiremock"
6+
7+
HOST = "wiremock.<domain>"
8+
# name of the Docker image to spin up
9+
DOCKER_IMAGE = "wiremock/wiremock"
10+
11+
def __init__(self):
12+
super().__init__(
13+
image_name=self.DOCKER_IMAGE,
14+
container_ports=[8080],
15+
host=self.HOST,
16+
)

localstack-wiremock/localstack_wiremock/utils/__init__.py

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

0 commit comments

Comments
 (0)