Skip to content

Commit 9d24737

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

File tree

3 files changed

+281
-0
lines changed

3 files changed

+281
-0
lines changed

tests/model_serving/maas_billing/maas_subscription/conftest.py

Lines changed: 53 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,53 @@ 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+
all_pods = list(
802+
Pod.get(
803+
client=admin_client,
804+
namespace=applications_namespace,
805+
label_selector=label_selector,
806+
)
807+
)
808+
running_pods = [pod for pod in all_pods if pod.instance.status.phase == "Running"]
809+
assert running_pods, f"No Running maas-api pods found in {applications_namespace}"
810+
return running_pods[0].name
811+
812+
813+
@pytest.fixture()
814+
def ephemeral_api_key(
815+
request_session_http: requests.Session,
816+
base_url: str,
817+
ocp_token_for_actor: str,
818+
) -> Generator[dict[str, Any]]:
819+
"""Create an ephemeral API key and revoke it on teardown."""
820+
create_resp, create_body = create_api_key(
821+
base_url=base_url,
822+
ocp_user_token=ocp_token_for_actor,
823+
request_session_http=request_session_http,
824+
api_key_name=f"e2e-ephemeral-{generate_random_name()}",
825+
expires_in="1h",
826+
ephemeral=True,
827+
raise_on_error=False,
828+
)
829+
assert_api_key_created_ok(resp=create_resp, body=create_body, required_fields=("key", "id"))
830+
LOGGER.info(f"[ephemeral] Created ephemeral key: id={create_body['id']}, expiresAt={create_body.get('expiresAt')}")
831+
yield create_body
832+
revoke_resp, _ = revoke_api_key(
833+
request_session_http=request_session_http,
834+
base_url=base_url,
835+
key_id=create_body["id"],
836+
ocp_user_token=ocp_token_for_actor,
837+
)
838+
if revoke_resp.status_code not in (200, 404):
839+
raise AssertionError(
840+
f"Unexpected teardown status for ephemeral key id={create_body['id']}: {revoke_resp.status_code}"
841+
)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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, f"CronJob {MAAS_CLEANUP_CRONJOB_NAME} not found in {applications_namespace}"
41+
42+
spec = cronjob.instance.spec
43+
44+
assert spec.schedule == "*/15 * * * *", f"Expected schedule '*/15 * * * *', got '{spec.schedule}'"
45+
assert spec.concurrencyPolicy == "Forbid", (
46+
"CronJob should use Forbid concurrency policy to prevent overlapping runs"
47+
)
48+
49+
containers = spec.jobTemplate.spec.template.spec.containers
50+
assert len(containers) >= 1, "CronJob should have at least one container"
51+
container_spec = containers[0]
52+
cmd_str = " ".join(container_spec.command or [])
53+
assert "/internal/v1/api-keys/cleanup" in cmd_str, (
54+
f"CronJob command should target the internal cleanup endpoint, got: {cmd_str}"
55+
)
56+
57+
sec_ctx = getattr(container_spec, "securityContext", None)
58+
assert sec_ctx is not None, "Cleanup container should have securityContext configured"
59+
assert sec_ctx.runAsNonRoot is True, "Cleanup container should run as non-root"
60+
assert sec_ctx.readOnlyRootFilesystem is True, "Cleanup container should have read-only root filesystem"
61+
62+
LOGGER.info(f"[ephemeral] CronJob validated: schedule={spec.schedule}, concurrency={spec.concurrencyPolicy}")
63+
64+
@pytest.mark.tier1
65+
def test_cleanup_networkpolicy_exists(self, admin_client: DynamicClient) -> None:
66+
"""Verify the cleanup NetworkPolicy restricts cleanup pod egress to maas-api only."""
67+
applications_namespace = py_config["applications_namespace"]
68+
69+
network_policy = NetworkPolicy(
70+
client=admin_client,
71+
name=MAAS_CLEANUP_NETWORKPOLICY_NAME,
72+
namespace=applications_namespace,
73+
)
74+
assert network_policy.exists, (
75+
f"NetworkPolicy {MAAS_CLEANUP_NETWORKPOLICY_NAME} not found in {applications_namespace}"
76+
)
77+
78+
spec = network_policy.instance.spec
79+
80+
assert spec.podSelector.matchLabels.get("app") == "maas-api-cleanup", (
81+
f"NetworkPolicy should target app=maas-api-cleanup pods, got: {spec.podSelector.matchLabels}"
82+
)
83+
assert "Egress" in spec.policyTypes, "NetworkPolicy should control Egress traffic"
84+
assert "Ingress" in spec.policyTypes, "NetworkPolicy should control Ingress traffic"
85+
86+
ingress_rules = getattr(spec, "ingress", None)
87+
assert ingress_rules in ([], None), "Cleanup pods should have no inbound traffic allowed"
88+
89+
egress_rules = getattr(spec, "egress", None)
90+
assert egress_rules, "NetworkPolicy should define at least one egress rule"
91+
92+
LOGGER.info("[ephemeral] NetworkPolicy validated: cleanup pods restricted to maas-api egress only")
93+
94+
@pytest.mark.tier1
95+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
96+
def test_create_ephemeral_key(
97+
self,
98+
request_session_http: requests.Session,
99+
base_url: str,
100+
ocp_token_for_actor: str,
101+
ephemeral_api_key: dict[str, Any],
102+
) -> None:
103+
"""Verify ephemeral keys are visible with includeEphemeral filter but hidden by default."""
104+
key_id = ephemeral_api_key["id"]
105+
api_keys_endpoint = f"{base_url}/v1/api-keys"
106+
auth_header = build_maas_headers(token=ocp_token_for_actor)
107+
108+
assert ephemeral_api_key.get("ephemeral") is True, "Key should be marked as ephemeral"
109+
110+
r_search = request_session_http.post(
111+
url=f"{api_keys_endpoint}/search",
112+
headers=auth_header,
113+
json={
114+
"filters": {"status": ["active"], "includeEphemeral": True},
115+
"pagination": {"limit": 50, "offset": 0},
116+
},
117+
timeout=30,
118+
)
119+
assert r_search.status_code == 200, (
120+
f"Expected 200 from search with includeEphemeral=True, "
121+
f"got {r_search.status_code}: {(r_search.text or '')[:200]}"
122+
)
123+
search_body = r_search.json()
124+
items: list[dict[str, Any]] = search_body.get("items") or search_body.get("data") or []
125+
assert key_id in [item["id"] for item in items], (
126+
f"Ephemeral key {key_id} should appear in search with includeEphemeral=True"
127+
)
128+
129+
r_default = request_session_http.post(
130+
url=f"{api_keys_endpoint}/search",
131+
headers=auth_header,
132+
json={
133+
"filters": {"status": ["active"]},
134+
"pagination": {"limit": 50, "offset": 0},
135+
},
136+
timeout=30,
137+
)
138+
assert r_default.status_code == 200, (
139+
f"Expected 200 from default search, got {r_default.status_code}: {(r_default.text or '')[:200]}"
140+
)
141+
default_body = r_default.json()
142+
default_items: list[dict[str, Any]] = default_body.get("items") or default_body.get("data") or []
143+
assert key_id not in [item["id"] for item in default_items], (
144+
"Ephemeral key should be excluded from default search (includeEphemeral defaults to False)"
145+
)
146+
LOGGER.info(f"[ephemeral] Ephemeral key {key_id} visibility verified")
147+
148+
@pytest.mark.tier1
149+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
150+
def test_trigger_cleanup_preserves_active_keys(
151+
self,
152+
request_session_http: requests.Session,
153+
base_url: str,
154+
ocp_token_for_actor: str,
155+
ephemeral_api_key: dict[str, Any],
156+
maas_api_pod_name: str,
157+
) -> None:
158+
"""Verify the cleanup endpoint does not delete active (non-expired) ephemeral keys."""
159+
applications_namespace = py_config["applications_namespace"]
160+
key_id = ephemeral_api_key["id"]
161+
api_keys_endpoint = f"{base_url}/v1/api-keys"
162+
auth_header = build_maas_headers(token=ocp_token_for_actor)
163+
164+
LOGGER.info(f"[ephemeral] Triggering cleanup via port-forward into pod={maas_api_pod_name}")
165+
166+
with portforward.forward(
167+
pod_or_service=maas_api_pod_name,
168+
namespace=applications_namespace,
169+
from_port=8080,
170+
to_port=8080,
171+
waiting=20,
172+
):
173+
cleanup_response = requests.post(
174+
url="http://localhost:8080/internal/v1/api-keys/cleanup",
175+
timeout=30,
176+
)
177+
178+
assert cleanup_response.status_code == 200, (
179+
f"Cleanup endpoint returned unexpected status: {cleanup_response.status_code}: "
180+
f"{(cleanup_response.text or '')[:200]}"
181+
)
182+
cleanup_resp = cleanup_response.json()
183+
deleted_count = cleanup_resp.get("deletedCount", -1)
184+
assert deleted_count >= 0, f"Cleanup response should have non-negative deletedCount, got: {cleanup_resp}"
185+
LOGGER.info(f"[ephemeral] Cleanup completed: deletedCount={deleted_count}")
186+
187+
r_get = request_session_http.get(
188+
url=f"{api_keys_endpoint}/{key_id}",
189+
headers=auth_header,
190+
timeout=30,
191+
)
192+
assert r_get.status_code == 200, (
193+
f"Active ephemeral key {key_id} should survive cleanup, got {r_get.status_code}: {(r_get.text or '')[:200]}"
194+
)
195+
get_body = r_get.json()
196+
assert get_body.get("status") == "active", (
197+
f"Key should still be active after cleanup, got: {get_body.get('status')}"
198+
)
199+
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)