Skip to content

Commit 297f18d

Browse files
committed
feat: add test env provider for docker-compose
1 parent a5a7604 commit 297f18d

9 files changed

Lines changed: 577 additions & 267 deletions

File tree

.github/workflows/integration-tests-core.yml

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@ jobs:
5353
with:
5454
repository: 'firebolt-db/firebolt-python-sdk'
5555

56-
- name: Setup Firebolt Core
57-
id: setup-core
58-
uses: firebolt-db/action-setup-core@eabcd701de0be41793fda0655d29d46c70c847c2 # main
59-
with:
60-
tag_version: ${{ inputs.tag_version || vars.DEFAULT_CORE_IMAGE_TAG }}
61-
6256
- name: Set up Python
6357
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
6458
with:
@@ -73,27 +67,15 @@ jobs:
7367
env:
7468
SERVICE_ID: ${{ secrets.FIREBOLT_CLIENT_ID_STG_NEW_IDN }}
7569
SERVICE_SECRET: ${{ secrets.FIREBOLT_CLIENT_SECRET_STG_NEW_IDN }}
76-
DATABASE_NAME: "firebolt"
77-
ENGINE_NAME: ""
78-
STOPPED_ENGINE_NAME: ""
79-
API_ENDPOINT: ""
80-
ACCOUNT_NAME: ""
81-
CORE_URL: ${{ steps.setup-core.outputs.service_url }}
8270
run: |
8371
pytest -o log_cli=true -o log_cli_level=WARNING tests/integration -k "core" --run-compose --alluredir=allure-results/
8472
8573
- name: "Compose: Run integration tests HTTPS"
8674
env:
8775
SERVICE_ID: ${{ secrets.FIREBOLT_CLIENT_ID_STG_NEW_IDN }}
8876
SERVICE_SECRET: ${{ secrets.FIREBOLT_CLIENT_SECRET_STG_NEW_IDN }}
89-
DATABASE_NAME: "firebolt"
90-
ENGINE_NAME: ""
91-
STOPPED_ENGINE_NAME: ""
92-
API_ENDPOINT: ""
93-
ACCOUNT_NAME: ""
94-
CORE_URL: ${{ steps.setup-core.outputs.service_https_url }}
9577
run: |
96-
pytest -o log_cli=true -o log_cli_level=WARNING tests/integration -k "core" --run-compose --alluredir=allure-results-https/
78+
pytest -o log_cli=true -o log_cli_level=WARNING tests/integration -k "core" --run-https --run-compose --alluredir=allure-results-https/
9779
9880
- name: "Kind: Run integration tests HTTP"
9981
env:

.github/workflows/pull-request.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@ jobs:
1111
uses: ./.github/workflows/unit-tests.yml
1212
secrets:
1313
GIST_PAT: ${{ secrets.GIST_PAT }}
14-
14+
1515
security-scan:
1616
needs: [unit-tests]
1717
uses: ./.github/workflows/security-scan.yml
1818
secrets:
1919
FOSSA_TOKEN: ${{ secrets.FOSSA_TOKEN }}
2020
SONARCLOUD_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }}
21-

tests/integration/cluster/__init__.py

Whitespace-only changes.

tests/integration/cluster/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import socket
2+
from abc import ABC, abstractmethod
3+
4+
5+
class AppManager(ABC):
6+
@abstractmethod
7+
def deploy(self, params: dict = None) -> dict:
8+
"""Deploy the application environment."""
9+
10+
@abstractmethod
11+
def cleanup(self, setup_data: dict) -> None:
12+
"""Clean up the application environment."""
13+
14+
15+
def get_free_port():
16+
"""Ask the OS for a free ephemeral port."""
17+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18+
s.bind(("", 0))
19+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
20+
return s.getsockname()[1]
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import json
2+
import os
3+
import shutil
4+
import socket
5+
import subprocess
6+
import tempfile
7+
import uuid
8+
from os import getenv
9+
from time import sleep
10+
11+
import yaml
12+
13+
from tests.integration.cluster.base import AppManager, get_free_port
14+
15+
NGINX_CONFIG_TEMPLATE = """
16+
{upstream_block}
17+
server {{
18+
listen 443 ssl;
19+
server_name localhost 127.0.0.1;
20+
21+
ssl_certificate /etc/nginx/certs/server.pem;
22+
ssl_certificate_key /etc/nginx/certs/server.key;
23+
24+
location / {{
25+
proxy_pass http://{proxy_target};
26+
proxy_set_header Host $host;
27+
}}
28+
}}
29+
"""
30+
31+
32+
def generate_self_signed_cert(cert_path: str, key_path: str) -> None:
33+
"""Generate a self-signed certificate for localhost using openssl."""
34+
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
35+
subprocess.run(
36+
[
37+
"openssl",
38+
"req",
39+
"-x509",
40+
"-newkey",
41+
"rsa:4096",
42+
"-keyout",
43+
key_path,
44+
"-out",
45+
cert_path,
46+
"-days",
47+
"365",
48+
"-nodes",
49+
"-subj",
50+
"/CN=localhost",
51+
"-addext",
52+
"subjectAltName = DNS:localhost, IP:127.0.0.1",
53+
],
54+
check=True,
55+
capture_output=True,
56+
)
57+
58+
59+
class ComposeAppManager(AppManager):
60+
def deploy(self, params=None):
61+
return deploy_compose(params)
62+
63+
def cleanup(self, setup_data):
64+
cleanup_compose(setup_data)
65+
66+
67+
def deploy_compose(params=None):
68+
"""Deploy Firebolt Core using Docker Compose."""
69+
if params is None:
70+
params = {}
71+
72+
nodes_count = int(params.get("nodesCount", 1))
73+
image_tag = params.get("image.tag", getenv("CORE_IMAGE_TAG", "preview-rc"))
74+
75+
test_id = (
76+
f"{os.environ.get('PYTEST_XDIST_WORKER', 'python-sdk')}-{uuid.uuid4().hex[:4]}"
77+
)
78+
project_name = f"core-{test_id}"
79+
80+
tmp_dir = tempfile.mkdtemp(prefix=f"firebolt-compose-{test_id}")
81+
resources_dir = os.path.join(tmp_dir, "resources")
82+
certs_dir = os.path.join(resources_dir, "certs")
83+
os.makedirs(certs_dir, exist_ok=True)
84+
85+
# Generate certs
86+
server_cert_path = os.path.join(certs_dir, "server.pem")
87+
server_key_path = os.path.join(certs_dir, "server.key")
88+
generate_self_signed_cert(server_cert_path, server_key_path)
89+
90+
# Generate nodes
91+
node_names = [f"firebolt-core-{i}" for i in range(nodes_count)]
92+
93+
# Generate core config
94+
core_config = {"nodes": [{"host": name} for name in node_names]}
95+
with open(os.path.join(resources_dir, "config.json"), "w") as f:
96+
json.dump(core_config, f, indent=2)
97+
98+
# Generate nginx config
99+
if nodes_count > 1:
100+
upstream_servers = "".join([f"server {name}:3473; " for name in node_names])
101+
upstream_block = f"upstream firebolt {{ {upstream_servers}}}"
102+
proxy_target = "firebolt" # Upstream name, no port needed
103+
else:
104+
# Single node, no upstream block needed
105+
upstream_block = ""
106+
proxy_target = f"{node_names[0]}:3473"
107+
108+
nginx_config = NGINX_CONFIG_TEMPLATE.format(
109+
upstream_block=upstream_block, proxy_target=proxy_target
110+
)
111+
with open(os.path.join(resources_dir, "default.conf"), "w") as f:
112+
f.write(nginx_config)
113+
114+
# Generate docker-compose.yaml
115+
node_ports = []
116+
services = {}
117+
118+
for i, name in enumerate(node_names):
119+
node_port = get_free_port()
120+
node_ports.append(node_port)
121+
services[name] = {
122+
"image": f"ghcr.io/firebolt-db/firebolt-core:{image_tag}",
123+
"container_name": f"{project_name}-{name}",
124+
"command": f"--node {i}",
125+
"privileged": True,
126+
"restart": "no",
127+
"ulimits": {"memlock": 8589934592},
128+
"ports": [f"{node_port}:3473"],
129+
"volumes": [
130+
"./resources/config.json:/firebolt-core/config.json:ro",
131+
f"./{name}:/firebolt-core/data",
132+
],
133+
}
134+
# Create data dir
135+
os.makedirs(os.path.join(tmp_dir, name), exist_ok=True)
136+
137+
# Create one nginx instance per core node, all load balancing
138+
nginx_ports = []
139+
for i in range(nodes_count):
140+
nginx_port = get_free_port()
141+
nginx_ports.append(nginx_port)
142+
services[f"nginx-{i}"] = {
143+
"image": "nginx:alpine",
144+
"container_name": f"{project_name}-nginx-{i}",
145+
"ports": [f"{nginx_port}:443"],
146+
"volumes": [
147+
"./resources/certs:/etc/nginx/certs:ro",
148+
"./resources/default.conf:/etc/nginx/conf.d/default.conf:ro",
149+
],
150+
"depends_on": node_names,
151+
}
152+
153+
compose_data = {"services": services}
154+
with open(os.path.join(tmp_dir, "docker-compose.yaml"), "w") as f:
155+
yaml.dump(compose_data, f, default_flow_style=False)
156+
157+
print(f"[Compose] Starting project {project_name} in {tmp_dir}...")
158+
subprocess.run(
159+
["docker", "compose", "-p", project_name, "up", "-d"],
160+
cwd=tmp_dir,
161+
check=True,
162+
capture_output=True,
163+
)
164+
165+
# Wait for core to be healthy
166+
print(f"[Compose] Waiting for cluster {project_name} to be healthy...")
167+
for i in range(30):
168+
try:
169+
# Try to connect to the core port directly
170+
with socket.create_connection(("127.0.0.1", node_ports[0]), timeout=1):
171+
res = subprocess.run(
172+
[
173+
"curl",
174+
"-s",
175+
"-o",
176+
"/dev/null",
177+
"-w",
178+
"%{http_code}",
179+
f"http://127.0.0.1:{node_ports[0]}",
180+
],
181+
capture_output=True,
182+
text=True,
183+
)
184+
if res.stdout.strip() == "200":
185+
print(f"[Compose] Cluster is healthy at 127.0.0.1:{node_ports[0]}")
186+
break
187+
except (socket.error, ConnectionRefusedError):
188+
pass
189+
190+
if i == 29:
191+
raise RuntimeError(
192+
f"Cluster {project_name} failed to become healthy at {node_ports[0]}"
193+
)
194+
sleep(2)
195+
196+
ips = [f"127.0.0.1:{port}" for port in node_ports]
197+
url = f"http://127.0.0.1:{node_ports[0]}"
198+
199+
return {
200+
"url": url,
201+
"project_name": project_name,
202+
"tmp_dir": tmp_dir,
203+
"ips": ips,
204+
"nginx_ports": nginx_ports, # list of ports
205+
"server_cert_path": server_cert_path,
206+
}
207+
208+
209+
def cleanup_compose(setup_data):
210+
"""Stop and clean up Docker Compose project."""
211+
print(f"[Compose] Stopping project {setup_data['project_name']}...")
212+
subprocess.run(
213+
["docker", "compose", "-p", setup_data["project_name"], "down", "-v"],
214+
cwd=setup_data["tmp_dir"],
215+
check=True,
216+
capture_output=True,
217+
)
218+
shutil.rmtree(setup_data["tmp_dir"])

0 commit comments

Comments
 (0)