Skip to content

Commit 6fd20b2

Browse files
committed
Added observability tests.
1 parent 17e4dc9 commit 6fd20b2

File tree

13 files changed

+1221
-202
lines changed

13 files changed

+1221
-202
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ jobs:
114114
echo "=== Post-deployment validation ==="
115115
./scripts/test.sh check-deployment
116116
117+
- name: Wait for monitoring stack
118+
run: |
119+
echo "=== Waiting for monitoring components (required for autoscaling) ==="
120+
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/component=server,app.kubernetes.io/name=prometheus -n eoapi --timeout=120s &
121+
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=grafana -n eoapi --timeout=120s &
122+
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=prometheus-adapter -n eoapi --timeout=120s &
123+
wait # Wait for all background jobs
124+
echo "✅ Monitoring stack ready"
125+
kubectl get hpa -n eoapi
126+
117127
- name: Run integration tests
118128
run: |
119129
export RELEASE_NAME="$RELEASE_NAME"

.github/workflows/tests/conftest.py

Lines changed: 185 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import json
12
import os
2-
from typing import Any, Generator
3+
import subprocess
4+
import time
5+
from typing import Any, Dict, Generator, List, Optional, cast
36

47
import psycopg2
58
import psycopg2.extensions
69
import pytest
10+
import requests
711

812

913
@pytest.fixture(scope="session")
@@ -22,17 +26,22 @@ def stac_endpoint() -> str:
2226

2327

2428
@pytest.fixture(scope="session")
25-
def db_connection() -> Generator[Any, None, None]:
26-
"""Create database connection for testing."""
27-
# Require all database connection parameters to be explicitly set
29+
def db_connection() -> Generator[psycopg2.extensions.connection, None, None]:
2830
required_vars = ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"]
2931
missing_vars = [var for var in required_vars if not os.getenv(var)]
30-
3132
if missing_vars:
3233
pytest.fail(
3334
f"Required environment variables not set: {', '.join(missing_vars)}"
3435
)
3536

37+
connection_params = {
38+
"host": os.getenv("PGHOST"),
39+
"port": os.getenv("PGPORT"),
40+
"database": os.getenv("PGDATABASE"),
41+
"user": os.getenv("PGUSER"),
42+
"password": os.getenv("PGPASSWORD"),
43+
}
44+
3645
# All required vars are guaranteed to exist due to check above
3746
try:
3847
conn = psycopg2.connect(
@@ -47,3 +56,174 @@ def db_connection() -> Generator[Any, None, None]:
4756
conn.close()
4857
except psycopg2.Error as e:
4958
pytest.fail(f"Cannot connect to database: {e}")
59+
60+
61+
def get_namespace() -> str:
62+
"""Get the namespace from environment variable."""
63+
return os.environ.get("NAMESPACE", "eoapi")
64+
65+
66+
def get_release_name() -> str:
67+
"""Get the release name from environment variable."""
68+
return os.environ.get("RELEASE_NAME", "eoapi")
69+
70+
71+
def kubectl_get(
72+
resource: str,
73+
namespace: Optional[str] = None,
74+
label_selector: Optional[str] = None,
75+
output: str = "json",
76+
) -> subprocess.CompletedProcess[str]:
77+
cmd: List[str] = ["kubectl", "get", resource]
78+
79+
if namespace:
80+
cmd.extend(["-n", namespace])
81+
82+
if label_selector:
83+
cmd.extend(["-l", label_selector])
84+
85+
if output:
86+
cmd.extend(["-o", output])
87+
88+
result = subprocess.run(cmd, capture_output=True, text=True)
89+
return result
90+
91+
92+
def kubectl_port_forward(
93+
service: str, local_port: int, remote_port: int, namespace: str
94+
) -> subprocess.Popen[str]:
95+
cmd = [
96+
"kubectl",
97+
"port-forward",
98+
f"svc/{service}",
99+
f"{local_port}:{remote_port}",
100+
"-n",
101+
namespace,
102+
]
103+
104+
process = subprocess.Popen(
105+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
106+
)
107+
108+
time.sleep(3)
109+
return process
110+
111+
112+
def wait_for_url(url: str, timeout: int = 30, interval: int = 2) -> bool:
113+
start_time = time.time()
114+
while time.time() - start_time < timeout:
115+
try:
116+
response = requests.get(url, timeout=5)
117+
if response.status_code == 200:
118+
return True
119+
except (requests.RequestException, requests.ConnectionError):
120+
pass
121+
time.sleep(interval)
122+
return False
123+
124+
125+
def make_request(url: str, timeout: int = 10) -> bool:
126+
try:
127+
response = requests.get(url, timeout=timeout)
128+
return response.status_code == 200
129+
except requests.RequestException:
130+
return False
131+
132+
133+
def get_base_url() -> str:
134+
"""Get the base URL for API access."""
135+
namespace = get_namespace()
136+
137+
# Check if we have an ingress
138+
result = subprocess.run(
139+
["kubectl", "get", "ingress", "-n", namespace, "-o", "json"],
140+
capture_output=True,
141+
text=True,
142+
)
143+
144+
if result.returncode == 0:
145+
ingress_data = json.loads(result.stdout)
146+
if ingress_data["items"]:
147+
ingress = ingress_data["items"][0]
148+
rules = ingress.get("spec", {}).get("rules", [])
149+
if rules:
150+
host = rules[0].get("host", "localhost")
151+
# Check if host is accessible
152+
try:
153+
response = requests.get(
154+
f"http://{host}/stac/collections", timeout=5
155+
)
156+
if response.status_code == 200:
157+
return f"http://{host}"
158+
except requests.RequestException:
159+
pass
160+
161+
return "http://localhost:8080"
162+
163+
164+
def get_pod_metrics(namespace: str, service_name: str) -> List[Dict[str, str]]:
165+
"""Get CPU and memory metrics for pods of a specific service."""
166+
release_name_val = get_release_name()
167+
result = subprocess.run(
168+
[
169+
"kubectl",
170+
"top",
171+
"pods",
172+
"-n",
173+
namespace,
174+
"-l",
175+
f"app={release_name_val}-{service_name}",
176+
"--no-headers",
177+
],
178+
capture_output=True,
179+
text=True,
180+
)
181+
182+
if result.returncode != 0:
183+
return []
184+
185+
metrics: List[Dict[str, str]] = []
186+
for line in result.stdout.strip().split("\n"):
187+
if line.strip():
188+
parts = line.split()
189+
if len(parts) >= 3:
190+
pod_name = parts[0]
191+
cpu = parts[1] # e.g., "25m"
192+
memory = parts[2] # e.g., "128Mi"
193+
metrics.append({"pod": pod_name, "cpu": cpu, "memory": memory})
194+
195+
return metrics
196+
197+
198+
def get_hpa_status(namespace: str, hpa_name: str) -> Optional[Dict[str, Any]]:
199+
"""Get HPA status for a specific HPA."""
200+
result = kubectl_get("hpa", namespace=namespace, output="json")
201+
if result.returncode != 0:
202+
return None
203+
204+
hpas = json.loads(result.stdout)
205+
for hpa in hpas["items"]:
206+
if hpa["metadata"]["name"] == hpa_name:
207+
return cast(Dict[str, Any], hpa)
208+
209+
return None
210+
211+
212+
def get_pod_count(namespace: str, service_name: str) -> int:
213+
"""Get the count of running pods for a specific service."""
214+
release_name_val = get_release_name()
215+
result = kubectl_get(
216+
"pods",
217+
namespace=namespace,
218+
label_selector=f"app={release_name_val}-{service_name}",
219+
)
220+
221+
if result.returncode != 0:
222+
return 0
223+
224+
pods = json.loads(result.stdout)
225+
running_pods = [
226+
pod for pod in pods["items"] if pod["status"]["phase"] == "Running"
227+
]
228+
229+
return len(running_pods)

.github/workflows/tests/test_autoscaling.py

Lines changed: 11 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,24 @@
11
"""Test autoscaling behavior and HPA functionality."""
22

33
import json
4-
import os
54
import subprocess
65
import threading
76
import time
8-
from typing import Any, Dict, List, Optional, cast
7+
from typing import Any, Dict, List
98

109
import pytest
1110
import requests
1211

13-
14-
def get_namespace() -> str:
15-
return os.environ.get("NAMESPACE", "eoapi")
16-
17-
18-
def get_release_name() -> str:
19-
return os.environ.get("RELEASE_NAME", "eoapi")
20-
21-
22-
def get_base_url() -> str:
23-
namespace = get_namespace()
24-
25-
# Check if we have an ingress
26-
result = subprocess.run(
27-
["kubectl", "get", "ingress", "-n", namespace, "-o", "json"],
28-
capture_output=True,
29-
text=True,
30-
)
31-
32-
if result.returncode == 0:
33-
ingress_data = json.loads(result.stdout)
34-
if ingress_data["items"]:
35-
ingress = ingress_data["items"][0]
36-
rules = ingress.get("spec", {}).get("rules", [])
37-
if rules:
38-
host = rules[0].get("host", "localhost")
39-
# Check if host is accessible
40-
try:
41-
response = requests.get(
42-
f"http://{host}/stac/collections", timeout=5
43-
)
44-
if response.status_code == 200:
45-
return f"http://{host}"
46-
except requests.RequestException:
47-
pass
48-
49-
return "http://localhost:8080"
50-
51-
52-
def kubectl_get(
53-
resource: str,
54-
namespace: Optional[str] = None,
55-
label_selector: Optional[str] = None,
56-
output: str = "json",
57-
) -> subprocess.CompletedProcess[str]:
58-
cmd = ["kubectl", "get", resource]
59-
60-
if namespace:
61-
cmd.extend(["-n", namespace])
62-
63-
if label_selector:
64-
cmd.extend(["-l", label_selector])
65-
66-
if output:
67-
cmd.extend(["-o", output])
68-
69-
result = subprocess.run(cmd, capture_output=True, text=True)
70-
return result
71-
72-
73-
def get_pod_metrics(namespace: str, service_name: str) -> List[Dict[str, str]]:
74-
release_name = get_release_name()
75-
result = subprocess.run(
76-
[
77-
"kubectl",
78-
"top",
79-
"pods",
80-
"-n",
81-
namespace,
82-
"-l",
83-
f"app={release_name}-{service_name}",
84-
"--no-headers",
85-
],
86-
capture_output=True,
87-
text=True,
88-
)
89-
90-
if result.returncode != 0:
91-
return []
92-
93-
metrics: List[Dict[str, str]] = []
94-
for line in result.stdout.strip().split("\n"):
95-
if line.strip():
96-
parts = line.split()
97-
if len(parts) >= 3:
98-
pod_name = parts[0]
99-
cpu = parts[1] # e.g., "25m"
100-
memory = parts[2] # e.g., "128Mi"
101-
metrics.append({"pod": pod_name, "cpu": cpu, "memory": memory})
102-
103-
return metrics
104-
105-
106-
def get_hpa_status(namespace: str, hpa_name: str) -> Optional[Dict[str, Any]]:
107-
"""Get HPA status for a specific HPA."""
108-
result = kubectl_get("hpa", namespace=namespace, output="json")
109-
if result.returncode != 0:
110-
return None
111-
112-
hpas = json.loads(result.stdout)
113-
for hpa in hpas["items"]:
114-
if hpa["metadata"]["name"] == hpa_name:
115-
return cast(Dict[str, Any], hpa)
116-
117-
return None
118-
119-
120-
def get_pod_count(namespace: str, service_name: str) -> int:
121-
release_name = get_release_name()
122-
result = kubectl_get(
123-
"pods",
124-
namespace=namespace,
125-
label_selector=f"app={release_name}-{service_name}",
126-
)
127-
128-
if result.returncode != 0:
129-
return 0
130-
131-
pods = json.loads(result.stdout)
132-
running_pods = [
133-
pod for pod in pods["items"] if pod["status"]["phase"] == "Running"
134-
]
135-
136-
return len(running_pods)
137-
138-
139-
def make_request(url: str, timeout: int = 10) -> bool:
140-
"""Make a single HTTP request and return success status."""
141-
try:
142-
response = requests.get(url, timeout=timeout)
143-
return bool(response.status_code == 200)
144-
except requests.RequestException:
145-
return False
12+
# Import shared utilities from conftest
13+
from conftest import (
14+
get_base_url,
15+
get_namespace,
16+
get_pod_count,
17+
get_pod_metrics,
18+
get_release_name,
19+
kubectl_get,
20+
make_request,
21+
)
14622

14723

14824
def generate_load(

0 commit comments

Comments
 (0)