Skip to content

Commit a63548d

Browse files
committed
test(k8s): add delivery-surface tests exercising CLI, MCP, REST, and SDK against a mocked API server
Drive each delivery entrypoint end-to-end (CLI via main(), MCP via handle_message(), REST via ASGI transport, SDK via ORBClient) through the full CQRS handler stack down to an in-process Kubernetes API mock, so the wiring from every public surface to the provider is covered.
1 parent c898237 commit a63548d

6 files changed

Lines changed: 1963 additions & 0 deletions

File tree

tests/providers/k8s/mocked/conftest.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@
2626
2727
* ``mock_logger`` (function-scoped) — a plain :class:`unittest.mock.MagicMock`
2828
satisfying the :class:`LoggingPort` protocol.
29+
30+
* ``orb_config_dir_k8s`` (function-scoped) — a complete ORB config directory
31+
pointing at a kmock-backed k8s provider instance; used by delivery-surface
32+
tests (CLI / MCP / REST / SDK). Sets ``ORB_CONFIG_DIR`` and tears down
33+
cleanly.
2934
"""
3035

3136
from __future__ import annotations
3237

38+
import json
39+
import os
40+
import shutil
41+
from pathlib import Path
3342
from typing import AsyncIterator
3443
from unittest.mock import MagicMock
3544

@@ -38,6 +47,12 @@
3847
from kmock import KubernetesEmulator
3948
from kmock._internal.apps import Server
4049

50+
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
51+
_CONFIG_SOURCE = _PROJECT_ROOT / "config"
52+
53+
# Provider instance name used in delivery-surface tests.
54+
K8S_KMOCK_PROVIDER_NAME = "k8s_kmock_test"
55+
4156

4257
@pytest_asyncio.fixture()
4358
async def kmock_k8s() -> AsyncIterator[KubernetesEmulator]:
@@ -91,3 +106,92 @@ def k8s_config(): # type: ignore[return]
91106
def mock_logger() -> MagicMock:
92107
"""A MagicMock satisfying the LoggingPort protocol."""
93108
return MagicMock()
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# orb_config_dir_k8s — delivery-surface tests (CLI / MCP / REST / SDK)
113+
# ---------------------------------------------------------------------------
114+
115+
116+
@pytest.fixture()
117+
def orb_config_dir_k8s(tmp_path, kmock_k8s):
118+
"""Generate a complete ORB config directory pointing at the kmock k8s provider.
119+
120+
Writes a ``config.json`` that declares a single k8s provider instance
121+
(``k8s_kmock_test``) with ``in_cluster: false`` and an explicit
122+
``namespace: orb-test``. Sets ``ORB_CONFIG_DIR`` for the test and clears
123+
it on teardown.
124+
125+
Depends on ``kmock_k8s`` so each test gets a fresh emulator.
126+
127+
Yields the Path to the config directory.
128+
"""
129+
from orb.infrastructure.di.container import reset_container
130+
from tests.utilities.reset_singletons import reset_all_singletons
131+
132+
reset_container()
133+
reset_all_singletons()
134+
135+
config_dir = tmp_path / "config"
136+
config_dir.mkdir(parents=True, exist_ok=True)
137+
138+
config_data = {
139+
"scheduler": {
140+
"type": "hostfactory",
141+
"config_root": str(config_dir),
142+
},
143+
"provider": {
144+
"providers": [
145+
{
146+
"name": K8S_KMOCK_PROVIDER_NAME,
147+
"type": "k8s",
148+
"enabled": True,
149+
"default": True,
150+
"config": {
151+
# in_cluster=True passes the empty-config guard in
152+
# create_k8s_strategy (False is falsy and would trigger
153+
# the "no cluster-targeting info" error). The actual
154+
# K8sClient is replaced post-bootstrap by
155+
# _inject_kmock_factory before any network calls are made
156+
# (initialize() is a no-op — it sets _initialized=True only).
157+
"in_cluster": True,
158+
"namespace": "orb-test",
159+
"watch_enabled": False,
160+
"orphan_gc_enabled": False,
161+
"metrics_enabled": False,
162+
},
163+
}
164+
]
165+
},
166+
"storage": {
167+
"strategy": "json",
168+
"default_storage_path": str(tmp_path / "data"),
169+
"json_strategy": {
170+
"storage_type": "single_file",
171+
"base_path": str(tmp_path / "data"),
172+
"filenames": {"single_file": "request_database.json"},
173+
},
174+
},
175+
}
176+
with open(config_dir / "config.json", "w") as f:
177+
json.dump(config_data, f, indent=2)
178+
179+
# Copy k8s_templates.json into the config dir so the template loader finds them.
180+
k8s_tpl_src = _CONFIG_SOURCE / "k8s_templates.json"
181+
if k8s_tpl_src.exists():
182+
shutil.copy2(k8s_tpl_src, config_dir / "k8s_templates.json")
183+
184+
default_src = _CONFIG_SOURCE / "default_config.json"
185+
if default_src.exists():
186+
shutil.copy2(default_src, config_dir / "default_config.json")
187+
188+
os.environ["ORB_CONFIG_DIR"] = str(config_dir)
189+
190+
yield config_dir
191+
192+
os.environ.pop("ORB_CONFIG_DIR", None)
193+
from orb.infrastructure.di.container import reset_container
194+
from tests.utilities.reset_singletons import reset_all_singletons
195+
196+
reset_container()
197+
reset_all_singletons()
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Shared helpers for kmock-backed delivery-surface tests.
2+
3+
Provides the k8s equivalents of the helpers exported from
4+
``tests/providers/aws/mocked/conftest.py``:
5+
6+
* ``_inject_kmock_factory`` — post-bootstrap hook that swaps the DI-wired
7+
K8sProviderStrategy's internal ``_kubernetes_client`` for a K8sClient
8+
pointed at the live kmock server URL, so every kubernetes SDK call routes
9+
through the emulator instead of a real apiserver.
10+
11+
* ``_make_k8s_logger`` — produces a MagicMock satisfying LoggingPort.
12+
13+
* ``_register_pod_resource`` / ``_register_deployment_resource`` /
14+
``_register_statefulset_resource`` / ``_register_job_resource`` — kmock
15+
resource-registration helpers, re-exported so delivery tests don't have to
16+
reach into test_lifecycle_e2e.py.
17+
18+
The ``orb_config_dir_k8s`` fixture lives in ``conftest.py`` (not here) so
19+
pytest discovers it automatically without any re-export tricks.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any
25+
from unittest.mock import MagicMock
26+
27+
# ---------------------------------------------------------------------------
28+
# Logger helper
29+
# ---------------------------------------------------------------------------
30+
31+
32+
def _make_k8s_logger() -> Any:
33+
"""Return a MagicMock satisfying the LoggingPort protocol."""
34+
logger = MagicMock()
35+
logger.debug = MagicMock()
36+
logger.info = MagicMock()
37+
logger.warning = MagicMock()
38+
logger.error = MagicMock()
39+
return logger
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# kmock resource-registration helpers (re-exported from test_lifecycle_e2e.py
44+
# so delivery-surface tests only import from this module)
45+
# ---------------------------------------------------------------------------
46+
47+
48+
def _register_pod_resource(kmock_k8s) -> None:
49+
from kmock import resource
50+
51+
pod_res = resource("", "v1", "pods")
52+
kmock_k8s.resources[pod_res] = {
53+
"namespaced": True,
54+
"kind": "Pod",
55+
"singular": "pod",
56+
"verbs": ["get", "list", "create", "delete", "watch"],
57+
"shortnames": ["po"],
58+
"categories": [],
59+
"subresources": [],
60+
}
61+
62+
63+
def _register_deployment_resource(kmock_k8s) -> None:
64+
from kmock import resource
65+
66+
dep_res = resource("apps", "v1", "deployments")
67+
kmock_k8s.resources[dep_res] = {
68+
"namespaced": True,
69+
"kind": "Deployment",
70+
"singular": "deployment",
71+
"verbs": ["get", "list", "create", "patch", "delete", "watch"],
72+
"shortnames": ["deploy"],
73+
"categories": [],
74+
"subresources": ["scale", "status"],
75+
}
76+
77+
78+
def _register_statefulset_resource(kmock_k8s) -> None:
79+
from kmock import resource
80+
81+
sts_res = resource("apps", "v1", "statefulsets")
82+
kmock_k8s.resources[sts_res] = {
83+
"namespaced": True,
84+
"kind": "StatefulSet",
85+
"singular": "statefulset",
86+
"verbs": ["get", "list", "create", "patch", "delete", "watch"],
87+
"shortnames": ["sts"],
88+
"categories": [],
89+
"subresources": ["scale", "status"],
90+
}
91+
92+
93+
def _register_job_resource(kmock_k8s) -> None:
94+
from kmock import resource
95+
96+
job_res = resource("batch", "v1", "jobs")
97+
kmock_k8s.resources[job_res] = {
98+
"namespaced": True,
99+
"kind": "Job",
100+
"singular": "job",
101+
"verbs": ["get", "list", "create", "patch", "delete", "watch"],
102+
"shortnames": [],
103+
"categories": [],
104+
"subresources": ["status"],
105+
}
106+
107+
108+
# ---------------------------------------------------------------------------
109+
# kmock injection helper
110+
# ---------------------------------------------------------------------------
111+
112+
113+
def _inject_kmock_factory(kmock_k8s, logger) -> None:
114+
"""Swap the DI-wired K8sProviderStrategy's kubernetes_client for a kmock one.
115+
116+
Called post-bootstrap (after Application.initialize completes) so that the
117+
DI container and provider registry are already populated. Replaces
118+
``strategy._kubernetes_client`` in-place so subsequent handler calls go
119+
through the kmock aiohttp server rather than a real apiserver.
120+
121+
Args:
122+
kmock_k8s: A running :class:`kmock.KubernetesEmulator` instance.
123+
logger: A LoggingPort-compatible object (use ``_make_k8s_logger()``).
124+
"""
125+
import kubernetes.client as _kc
126+
127+
from orb.domain.base.ports import ConfigurationPort
128+
from orb.infrastructure.di.container import get_container
129+
from orb.providers.k8s.configuration.config import K8sProviderConfig
130+
from orb.providers.k8s.infrastructure.k8s_client import K8sClient
131+
from orb.providers.k8s.strategy.handler_registry import K8sHandlerRegistry
132+
from orb.providers.registry import get_provider_registry
133+
from tests.providers.k8s.mocked.conftest import K8S_KMOCK_PROVIDER_NAME
134+
135+
# Build a kubernetes.client.ApiClient pointed at the kmock server.
136+
cfg = _kc.Configuration()
137+
cfg.host = str(kmock_k8s.url).rstrip("/")
138+
cfg.verify_ssl = False
139+
api_client = _kc.ApiClient(configuration=cfg)
140+
141+
k8s_config = K8sProviderConfig(namespace="orb-test") # type: ignore[call-arg]
142+
kmock_client = K8sClient(config=k8s_config, logger=logger, api_client=api_client)
143+
144+
# Ensure the provider instance is registered in the registry.
145+
registry = get_provider_registry()
146+
container = get_container()
147+
cfg_port = container.get(ConfigurationPort)
148+
registry._config_port = cfg_port
149+
150+
provider_config = cfg_port.get_provider_config()
151+
if provider_config:
152+
for pi in provider_config.get_active_providers():
153+
if not registry.is_provider_instance_registered(pi.name):
154+
registry.ensure_provider_instance_registered_from_config(pi)
155+
156+
strategy = registry.get_or_create_strategy(K8S_KMOCK_PROVIDER_NAME)
157+
if strategy is None:
158+
# Strategy may not yet be cached — create it directly with our client.
159+
from orb.providers.k8s.strategy.k8s_provider_strategy import K8sProviderStrategy
160+
161+
strategy = K8sProviderStrategy(
162+
config=k8s_config,
163+
logger=logger,
164+
provider_name=K8S_KMOCK_PROVIDER_NAME,
165+
kubernetes_client=kmock_client,
166+
)
167+
strategy.initialize()
168+
registry._strategy_cache[K8S_KMOCK_PROVIDER_NAME] = strategy
169+
else:
170+
# Swap out the client on the already-cached strategy.
171+
strategy._kubernetes_client = kmock_client
172+
173+
# Rebuild the handler registry so handlers pick up the new client.
174+
strategy._handler_registry = K8sHandlerRegistry(
175+
config=strategy._k8s_config,
176+
logger=strategy._logger,
177+
client_provider=lambda: strategy.kubernetes_client,
178+
watch_manager_provider=lambda: None,
179+
plugin_factories=lambda: {},
180+
native_spec_service_provider=lambda: None,
181+
)

0 commit comments

Comments
 (0)