Skip to content

Commit 8968196

Browse files
authored
test(maas): add ephemeral API key cleanup tests (#1324)
* test(maas): add ephemeral API key cleanup tests Signed-off-by: Swati Mukund Bagal <sbagal@redhat.com> * fix: address review comments Signed-off-by: Swati Mukund Bagal <sbagal@redhat.com> --------- Signed-off-by: Swati Mukund Bagal <sbagal@redhat.com>
1 parent 69f20d4 commit 8968196

File tree

3 files changed

+286
-0
lines changed

3 files changed

+286
-0
lines changed

tests/model_serving/maas_billing/maas_subscription/conftest.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,24 @@
55
import requests
66
import structlog
77
from kubernetes.dynamic import DynamicClient
8+
from ocp_resources.cron_job import CronJob
89
from ocp_resources.llm_inference_service import LLMInferenceService
910
from ocp_resources.maas_auth_policy import MaaSAuthPolicy
1011
from ocp_resources.maas_model_ref import MaaSModelRef
1112
from ocp_resources.maas_subscription import MaaSSubscription
1213
from ocp_resources.namespace import Namespace
14+
from ocp_resources.network_policy import NetworkPolicy
15+
from ocp_resources.pod import Pod
1316
from ocp_resources.resource import ResourceEditor
1417
from ocp_resources.service_account import ServiceAccount
1518
from pytest_testconfig import config as py_config
1619

1720
from tests.model_serving.maas_billing.maas_subscription.utils import (
21+
assert_api_key_created_ok,
1822
create_and_yield_api_key_id,
1923
create_api_key,
2024
create_maas_subscription,
25+
get_maas_api_labels,
2126
patch_llmisvc_with_maas_router_and_tiers,
2227
resolve_api_key_username,
2328
revoke_api_key,
@@ -786,3 +791,87 @@ def short_expiration_api_key_id(
786791
key_name_prefix="e2e-exp-short",
787792
expires_in="1h",
788793
)
794+
795+
796+
@pytest.fixture()
797+
def maas_cleanup_cronjob(
798+
admin_client: DynamicClient,
799+
) -> CronJob:
800+
"""Return the maas-api-key-cleanup CronJob, asserting it exists."""
801+
applications_namespace = py_config["applications_namespace"]
802+
cronjob = CronJob(
803+
client=admin_client,
804+
name="maas-api-key-cleanup",
805+
namespace=applications_namespace,
806+
)
807+
assert cronjob.exists, f"CronJob maas-api-key-cleanup not found in {applications_namespace}"
808+
return cronjob
809+
810+
811+
@pytest.fixture()
812+
def maas_cleanup_networkpolicy(
813+
admin_client: DynamicClient,
814+
) -> NetworkPolicy:
815+
"""Return the maas-api-cleanup-restrict NetworkPolicy, asserting it exists."""
816+
applications_namespace = py_config["applications_namespace"]
817+
network_policy = NetworkPolicy(
818+
client=admin_client,
819+
name="maas-api-cleanup-restrict",
820+
namespace=applications_namespace,
821+
)
822+
assert network_policy.exists, f"NetworkPolicy maas-api-cleanup-restrict not found in {applications_namespace}"
823+
return network_policy
824+
825+
826+
@pytest.fixture()
827+
def maas_api_pod_name(
828+
admin_client: DynamicClient,
829+
) -> str:
830+
"""Return the name of the single running maas-api pod (exactly one pod is expected)."""
831+
applications_namespace = py_config["applications_namespace"]
832+
label_selector = ",".join(f"{k}={v}" for k, v in get_maas_api_labels().items())
833+
pods = list(
834+
Pod.get(
835+
client=admin_client,
836+
namespace=applications_namespace,
837+
label_selector=label_selector,
838+
)
839+
)
840+
assert len(pods) == 1, f"Expected exactly 1 maas-api pod in {applications_namespace}, found {len(pods)}"
841+
assert pods[0].instance.status.phase == "Running", (
842+
f"maas-api pod '{pods[0].name}' is not Running (phase={pods[0].instance.status.phase})"
843+
)
844+
return pods[0].name
845+
846+
847+
@pytest.fixture()
848+
def ephemeral_api_key(
849+
request_session_http: requests.Session,
850+
base_url: str,
851+
ocp_token_for_actor: str,
852+
) -> Generator[dict[str, Any]]:
853+
"""Create an ephemeral API key and revoke it on teardown."""
854+
creation_response, api_key_data = create_api_key(
855+
base_url=base_url,
856+
ocp_user_token=ocp_token_for_actor,
857+
request_session_http=request_session_http,
858+
api_key_name=f"e2e-ephemeral-{generate_random_name()}",
859+
expires_in="1h",
860+
ephemeral=True,
861+
raise_on_error=False,
862+
)
863+
assert_api_key_created_ok(resp=creation_response, body=api_key_data, required_fields=("key", "id"))
864+
LOGGER.info(
865+
f"[ephemeral] Created ephemeral key: id={api_key_data['id']}, expiresAt={api_key_data.get('expiresAt')}"
866+
)
867+
yield api_key_data
868+
revoke_response, _ = revoke_api_key(
869+
request_session_http=request_session_http,
870+
base_url=base_url,
871+
key_id=api_key_data["id"],
872+
ocp_user_token=ocp_token_for_actor,
873+
)
874+
if revoke_response.status_code not in (200, 404):
875+
raise AssertionError(
876+
f"Unexpected teardown status for ephemeral key id={api_key_data['id']}: {revoke_response.status_code}"
877+
)
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from typing import Any
2+
3+
import portforward
4+
import pytest
5+
import requests
6+
import structlog
7+
from ocp_resources.cron_job import CronJob
8+
from ocp_resources.network_policy import NetworkPolicy
9+
from pytest_testconfig import config as py_config
10+
11+
from tests.model_serving.maas_billing.maas_subscription.utils import search_active_api_keys
12+
from tests.model_serving.maas_billing.utils import build_maas_headers
13+
14+
LOGGER = structlog.get_logger(name=__name__)
15+
16+
17+
@pytest.mark.usefixtures(
18+
"maas_subscription_controller_enabled_latest",
19+
"maas_gateway_api",
20+
"maas_api_gateway_reachable",
21+
)
22+
class TestEphemeralKeyCleanup:
23+
"""Tests for ephemeral API key cleanup (CronJob + internal endpoint)."""
24+
25+
@pytest.mark.tier1
26+
def test_cronjob_exists_and_configured(self, maas_cleanup_cronjob: CronJob) -> None:
27+
"""Verify the maas-api-key-cleanup CronJob exists with expected configuration."""
28+
spec = maas_cleanup_cronjob.instance.spec
29+
30+
assert spec.schedule == "*/15 * * * *", f"Expected schedule '*/15 * * * *', got '{spec.schedule}'"
31+
assert spec.concurrencyPolicy == "Forbid", (
32+
"CronJob should use Forbid concurrency policy to prevent overlapping runs"
33+
)
34+
35+
containers = spec.jobTemplate.spec.template.spec.containers
36+
assert len(containers) >= 1, "CronJob should have at least one container"
37+
container_spec = containers[0]
38+
cmd_str = " ".join(container_spec.command or [])
39+
assert "/internal/v1/api-keys/cleanup" in cmd_str, (
40+
f"CronJob command should target the internal cleanup endpoint, got: {cmd_str}"
41+
)
42+
43+
sec_ctx = getattr(container_spec, "securityContext", None)
44+
assert sec_ctx is not None, "Cleanup container should have securityContext configured"
45+
assert sec_ctx.runAsNonRoot is True, "Cleanup container should run as non-root"
46+
assert sec_ctx.readOnlyRootFilesystem is True, "Cleanup container should have read-only root filesystem"
47+
48+
LOGGER.info(f"[ephemeral] CronJob validated: schedule={spec.schedule}, concurrency={spec.concurrencyPolicy}")
49+
50+
@pytest.mark.tier1
51+
def test_cleanup_networkpolicy_exists(self, maas_cleanup_networkpolicy: NetworkPolicy) -> None:
52+
"""Verify the cleanup NetworkPolicy restricts cleanup pod egress to maas-api only."""
53+
spec = maas_cleanup_networkpolicy.instance.spec
54+
55+
assert spec.podSelector.matchLabels.get("app") == "maas-api-cleanup", (
56+
f"NetworkPolicy should target app=maas-api-cleanup pods, got: {spec.podSelector.matchLabels}"
57+
)
58+
for policy_type in ("Egress", "Ingress"):
59+
assert policy_type in spec.policyTypes, f"NetworkPolicy should control {policy_type} traffic"
60+
61+
ingress_rules = getattr(spec, "ingress", None)
62+
assert ingress_rules in ([], None), "Cleanup pods should have no inbound traffic allowed"
63+
64+
egress_rules = getattr(spec, "egress", None)
65+
assert egress_rules, "NetworkPolicy should define at least one egress rule"
66+
67+
LOGGER.info("[ephemeral] NetworkPolicy validated: cleanup pods restricted to maas-api egress only")
68+
69+
@pytest.mark.tier1
70+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
71+
def test_ephemeral_key_visible_with_include_filter(
72+
self,
73+
request_session_http: requests.Session,
74+
base_url: str,
75+
ocp_token_for_actor: str,
76+
ephemeral_api_key: dict[str, Any],
77+
) -> None:
78+
"""Verify ephemeral key is marked as ephemeral and visible when includeEphemeral=True."""
79+
key_id = ephemeral_api_key["id"]
80+
81+
assert ephemeral_api_key.get("ephemeral") is True, "Key should be marked as ephemeral"
82+
83+
items = search_active_api_keys(
84+
request_session_http=request_session_http,
85+
base_url=base_url,
86+
ocp_user_token=ocp_token_for_actor,
87+
include_ephemeral=True,
88+
)
89+
assert key_id in [item["id"] for item in items], (
90+
f"Ephemeral key {key_id} should appear in search with includeEphemeral=True"
91+
)
92+
LOGGER.info(f"[ephemeral] Ephemeral key {key_id} visible with includeEphemeral=True")
93+
94+
@pytest.mark.tier1
95+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
96+
def test_ephemeral_key_hidden_from_default_search(
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 key is hidden from default search when includeEphemeral is not set."""
104+
key_id = ephemeral_api_key["id"]
105+
106+
default_items = search_active_api_keys(
107+
request_session_http=request_session_http,
108+
base_url=base_url,
109+
ocp_user_token=ocp_token_for_actor,
110+
include_ephemeral=False,
111+
)
112+
assert key_id not in [item["id"] for item in default_items], (
113+
"Ephemeral key should be excluded from default search (includeEphemeral defaults to False)"
114+
)
115+
LOGGER.info(f"[ephemeral] Ephemeral key {key_id} correctly hidden from default search")
116+
117+
@pytest.mark.tier1
118+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
119+
def test_trigger_cleanup_preserves_active_keys(
120+
self,
121+
request_session_http: requests.Session,
122+
base_url: str,
123+
ocp_token_for_actor: str,
124+
ephemeral_api_key: dict[str, Any],
125+
maas_api_pod_name: str,
126+
) -> None:
127+
"""Verify the cleanup endpoint does not delete active (non-expired) ephemeral keys."""
128+
applications_namespace = py_config["applications_namespace"]
129+
key_id = ephemeral_api_key["id"]
130+
api_keys_endpoint = f"{base_url}/v1/api-keys"
131+
auth_header = build_maas_headers(token=ocp_token_for_actor)
132+
133+
LOGGER.info(f"[ephemeral] Triggering cleanup via port-forward into pod={maas_api_pod_name}")
134+
135+
with portforward.forward(
136+
pod_or_service=maas_api_pod_name,
137+
namespace=applications_namespace,
138+
from_port=8080,
139+
to_port=8080,
140+
waiting=20,
141+
):
142+
cleanup_response = requests.post(
143+
url="http://localhost:8080/internal/v1/api-keys/cleanup",
144+
timeout=30,
145+
)
146+
147+
assert cleanup_response.status_code == 200, (
148+
f"Cleanup endpoint returned unexpected status: {cleanup_response.status_code}: "
149+
f"{(cleanup_response.text or '')[:200]}"
150+
)
151+
cleanup_resp = cleanup_response.json()
152+
deleted_count = cleanup_resp.get("deletedCount", -1)
153+
assert deleted_count >= 0, f"Cleanup response should have non-negative deletedCount, got: {cleanup_resp}"
154+
LOGGER.info(f"[ephemeral] Cleanup completed: deletedCount={deleted_count}")
155+
156+
r_get = request_session_http.get(
157+
url=f"{api_keys_endpoint}/{key_id}",
158+
headers=auth_header,
159+
timeout=30,
160+
)
161+
assert r_get.status_code == 200, (
162+
f"Active ephemeral key {key_id} should survive cleanup, got {r_get.status_code}: {(r_get.text or '')[:200]}"
163+
)
164+
get_body = r_get.json()
165+
assert get_body.get("status") == "active", (
166+
f"Key should still be active after cleanup, got: {get_body.get('status')}"
167+
)
168+
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,
@@ -466,6 +472,29 @@ def assert_api_key_created_ok(
466472
assert field in body, f"Response must contain '{field}'"
467473

468474

475+
def search_active_api_keys(
476+
request_session_http: requests.Session,
477+
base_url: str,
478+
ocp_user_token: str,
479+
include_ephemeral: bool = False,
480+
request_timeout_seconds: int = 30,
481+
) -> list[dict[str, Any]]:
482+
"""POST /v1/api-keys/search for active keys and return the list of matching items."""
483+
filters: dict[str, Any] = {"status": ["active"]}
484+
if include_ephemeral:
485+
filters["includeEphemeral"] = True
486+
url = f"{base_url}/v1/api-keys/search"
487+
resp = request_session_http.post(
488+
url=url,
489+
headers={"Authorization": f"Bearer {ocp_user_token}"},
490+
json={"filters": filters, "pagination": {"limit": 50, "offset": 0}},
491+
timeout=request_timeout_seconds,
492+
)
493+
assert resp.status_code == 200, f"Expected 200 from key search, got {resp.status_code}: {(resp.text or '')[:200]}"
494+
body = resp.json()
495+
return body.get("items") or body.get("data") or []
496+
497+
469498
def assert_api_key_get_ok(resp: Response, body: dict[str, Any], key_id: str) -> None:
470499
"""Assert a GET /v1/api-keys/{id} response has status 200."""
471500
assert resp.status_code == 200, (

0 commit comments

Comments
 (0)