Skip to content

Commit f4be749

Browse files
committed
add initial version of TypeDB extension
1 parent a25f189 commit f4be749

File tree

10 files changed

+634
-0
lines changed

10 files changed

+634
-0
lines changed

typedb/.gitignore

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

typedb/Makefile

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 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+
clean-dist: clean
42+
rm -rf dist/
43+
44+
.PHONY: clean clean-dist dist install publish usage venv format test

typedb/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
TypeDB on LocalStack
2+
=====================
3+
4+
This repo contains a [LocalStack Extension](https://github.com/localstack/localstack-extensions) that facilitates developing [TypeDB](https://typedb.com)-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/localstack/localstack-extensions.git#egg=localstack-typedb&subdirectory=typedb"
19+
```
20+
21+
## Install local development version
22+
23+
To install the extension into LocalStack in developer mode, you will need Python 3.13, 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_typedb"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
import shlex
3+
4+
from localstack.config import is_env_not_false
5+
from localstack.utils.docker_utils import DOCKER_CLIENT
6+
from localstack_typedb.utils.docker import ProxiedDockerContainerExtension
7+
from rolo import Request
8+
9+
# environment variable for user-defined command args to pass to TypeDB
10+
ENV_CMD_FLAGS = "TYPEDB_FLAGS"
11+
# environment variable for flag to enable/disable HTTP2 proxy for gRPC traffic
12+
ENV_HTTP2_PROXY = "TYPEDB_HTTP2_PROXY"
13+
14+
15+
class TypeDbExtension(ProxiedDockerContainerExtension):
16+
name = "localstack-typedb"
17+
18+
HOST = "typedb.<domain>"
19+
# name of the Docker image to spin up
20+
DOCKER_IMAGE = "typedb/typedb"
21+
# default command args to pass to TypeDB
22+
DEFAULT_CMD_FLAGS = ["--diagnostics.reporting.metrics=false"]
23+
# default port for TypeDB HTTP2/gRPC endpoint
24+
TYPEDB_PORT = 1729
25+
26+
def __init__(self):
27+
command_flags = (os.environ.get(ENV_CMD_FLAGS) or "").strip()
28+
command_flags = self.DEFAULT_CMD_FLAGS + shlex.split(command_flags)
29+
command = self._get_image_command() + command_flags
30+
http2_ports = [self.TYPEDB_PORT] if is_env_not_false(ENV_HTTP2_PROXY) else []
31+
super().__init__(
32+
image_name=self.DOCKER_IMAGE,
33+
container_ports=[8000, 1729],
34+
host=self.HOST,
35+
request_to_port_router=self.request_to_port_router,
36+
command=command,
37+
http2_ports=http2_ports,
38+
)
39+
40+
def _get_image_command(self) -> list[str]:
41+
result = DOCKER_CLIENT.inspect_image(self.DOCKER_IMAGE)
42+
image_command = result["Config"]["Cmd"]
43+
return image_command
44+
45+
def request_to_port_router(self, request: Request) -> int:
46+
# TODO add REST API / gRPC routing based on request
47+
return 1729

typedb/localstack_typedb/utils/__init__.py

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

0 commit comments

Comments
 (0)