Skip to content

Commit 5009255

Browse files
committed
test(maas): add ephemeral API key cleanup tests
Signed-off-by: Swati Mukund Bagal <sbagal@redhat.com>
1 parent 866849e commit 5009255

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed

tests/model_serving/maas_billing/maas_subscription/conftest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010
from ocp_resources.maas_model_ref import MaaSModelRef
1111
from ocp_resources.maas_subscription import MaaSSubscription
1212
from ocp_resources.namespace import Namespace
13+
from ocp_resources.pod import Pod
1314
from ocp_resources.resource import ResourceEditor
1415
from ocp_resources.service_account import ServiceAccount
1516
from pytest_testconfig import config as py_config
1617

1718
from tests.model_serving.maas_billing.maas_subscription.utils import (
19+
assert_api_key_created_ok,
1820
create_and_yield_api_key_id,
1921
create_api_key,
2022
create_maas_subscription,
23+
get_maas_api_labels,
2124
patch_llmisvc_with_maas_router_and_tiers,
2225
resolve_api_key_username,
2326
revoke_api_key,
@@ -786,3 +789,48 @@ def short_expiration_api_key_id(
786789
key_name_prefix="e2e-exp-short",
787790
expires_in="1h",
788791
)
792+
793+
794+
@pytest.fixture()
795+
def maas_api_pod_name(
796+
admin_client: DynamicClient,
797+
) -> str:
798+
"""Return the name of the first running maas-api pod."""
799+
applications_namespace = py_config["applications_namespace"]
800+
label_selector = ",".join(f"{k}={v}" for k, v in get_maas_api_labels().items())
801+
pods = list(
802+
Pod.get(
803+
client=admin_client,
804+
namespace=applications_namespace,
805+
label_selector=label_selector,
806+
)
807+
)
808+
assert pods, f"No maas-api pods found in {applications_namespace}"
809+
return pods[0].name
810+
811+
812+
@pytest.fixture()
813+
def ephemeral_api_key(
814+
request_session_http: requests.Session,
815+
base_url: str,
816+
ocp_token_for_actor: str,
817+
) -> Generator[dict[str, Any], None, None]:
818+
"""Create an ephemeral API key and revoke it on teardown."""
819+
create_resp, create_body = create_api_key(
820+
base_url=base_url,
821+
ocp_user_token=ocp_token_for_actor,
822+
request_session_http=request_session_http,
823+
api_key_name=f"e2e-ephemeral-{generate_random_name()}",
824+
expires_in="1h",
825+
ephemeral=True,
826+
raise_on_error=False,
827+
)
828+
assert_api_key_created_ok(resp=create_resp, body=create_body, required_fields=("key", "id"))
829+
LOGGER.info(f"[ephemeral] Created ephemeral key: id={create_body['id']}, expiresAt={create_body.get('expiresAt')}")
830+
yield create_body
831+
revoke_api_key(
832+
request_session_http=request_session_http,
833+
base_url=base_url,
834+
key_id=create_body["id"],
835+
ocp_user_token=ocp_token_for_actor,
836+
)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
import portforward
6+
import pytest
7+
import requests
8+
import structlog
9+
from kubernetes.dynamic import DynamicClient
10+
from ocp_resources.cron_job import CronJob
11+
from ocp_resources.network_policy import NetworkPolicy
12+
from pytest_testconfig import config as py_config
13+
14+
from tests.model_serving.maas_billing.utils import build_maas_headers
15+
16+
LOGGER = structlog.get_logger(name=__name__)
17+
18+
MAAS_CLEANUP_CRONJOB_NAME = "maas-api-key-cleanup"
19+
MAAS_CLEANUP_NETWORKPOLICY_NAME = "maas-api-cleanup-restrict"
20+
21+
22+
@pytest.mark.usefixtures(
23+
"maas_subscription_controller_enabled_latest",
24+
"maas_gateway_api",
25+
"maas_api_gateway_reachable",
26+
)
27+
class TestEphemeralKeyCleanup:
28+
"""Tests for ephemeral API key cleanup (CronJob + internal endpoint)."""
29+
30+
@pytest.mark.tier1
31+
def test_cronjob_exists_and_configured(self, admin_client: DynamicClient) -> None:
32+
"""Verify the maas-api-key-cleanup CronJob exists with expected configuration."""
33+
applications_namespace = py_config["applications_namespace"]
34+
35+
cronjob = CronJob(
36+
client=admin_client,
37+
name=MAAS_CLEANUP_CRONJOB_NAME,
38+
namespace=applications_namespace,
39+
)
40+
assert cronjob.exists, (
41+
f"CronJob {MAAS_CLEANUP_CRONJOB_NAME} not found in {applications_namespace}"
42+
)
43+
44+
spec = cronjob.instance.spec
45+
46+
assert spec.schedule == "*/15 * * * *", (
47+
f"Expected schedule '*/15 * * * *', got '{spec.schedule}'"
48+
)
49+
assert spec.concurrencyPolicy == "Forbid", (
50+
"CronJob should use Forbid concurrency policy to prevent overlapping runs"
51+
)
52+
53+
containers = spec.jobTemplate.spec.template.spec.containers
54+
assert len(containers) >= 1, "CronJob should have at least one container"
55+
container_spec = containers[0]
56+
cmd_str = " ".join(container_spec.command or [])
57+
assert "/internal/v1/api-keys/cleanup" in cmd_str, (
58+
f"CronJob command should target the internal cleanup endpoint, got: {cmd_str}"
59+
)
60+
61+
sec_ctx = getattr(container_spec, "securityContext", None)
62+
assert sec_ctx is not None, "Cleanup container should have securityContext configured"
63+
assert sec_ctx.runAsNonRoot is True, "Cleanup container should run as non-root"
64+
assert sec_ctx.readOnlyRootFilesystem is True, (
65+
"Cleanup container should have read-only root filesystem"
66+
)
67+
68+
LOGGER.info(
69+
f"[ephemeral] CronJob validated: schedule={spec.schedule}, concurrency={spec.concurrencyPolicy}"
70+
)
71+
72+
@pytest.mark.tier1
73+
def test_cleanup_networkpolicy_exists(self, admin_client: DynamicClient) -> None:
74+
"""Verify the cleanup NetworkPolicy restricts cleanup pod egress to maas-api only."""
75+
applications_namespace = py_config["applications_namespace"]
76+
77+
network_policy = NetworkPolicy(
78+
client=admin_client,
79+
name=MAAS_CLEANUP_NETWORKPOLICY_NAME,
80+
namespace=applications_namespace,
81+
)
82+
assert network_policy.exists, (
83+
f"NetworkPolicy {MAAS_CLEANUP_NETWORKPOLICY_NAME} not found in {applications_namespace}"
84+
)
85+
86+
spec = network_policy.instance.spec
87+
88+
assert spec.podSelector.matchLabels.get("app") == "maas-api-cleanup", (
89+
f"NetworkPolicy should target app=maas-api-cleanup pods, got: {spec.podSelector.matchLabels}"
90+
)
91+
assert "Egress" in spec.policyTypes, "NetworkPolicy should control Egress traffic"
92+
assert "Ingress" in spec.policyTypes, "NetworkPolicy should control Ingress traffic"
93+
94+
ingress_rules = getattr(spec, "ingress", None)
95+
assert ingress_rules in ([], None), "Cleanup pods should have no inbound traffic allowed"
96+
97+
LOGGER.info("[ephemeral] NetworkPolicy validated: cleanup pods restricted to maas-api egress only")
98+
99+
@pytest.mark.tier1
100+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
101+
def test_create_ephemeral_key(
102+
self,
103+
request_session_http: requests.Session,
104+
base_url: str,
105+
ocp_token_for_actor: str,
106+
ephemeral_api_key: dict[str, Any],
107+
) -> None:
108+
"""Verify ephemeral keys are visible with includeEphemeral filter but hidden by default."""
109+
key_id = ephemeral_api_key["id"]
110+
api_keys_endpoint = f"{base_url}/v1/api-keys"
111+
auth_header = build_maas_headers(token=ocp_token_for_actor)
112+
113+
assert ephemeral_api_key.get("ephemeral") is True, "Key should be marked as ephemeral"
114+
115+
r_search = request_session_http.post(
116+
url=f"{api_keys_endpoint}/search",
117+
headers=auth_header,
118+
json={
119+
"filters": {"status": ["active"], "includeEphemeral": True},
120+
"pagination": {"limit": 50, "offset": 0},
121+
},
122+
timeout=30,
123+
)
124+
assert r_search.status_code == 200, (
125+
f"Expected 200 from search with includeEphemeral=True, got {r_search.status_code}: {(r_search.text or '')[:200]}"
126+
)
127+
search_body = r_search.json()
128+
items: list[dict[str, Any]] = search_body.get("items") or search_body.get("data") or []
129+
assert key_id in [item["id"] for item in items], (
130+
f"Ephemeral key {key_id} should appear in search with includeEphemeral=True"
131+
)
132+
133+
r_default = request_session_http.post(
134+
url=f"{api_keys_endpoint}/search",
135+
headers=auth_header,
136+
json={
137+
"filters": {"status": ["active"]},
138+
"pagination": {"limit": 50, "offset": 0},
139+
},
140+
timeout=30,
141+
)
142+
assert r_default.status_code == 200, (
143+
f"Expected 200 from default search, got {r_default.status_code}: {(r_default.text or '')[:200]}"
144+
)
145+
default_body = r_default.json()
146+
default_items: list[dict[str, Any]] = (
147+
default_body.get("items") or default_body.get("data") or []
148+
)
149+
assert key_id not in [item["id"] for item in default_items], (
150+
"Ephemeral key should be excluded from default search (includeEphemeral defaults to False)"
151+
)
152+
LOGGER.info(f"[ephemeral] Ephemeral key {key_id} visibility verified")
153+
154+
@pytest.mark.tier1
155+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
156+
def test_trigger_cleanup_preserves_active_keys(
157+
self,
158+
request_session_http: requests.Session,
159+
base_url: str,
160+
ocp_token_for_actor: str,
161+
ephemeral_api_key: dict[str, Any],
162+
maas_api_pod_name: str,
163+
) -> None:
164+
"""Verify the cleanup endpoint does not delete active (non-expired) ephemeral keys."""
165+
applications_namespace = py_config["applications_namespace"]
166+
key_id = ephemeral_api_key["id"]
167+
api_keys_endpoint = f"{base_url}/v1/api-keys"
168+
auth_header = build_maas_headers(token=ocp_token_for_actor)
169+
170+
LOGGER.info(f"[ephemeral] Triggering cleanup via port-forward into pod={maas_api_pod_name}")
171+
172+
with portforward.forward(
173+
pod_or_service=maas_api_pod_name,
174+
namespace=applications_namespace,
175+
from_port=8080,
176+
to_port=8080,
177+
waiting=20,
178+
):
179+
cleanup_response = requests.post(
180+
url="http://localhost:8080/internal/v1/api-keys/cleanup",
181+
timeout=30,
182+
)
183+
184+
assert cleanup_response.status_code == 200, (
185+
f"Cleanup endpoint returned unexpected status: {cleanup_response.status_code}: "
186+
f"{(cleanup_response.text or '')[:200]}"
187+
)
188+
cleanup_resp = cleanup_response.json()
189+
deleted_count = cleanup_resp.get("deletedCount", -1)
190+
assert deleted_count >= 0, (
191+
f"Cleanup response should have non-negative deletedCount, got: {cleanup_resp}"
192+
)
193+
LOGGER.info(f"[ephemeral] Cleanup completed: deletedCount={deleted_count}")
194+
195+
r_get = request_session_http.get(
196+
url=f"{api_keys_endpoint}/{key_id}",
197+
headers=auth_header,
198+
timeout=30,
199+
)
200+
assert r_get.status_code == 200, (
201+
f"Active ephemeral key {key_id} should survive cleanup, got {r_get.status_code}: {(r_get.text or '')[:200]}"
202+
)
203+
get_body = r_get.json()
204+
assert get_body.get("status") == "active", (
205+
f"Key should still be active after cleanup, got: {get_body.get('status')}"
206+
)
207+
LOGGER.info(f"[ephemeral] Active key {key_id} survived cleanup correctly")

tests/model_serving/maas_billing/maas_subscription/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ def create_api_key(
188188
expires_in: str | None = None,
189189
raise_on_error: bool = True,
190190
subscription: str | None = None,
191+
ephemeral: bool = False,
191192
) -> tuple[Response, dict[str, Any]]:
192193
"""
193194
Create an API key via MaaS API and return (response, parsed_body).
@@ -203,6 +204,9 @@ def create_api_key(
203204
subscription: Optional MaaSSubscription name to bind at mint time.
204205
When provided, the key is bound to this subscription for inference.
205206
When None, the API auto-selects the highest-priority subscription.
207+
ephemeral: When True, marks the key as short-lived/programmatic.
208+
Ephemeral keys are hidden from default search results and are
209+
cleaned up automatically by the cleanup CronJob after expiration.
206210
"""
207211
api_keys_url = f"{base_url}/v1/api-keys"
208212

@@ -211,6 +215,8 @@ def create_api_key(
211215
payload["expiresIn"] = expires_in
212216
if subscription is not None:
213217
payload["subscription"] = subscription
218+
if ephemeral:
219+
payload["ephemeral"] = True
214220

215221
response = request_session_http.post(
216222
url=api_keys_url,
@@ -473,6 +479,29 @@ def assert_api_key_get_ok(resp: Response, body: dict[str, Any], key_id: str) ->
473479
)
474480

475481

482+
def assert_subscription_info_schema(sub: dict[str, Any]) -> None:
483+
"""Assert a SubscriptionInfo object has the expected structure and field types."""
484+
assert "subscription_id_header" in sub, f"Missing subscription_id_header: {sub}"
485+
assert isinstance(sub["subscription_id_header"], str), "subscription_id_header must be string"
486+
assert "subscription_description" in sub, f"Missing subscription_description: {sub}"
487+
assert isinstance(sub["subscription_description"], str), "subscription_description must be string"
488+
assert "priority" in sub, f"Missing priority: {sub}"
489+
assert isinstance(sub["priority"], int), "priority must be integer"
490+
assert "model_refs" in sub, f"Missing model_refs: {sub}"
491+
assert isinstance(sub["model_refs"], list), "model_refs must be a list"
492+
for ref in sub["model_refs"]:
493+
assert "name" in ref, f"model_ref missing name: {ref}"
494+
assert isinstance(ref["name"], str), "model_ref name must be string"
495+
if "display_name" in sub:
496+
assert isinstance(sub["display_name"], str), "display_name must be string"
497+
if "organization_id" in sub:
498+
assert isinstance(sub["organization_id"], str), "organization_id must be string"
499+
if "cost_center" in sub:
500+
assert isinstance(sub["cost_center"], str), "cost_center must be string"
501+
if "labels" in sub:
502+
assert isinstance(sub["labels"], dict), "labels must be a dict"
503+
504+
476505
def get_maas_postgres_labels() -> dict[str, str]:
477506
return {
478507
"app": "postgres",

0 commit comments

Comments
 (0)