Files
magnum-capi-helm/magnum_capi_helm/tests/test_kubernetes.py
Matthew Northcott 1fe3fe44c0 Support credential API
Implements the credential rotation feature introduced in Magnum. The
naming scheme of application credentials created has been changed to
include a nonce value to allow validation of the new credential before
deletion of the old one. Existing app credentials are now identified
by decoding their ID from the corresponding secret in the active
cluster.

Change-Id: Ibd01e145af498c4b2a8e38fb0faf48f36da0ab98
Signed-off-by: Matthew Northcott <matthewnorthcott@catalystcloud.nz>
2025-08-29 15:13:12 +12:00

550 lines
18 KiB
Python

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import base64
import os
import pathlib
import tempfile
from unittest import mock
import yaml
import requests
from magnum_capi_helm import kubernetes
from magnum_capi_helm.tests import base
TEST_SERVER = "https://test:6443"
TEST_KUBECONFIG_YAML = f"""\
apiVersion: v1
clusters:
- cluster:
certificate-authority: "cafile"
server: {TEST_SERVER}
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
users:
- name: default
user:
client-certificate: "certfile"
client-key: "keyfile"
"""
TEST_KUBECONFIG = yaml.safe_load(TEST_KUBECONFIG_YAML)
class TestKubernetesClient(base.TestCase):
# Basic lookup, non "-data" key
def test_file_or_data(self):
client = kubernetes.Client(TEST_KUBECONFIG)
data = client.ensure_file_cert(dict(key="mydata"), "key")
self.assertEqual("mydata", data)
# Lookup with a "-data" key, requiring temporary file
@mock.patch.object(tempfile, "NamedTemporaryFile")
def test_file_or_data_create_temp(self, mock_temp):
client = kubernetes.Client(TEST_KUBECONFIG)
data = client.ensure_file_cert(
{"key-data": base64.b64encode(b"mydata").decode("utf-8")}, "key"
)
mock_temp.assert_has_calls(
[
mock.call(delete=False),
mock.call().__enter__(),
mock.call().__enter__().write(b"mydata"),
mock.call().__exit__(None, None, None),
]
)
self.assertEqual(mock_temp().__enter__().name, data)
# Lookup with no key, expecting no error, and no data returned.
def test_file_or_data_missing(self):
client = kubernetes.Client(TEST_KUBECONFIG)
data = client.ensure_file_cert(dict(), "key")
self.assertIsNone(data)
def test_client_constructor(self):
client = kubernetes.Client(TEST_KUBECONFIG)
self.assertEqual(TEST_SERVER, client.server)
self.assertEqual("cafile", client.verify)
self.assertEqual(("certfile", "keyfile"), client.cert)
@mock.patch.object(tempfile, "NamedTemporaryFile")
@mock.patch.object(os, "remove")
def test_client_certificate_finalizer(self, mock_remove, mock_temp):
kubeconfig = yaml.safe_load(TEST_KUBECONFIG_YAML)
# Set -data in base64 to create tmp files.
del kubeconfig["users"][0]["user"]["client-certificate"]
kubeconfig["users"][0]["user"]["client-certificate-data"] = (
base64.b64encode(b"client cert data").decode("utf-8")
)
client = kubernetes.Client(kubeconfig)
# Ensure a temporary file was created
mock_temp.assert_has_calls(
[
mock.call(delete=False),
mock.call().__enter__(),
mock.call().__enter__().write(b"client cert data"),
mock.call().__exit__(None, None, None),
]
)
# Call finalizer method directly
client.__del__()
# Exactly one temp file should be cleaned up
mock_remove.assert_called_once()
def test_get_kubeconfig_path_default(self):
self.assertEqual(
pathlib.Path.home() / ".kube" / "config",
kubernetes.Client._get_kubeconfig_path(),
)
@mock.patch.object(kubernetes.CONF, "capi_helm")
def test_get_kubeconfig_path_config(self, mock_conf):
mock_conf.kubeconfig_file = "foo"
os.environ["KUBECONFIG"] = "bar"
path = kubernetes.Client._get_kubeconfig_path()
del os.environ["KUBECONFIG"]
self.assertEqual("foo", path)
def test_get_kubeconfig_path_env(self):
os.environ["KUBECONFIG"] = "bar"
path = kubernetes.Client._get_kubeconfig_path()
del os.environ["KUBECONFIG"]
self.assertEqual("bar", path)
@mock.patch.object(kubernetes.CONF, "capi_helm")
@mock.patch(
"builtins.open",
new_callable=mock.mock_open,
read_data=TEST_KUBECONFIG_YAML,
)
def test_client_load(self, mock_open, mock_conf):
mock_conf.kubeconfig_file = "mypath"
client = kubernetes.Client.load()
self.assertEqual(TEST_SERVER, client.server)
mock_open.assert_called_once_with("mypath")
@mock.patch.object(requests.Session, "request")
def test_ensure_namespace(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
client.ensure_namespace("namespace1")
mock_request.assert_called_once_with(
"PATCH",
"https://test:6443/api/v1/namespaces/namespace1",
data=None,
json={
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": "namespace1"},
},
headers={"Content-Type": "application/apply-patch+yaml"},
params={"fieldManager": "magnum", "force": "true"},
)
@mock.patch.object(requests.Session, "request")
def test_apply_secret(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
test_data = dict(
stringData=dict(foo="bar"), metadata=dict(labels=dict(baz="asdf"))
)
client.apply_secret("secname", test_data, "ns1")
mock_request.assert_called_once_with(
"PATCH",
"https://test:6443/api/v1/namespaces/ns1/secrets/secname",
data=None,
json={
"stringData": {"foo": "bar"},
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"labels": {"baz": "asdf"},
"name": "secname",
"namespace": "ns1",
},
},
headers={"Content-Type": "application/apply-patch+yaml"},
params={"fieldManager": "magnum", "force": "true"},
)
@mock.patch.object(requests.Session, "request")
def test_delete_all_secrets_by_label(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_request.return_value = mock_response
client.delete_all_secrets_by_label("label", "cluster1", "ns1")
mock_request.assert_called_once_with(
"DELETE",
"https://test:6443/api/v1/namespaces/ns1/secrets",
params={"labelSelector": "label=cluster1"},
)
mock_response.raise_for_status.assert_called_once_with()
@mock.patch.object(requests.Session, "request")
def test_get_secret(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_request.return_value = mock_response
secret_name = "secret1"
secret_namespace = "ns1"
client.get_secret(secret_name, secret_namespace)
mock_request.assert_called_once_with(
"GET",
"https://test:6443/api/v1/namespaces"
f"/{secret_namespace}/secrets/{secret_name}",
allow_redirects=True,
)
@mock.patch.object(requests.Session, "request")
def test_get_secret_value(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_request.return_value = mock_response
secret_name = "secret1"
secret_namespace = "ns1"
secret_key = "mykey"
secret_value = "mysecretvalue"
mock_response.json.return_value = {
"data": {
secret_key: base64.b64encode(secret_value.encode()).decode(),
}
}
self.assertEqual(
client.get_secret_value(secret_name, secret_namespace, secret_key),
secret_value,
)
mock_request.assert_called_once_with(
"GET",
"https://test:6443/api/v1/namespaces"
f"/{secret_namespace}/secrets/{secret_name}",
allow_redirects=True,
)
@mock.patch.object(requests.Session, "request")
def test_get_capi_cluster_found(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = "mock_json"
mock_request.return_value = mock_response
cluster = client.get_capi_cluster("name", "ns1")
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/cluster.x-k8s.io/"
"v1beta1/namespaces/ns1/clusters/name"
),
allow_redirects=True,
)
self.assertEqual("mock_json", cluster)
@mock.patch.object(requests.Session, "request")
def test_get_capi_cluster_not_found(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 404
mock_request.return_value = mock_response
cluster = client.get_capi_cluster("name", "ns1")
self.assertIsNone(cluster)
@mock.patch.object(requests.Session, "request")
def test_get_capi_cluster_error(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = requests.HTTPError
mock_request.return_value = mock_response
self.assertRaises(
requests.HTTPError, client.get_capi_cluster, "name", "ns1"
)
@mock.patch.object(requests.Session, "request")
def test_get_kubeadm_control_plane_found(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = "mock_json"
mock_request.return_value = mock_response
cluster = client.get_kubeadm_control_plane("name", "ns1")
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/controlplane.cluster.x-k8s.io/"
"v1beta1/namespaces/ns1/kubeadmcontrolplanes/name"
),
allow_redirects=True,
)
self.assertEqual("mock_json", cluster)
@mock.patch.object(requests.Session, "request")
def test_get_machine_deployment_found(self, mock_request):
client = kubernetes.Client(TEST_KUBECONFIG)
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = "mock_json"
mock_request.return_value = mock_response
cluster = client.get_machine_deployment("name", "ns1")
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/cluster.x-k8s.io/"
"v1beta1/namespaces/ns1/machinedeployments/name"
),
allow_redirects=True,
)
self.assertEqual("mock_json", cluster)
@mock.patch.object(requests.Session, "request")
def test_get_manifests_by_label(self, mock_request):
items = [
{
"kind": "Manifests",
"metadata": {"name": f"manifests{idx}", "namespace": "ns1"},
}
for idx in range(5)
]
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"metadata": {
"continue": "",
},
"items": items,
}
mock_request.return_value = mock_response
client = kubernetes.Client(TEST_KUBECONFIG)
manifests = client.get_manifests_by_label({"label": "cluster1"}, "ns1")
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/addons.stackhpc.com/"
"v1alpha1/namespaces/ns1/manifests"
),
params={"labelSelector": "label=cluster1"},
allow_redirects=True,
)
self.assertEqual(items, manifests)
@mock.patch.object(requests.Session, "request")
def test_get_helm_releases_by_label(self, mock_request):
items = [
{
"kind": "HelmRelease",
"metadata": {"name": f"helmrelease{idx}", "namespace": "ns1"},
}
for idx in range(5)
]
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"metadata": {
"continue": "",
},
"items": items,
}
mock_request.return_value = mock_response
client = kubernetes.Client(TEST_KUBECONFIG)
helm_releases = client.get_helm_releases_by_label(
{"label": "cluster1"}, "ns1"
)
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/addons.stackhpc.com/"
"v1alpha1/namespaces/ns1/helmreleases"
),
params={"labelSelector": "label=cluster1"},
allow_redirects=True,
)
self.assertEqual(items, helm_releases)
@mock.patch.object(requests.Session, "request")
def test_get_helm_releases_by_label_multipage(self, mock_request):
items = [
{
"kind": "HelmRelease",
"metadata": {"name": f"helmrelease{idx}", "namespace": "ns1"},
}
for idx in range(10)
]
mock_response_page1 = mock.Mock()
mock_response_page1.raise_for_status.return_value = None
mock_response_page1.json.return_value = {
"metadata": {
"continue": "continuetoken",
},
"items": items[:5],
}
mock_response_page2 = mock.Mock()
mock_response_page2.raise_for_status.return_value = None
mock_response_page2.json.return_value = {
"metadata": {
"continue": "",
},
"items": items[5:],
}
mock_request.side_effect = [
mock_response_page1,
mock_response_page2,
]
client = kubernetes.Client(TEST_KUBECONFIG)
helm_releases = client.get_helm_releases_by_label(
{"label": "cluster1"}, "ns1"
)
self.assertEqual(mock_request.call_count, 2)
mock_request.assert_has_calls(
[
mock.call(
"GET",
(
"https://test:6443/apis/addons.stackhpc.com/"
"v1alpha1/namespaces/ns1/helmreleases"
),
params={"labelSelector": "label=cluster1"},
allow_redirects=True,
),
mock.call(
"GET",
(
"https://test:6443/apis/addons.stackhpc.com/"
"v1alpha1/namespaces/ns1/helmreleases"
),
params={
"labelSelector": "label=cluster1",
"continue": "continuetoken",
},
allow_redirects=True,
),
]
)
self.assertEqual(items, helm_releases)
@mock.patch.object(kubernetes.Client, "get_helm_releases_by_label")
@mock.patch.object(kubernetes.Client, "get_manifests_by_label")
def test_get_addons_by_label(
self, mock_get_manifests, mock_get_helm_releases
):
manifests = [
{
"kind": "Manifests",
"metadata": {"name": f"manifests{idx}", "namespace": "ns1"},
}
for idx in range(5)
]
helm_releases = [
{
"kind": "HelmRelease",
"metadata": {"name": f"helmrelease{idx}", "namespace": "ns1"},
}
for idx in range(5)
]
mock_get_manifests.return_value = manifests
mock_get_helm_releases.return_value = helm_releases
client = kubernetes.Client(TEST_KUBECONFIG)
addons = client.get_addons_by_label({"label": "cluster1"}, "ns1")
mock_get_manifests.assert_called_once_with(
{"label": "cluster1"}, "ns1"
)
mock_get_helm_releases.assert_called_once_with(
{"label": "cluster1"}, "ns1"
)
self.assertEqual(manifests + helm_releases, addons)
@mock.patch.object(requests.Session, "request")
def test_get_all_machines_by_label(self, mock_request):
items = [
{
"kind": "Machine",
"metadata": {"name": f"machine{idx}", "namespace": "ns1"},
}
for idx in range(5)
]
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"metadata": {
"continue": "",
},
"items": items,
}
mock_request.return_value = mock_response
client = kubernetes.Client(TEST_KUBECONFIG)
machines = client.get_all_machines_by_label(
{"capi.stackhpc.com/cluster": "cluster_name", "foo": "bar"}, "ns1"
)
mock_request.assert_called_once_with(
"GET",
(
"https://test:6443/apis/cluster.x-k8s.io/"
"v1beta1/namespaces/ns1/machines"
),
params={
"labelSelector": (
"capi.stackhpc.com/cluster=cluster_name,foo=bar"
)
},
allow_redirects=True,
)
self.assertEqual(items, machines)