Skip to content

Commit 65220b3

Browse files
committed
cert-manager: add jmp admin legacy client support, oidc missing yet
1 parent 77c7f09 commit 65220b3

6 files changed

Lines changed: 352 additions & 17 deletions

File tree

e2e/tests.bats

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,34 @@ EOF
179179
jmp shell --selector example.com/board=oidc j power on
180180
}
181181

182+
@test "legacy client config contains CA certificate and works with secure TLS" {
183+
# This test only works with operator-based deployment, which creates the CA ConfigMap
184+
if [ "${METHOD:-}" != "operator" ]; then
185+
skip "CA certificate injection only available with operator deployment (METHOD=$METHOD)"
186+
fi
187+
188+
wait_for_exporter
189+
190+
# Get the config file path from jmp (clients are saved to ~/.config/jumpstarter/clients/)
191+
local config_file="${HOME}/.config/jumpstarter/clients/test-client-legacy.yaml"
192+
run test -f "$config_file"
193+
assert_success
194+
195+
# Check that tls.ca field exists and is not empty
196+
run go run github.com/mikefarah/yq/v4@latest '.tls.ca' "$config_file"
197+
assert_success
198+
# The CA should be a non-empty base64-encoded string
199+
refute_output ""
200+
refute_output "null"
201+
202+
# Test that the client works WITHOUT JUMPSTARTER_GRPC_INSECURE set
203+
# This proves the CA certificate is being used for TLS verification
204+
run env -u JUMPSTARTER_GRPC_INSECURE jmp get exporters --client test-client-legacy
205+
assert_success
206+
# Should see the legacy exporter in the output
207+
assert_output --partial "test-exporter-legacy"
208+
}
209+
182210
@test "can operate on leases" {
183211
wait_for_exporter
184212

python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .util import AbstractAsyncCustomObjectApi
1313
from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers
1414
from jumpstarter.config.common import ObjectMeta
15+
from jumpstarter.config.tls import TLSConfigV1Alpha1
1516

1617
logger = logging.getLogger(__name__)
1718

@@ -147,6 +148,8 @@ async def get_client_config(self, name: str, allow: list[str], unsafe=False) ->
147148
secret = await self.core_api.read_namespaced_secret(client.status.credential.name, self.namespace)
148149
endpoint = client.status.endpoint
149150
token = base64.b64decode(secret.data["token"]).decode("utf8")
151+
# Get CA bundle from ConfigMap (base64-encoded)
152+
ca_bundle = await self.get_ca_bundle()
150153
return ClientConfigV1Alpha1(
151154
alias=name,
152155
metadata=ObjectMeta(
@@ -156,6 +159,7 @@ async def get_client_config(self, name: str, allow: list[str], unsafe=False) ->
156159
endpoint=endpoint,
157160
token=token,
158161
drivers=ClientConfigV1Alpha1Drivers(allow=allow, unsafe=unsafe),
162+
tls=TLSConfigV1Alpha1(ca=ca_bundle),
159163
)
160164

161165
async def delete_client(self, name: str):

python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py

Lines changed: 182 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import base64
2+
from unittest.mock import AsyncMock, MagicMock
13

2-
from kubernetes_asyncio.client.models import V1ObjectMeta
4+
import pytest
5+
from kubernetes_asyncio.client.exceptions import ApiException
6+
from kubernetes_asyncio.client.models import V1ConfigMap, V1ObjectMeta, V1Secret
37

48
from jumpstarter_kubernetes import V1Alpha1Client, V1Alpha1ClientStatus
9+
from jumpstarter_kubernetes.clients import ClientsV1Alpha1Api
510

611
TEST_CLIENT = V1Alpha1Client(
712
api_version="jumpstarter.dev/v1alpha1",
@@ -123,7 +128,6 @@ def test_client_from_dict_without_status():
123128

124129
def test_client_rich_add_columns():
125130
"""Test V1Alpha1Client.rich_add_columns"""
126-
from unittest.mock import MagicMock
127131

128132
mock_table = MagicMock()
129133
V1Alpha1Client.rich_add_columns(mock_table)
@@ -134,7 +138,6 @@ def test_client_rich_add_columns():
134138

135139
def test_client_rich_add_rows_with_status():
136140
"""Test V1Alpha1Client.rich_add_rows with status"""
137-
from unittest.mock import MagicMock
138141

139142
mock_table = MagicMock()
140143
TEST_CLIENT.rich_add_rows(mock_table)
@@ -143,7 +146,6 @@ def test_client_rich_add_rows_with_status():
143146

144147
def test_client_rich_add_rows_without_status():
145148
"""Test V1Alpha1Client.rich_add_rows without status"""
146-
from unittest.mock import MagicMock
147149

148150
client = V1Alpha1Client(
149151
api_version="jumpstarter.dev/v1alpha1",
@@ -205,7 +207,6 @@ def test_client_list_from_dict():
205207

206208
def test_client_list_rich_add_columns():
207209
"""Test V1Alpha1ClientList.rich_add_columns"""
208-
from unittest.mock import MagicMock
209210

210211
from jumpstarter_kubernetes import V1Alpha1ClientList
211212

@@ -216,7 +217,6 @@ def test_client_list_rich_add_columns():
216217

217218
def test_client_list_rich_add_rows():
218219
"""Test V1Alpha1ClientList.rich_add_rows"""
219-
from unittest.mock import MagicMock
220220

221221
from jumpstarter_kubernetes import V1Alpha1ClientList
222222

@@ -234,3 +234,179 @@ def test_client_list_rich_add_names():
234234
names = []
235235
client_list.rich_add_names(names)
236236
assert names == ["client.jumpstarter.dev/test-client"]
237+
238+
239+
# Tests for get_ca_bundle and get_client_config
240+
241+
242+
@pytest.mark.asyncio
243+
async def test_get_ca_bundle_with_ca_cert():
244+
"""Test get_ca_bundle returns base64-encoded CA certificate"""
245+
api = ClientsV1Alpha1Api(namespace="test-namespace")
246+
api.core_api = AsyncMock()
247+
248+
# Mock ConfigMap with CA certificate
249+
ca_cert_pem = "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----"
250+
mock_configmap = V1ConfigMap(data={"ca.crt": ca_cert_pem})
251+
api.core_api.read_namespaced_config_map = AsyncMock(return_value=mock_configmap)
252+
253+
result = await api.get_ca_bundle()
254+
255+
# Verify it's base64-encoded
256+
expected = base64.b64encode(ca_cert_pem.encode("utf-8")).decode("utf-8")
257+
assert result == expected
258+
api.core_api.read_namespaced_config_map.assert_called_once_with(
259+
"jumpstarter-service-ca-cert", "test-namespace"
260+
)
261+
262+
263+
@pytest.mark.asyncio
264+
async def test_get_ca_bundle_empty_ca_cert():
265+
"""Test get_ca_bundle returns empty string when ca.crt is empty"""
266+
api = ClientsV1Alpha1Api(namespace="test-namespace")
267+
api.core_api = AsyncMock()
268+
269+
# Mock ConfigMap with empty CA certificate
270+
mock_configmap = V1ConfigMap(data={"ca.crt": ""})
271+
api.core_api.read_namespaced_config_map = AsyncMock(return_value=mock_configmap)
272+
273+
result = await api.get_ca_bundle()
274+
275+
assert result == ""
276+
277+
278+
@pytest.mark.asyncio
279+
async def test_get_ca_bundle_missing_ca_crt_key():
280+
"""Test get_ca_bundle returns empty string when ca.crt key is missing"""
281+
api = ClientsV1Alpha1Api(namespace="test-namespace")
282+
api.core_api = AsyncMock()
283+
284+
# Mock ConfigMap without ca.crt key
285+
mock_configmap = V1ConfigMap(data={"other-key": "value"})
286+
api.core_api.read_namespaced_config_map = AsyncMock(return_value=mock_configmap)
287+
288+
result = await api.get_ca_bundle()
289+
290+
assert result == ""
291+
292+
293+
@pytest.mark.asyncio
294+
async def test_get_ca_bundle_configmap_not_found():
295+
"""Test get_ca_bundle returns empty string when ConfigMap doesn't exist"""
296+
api = ClientsV1Alpha1Api(namespace="test-namespace")
297+
api.core_api = AsyncMock()
298+
299+
# Mock 404 error
300+
api.core_api.read_namespaced_config_map = AsyncMock(
301+
side_effect=ApiException(status=404, reason="Not Found")
302+
)
303+
304+
result = await api.get_ca_bundle()
305+
306+
assert result == ""
307+
308+
309+
@pytest.mark.asyncio
310+
async def test_get_ca_bundle_other_api_error():
311+
"""Test get_ca_bundle raises exception for non-404 errors"""
312+
api = ClientsV1Alpha1Api(namespace="test-namespace")
313+
api.core_api = AsyncMock()
314+
315+
# Mock 403 error
316+
api.core_api.read_namespaced_config_map = AsyncMock(
317+
side_effect=ApiException(status=403, reason="Forbidden")
318+
)
319+
320+
with pytest.raises(ApiException) as exc_info:
321+
await api.get_ca_bundle()
322+
323+
assert exc_info.value.status == 403
324+
325+
326+
@pytest.mark.asyncio
327+
async def test_get_client_config_includes_ca_bundle():
328+
"""Test get_client_config includes CA bundle from ConfigMap"""
329+
api = ClientsV1Alpha1Api(namespace="test-namespace")
330+
api.api = AsyncMock()
331+
api.core_api = AsyncMock()
332+
333+
# Mock client response
334+
client_dict = {
335+
"apiVersion": "jumpstarter.dev/v1alpha1",
336+
"kind": "Client",
337+
"metadata": {
338+
"creationTimestamp": "2021-10-01T00:00:00Z",
339+
"generation": 1,
340+
"name": "test-client",
341+
"namespace": "test-namespace",
342+
"resourceVersion": "1",
343+
"uid": "test-uid",
344+
},
345+
"status": {
346+
"credential": {"name": "test-secret"},
347+
"endpoint": "https://test-endpoint:8082",
348+
},
349+
}
350+
api.api.get_namespaced_custom_object = AsyncMock(return_value=client_dict)
351+
352+
# Mock secret with token
353+
token = "test-token-value"
354+
mock_secret = V1Secret(data={"token": base64.b64encode(token.encode()).decode()})
355+
api.core_api.read_namespaced_secret = AsyncMock(return_value=mock_secret)
356+
357+
# Mock ConfigMap with CA certificate
358+
ca_cert_pem = "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----"
359+
mock_configmap = V1ConfigMap(data={"ca.crt": ca_cert_pem})
360+
api.core_api.read_namespaced_config_map = AsyncMock(return_value=mock_configmap)
361+
362+
config = await api.get_client_config("test-client", allow=[], unsafe=False)
363+
364+
# Verify CA bundle is included and base64-encoded
365+
expected_ca = base64.b64encode(ca_cert_pem.encode("utf-8")).decode("utf-8")
366+
assert config.tls.ca == expected_ca
367+
assert config.endpoint == "https://test-endpoint:8082"
368+
assert config.token == token
369+
370+
371+
@pytest.mark.asyncio
372+
async def test_get_client_config_without_ca_bundle():
373+
"""Test get_client_config works when CA ConfigMap doesn't exist"""
374+
api = ClientsV1Alpha1Api(namespace="test-namespace")
375+
api.api = AsyncMock()
376+
api.core_api = AsyncMock()
377+
378+
# Mock client response
379+
client_dict = {
380+
"apiVersion": "jumpstarter.dev/v1alpha1",
381+
"kind": "Client",
382+
"metadata": {
383+
"creationTimestamp": "2021-10-01T00:00:00Z",
384+
"generation": 1,
385+
"name": "test-client",
386+
"namespace": "test-namespace",
387+
"resourceVersion": "1",
388+
"uid": "test-uid",
389+
},
390+
"status": {
391+
"credential": {"name": "test-secret"},
392+
"endpoint": "https://test-endpoint:8082",
393+
},
394+
}
395+
api.api.get_namespaced_custom_object = AsyncMock(return_value=client_dict)
396+
397+
# Mock secret with token
398+
token = "test-token-value"
399+
mock_secret = V1Secret(data={"token": base64.b64encode(token.encode()).decode()})
400+
api.core_api.read_namespaced_secret = AsyncMock(return_value=mock_secret)
401+
402+
# Mock ConfigMap not found
403+
api.core_api.read_namespaced_config_map = AsyncMock(
404+
side_effect=ApiException(status=404, reason="Not Found")
405+
)
406+
407+
config = await api.get_client_config("test-client", allow=[], unsafe=False)
408+
409+
# Verify CA bundle is empty
410+
assert config.tls.ca == ""
411+
assert config.endpoint == "https://test-endpoint:8082"
412+
assert config.token == token

python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .util import AbstractAsyncCustomObjectApi
1313
from jumpstarter.config.common import ObjectMeta
1414
from jumpstarter.config.exporter import ExporterConfigV1Alpha1
15+
from jumpstarter.config.tls import TLSConfigV1Alpha1
1516

1617
CREATE_EXPORTER_DELAY = 1
1718
CREATE_EXPORTER_COUNT = 10
@@ -178,6 +179,8 @@ async def get_exporter_config(self, name: str) -> ExporterConfigV1Alpha1:
178179
secret = await self.core_api.read_namespaced_secret(exporter.status.credential.name, self.namespace)
179180
endpoint = exporter.status.endpoint
180181
token = base64.b64decode(secret.data["token"]).decode("utf8")
182+
# Get CA bundle from ConfigMap (base64-encoded)
183+
ca_bundle = await self.get_ca_bundle()
181184
return ExporterConfigV1Alpha1(
182185
alias=name,
183186
metadata=ObjectMeta(
@@ -187,6 +190,7 @@ async def get_exporter_config(self, name: str) -> ExporterConfigV1Alpha1:
187190
endpoint=endpoint,
188191
token=token,
189192
export={},
193+
tls=TLSConfigV1Alpha1(ca=ca_bundle),
190194
)
191195

192196
async def delete_exporter(self, name: str):

0 commit comments

Comments
 (0)