diff --git a/.zuul.d/secure.yaml b/.zuul.d/secure.yaml index 95b331ba7..f50e98292 100644 --- a/.zuul.d/secure.yaml +++ b/.zuul.d/secure.yaml @@ -426,6 +426,37 @@ Iek4Bf2Vsh3IcXe4WPgooBm9y0wejEmRVwApwcsArxILaa/VUJQO6zVeaLKTGTBNVKolm pP2x748l+uFWMUGjmzao4OTyfjBKx2cXNqe/OmIyscCr7lDZkipFdDTWrR1p/FLHQiCIc jBsgwuDMuVcVslw+cy3CPiPKZNWSJLop5cIvCEzGPBzXto9YTVQwPwnmcl+Rnc= + teuto_mgmt_token: !encrypted/pkcs1-oaep + - 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 noris_token: !encrypted/pkcs1-oaep - FeRLNW8L6vSdwjxmxWNJ1auG7tEGCutVdq58tN4fabgDBGqGOf8DVjwZy8Q7J/pP1cCf/ EwVZXwgS9obpFDtl2vAswAZVAPF6bZeD3u9MD7g3s+3Kyp6sYkg66g+KTdOthMS/dIUzM diff --git a/Tests/config.toml b/Tests/config.toml index d09cbe9e7..48aedbf3f 100644 --- a/Tests/config.toml +++ b/Tests/config.toml @@ -45,6 +45,8 @@ subjects = [ "scaleup-1.35", "syself-1.34", "syself-1.35", + "teuto-1.35", + "teuto-1.36", ] workers = 16 @@ -54,7 +56,7 @@ scopes = [ "scs-compatible-kaas", ] subjects = [ - # add 1.36 subjects here + "teuto-1.36", ] workers = 4 @@ -79,6 +81,7 @@ subjects = [ "noris-1.35", "scaleup-1.35", "syself-1.35", + "teuto-1.35", ] workers = 4 diff --git a/Tests/kaas/plugin/conftest.py b/Tests/kaas/plugin/conftest.py new file mode 100644 index 000000000..306f2d40c --- /dev/null +++ b/Tests/kaas/plugin/conftest.py @@ -0,0 +1,4 @@ +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/Tests/kaas/plugin/cs_helper.py b/Tests/kaas/plugin/cs_helper.py index 20880615d..61ddb8976 100644 --- a/Tests/kaas/plugin/cs_helper.py +++ b/Tests/kaas/plugin/cs_helper.py @@ -1,20 +1,7 @@ """helper functions for ClusterStacks plugin""" import base64 -import os -from kubernetes.client import Configuration, CoreV1Api, CustomObjectsApi - - -def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'): - """transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s""" - token = kubeconfig['users'][0]['user']['token'] - client_config.api_key['authorization'] = 'Bearer {}'.format(token) - client_config.host = kubeconfig['clusters'][0]['cluster']['server'] - client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt')) - with open(client_config.ssl_ca_cert, "wb") as fileobj: - fileobj.write(base64.standard_b64decode( - kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode() - )) +from kubernetes.client import CoreV1Api, CustomObjectsApi def create_cr(api_instance: CustomObjectsApi, namespace, resource_dict): diff --git a/Tests/kaas/plugin/gardener_helper.py b/Tests/kaas/plugin/gardener_helper.py index edd8f245d..224123fc8 100644 --- a/Tests/kaas/plugin/gardener_helper.py +++ b/Tests/kaas/plugin/gardener_helper.py @@ -1,9 +1,8 @@ """helper functions for Gardener plugin""" import base64 import json -import os -from kubernetes.client import Configuration, CustomObjectsApi, ApiClient +from kubernetes.client import CustomObjectsApi, ApiClient GARDENER_GROUP = 'core.gardener.cloud' @@ -11,18 +10,6 @@ GARDENER_PLURAL = 'shoots' -def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'): - """transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s""" - token = kubeconfig['users'][0]['user']['token'] - client_config.api_key['authorization'] = 'Bearer {}'.format(token) - client_config.host = kubeconfig['clusters'][0]['cluster']['server'] - client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt')) - with open(client_config.ssl_ca_cert, "wb") as fileobj: - fileobj.write(base64.standard_b64decode( - kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode() - )) - - def get_cloudprofile(api_instance: CustomObjectsApi, namespace, name): """mimic `kubectl get cloudprofile`""" return api_instance.get_cluster_custom_object( diff --git a/Tests/kaas/plugin/k8s_helper.py b/Tests/kaas/plugin/k8s_helper.py new file mode 100644 index 000000000..befcd1cab --- /dev/null +++ b/Tests/kaas/plugin/k8s_helper.py @@ -0,0 +1,17 @@ +"""helper functions shared by the Kubernetes-based cluster plugins""" +import base64 +import os + +from kubernetes.client import Configuration + + +def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'): + """transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s""" + token = kubeconfig['users'][0]['user']['token'] + client_config.api_key['authorization'] = 'Bearer {}'.format(token) + client_config.host = kubeconfig['clusters'][0]['cluster']['server'] + client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt')) + with open(client_config.ssl_ca_cert, "wb") as fileobj: + fileobj.write(base64.standard_b64decode( + kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode() + )) diff --git a/Tests/kaas/plugin/plugin_clusterstacks.py b/Tests/kaas/plugin/plugin_clusterstacks.py index ad29a6c20..286a90323 100644 --- a/Tests/kaas/plugin/plugin_clusterstacks.py +++ b/Tests/kaas/plugin/plugin_clusterstacks.py @@ -4,11 +4,12 @@ import time from jinja2 import Environment -from kubernetes.client import ApiClient, ApiException +from kubernetes.client import ApiClient, ApiException, Configuration import yaml from interface import KubernetesClusterPlugin import cs_helper as _csh +import k8s_helper logger = logging.getLogger(__name__) logging.getLogger("kubernetes").setLevel(logging.INFO) @@ -147,8 +148,8 @@ def __init__(self, config, basepath='.', cwd='.'): self.vars['name'] = self.config['name'] self.secrets = self.config['secrets'] self.kubeconfig = yaml.load(self._render_template('kubeconfig'), Loader=yaml.SafeLoader) - self.client_config = _csh.Configuration() - _csh.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd) + self.client_config = Configuration() + k8s_helper.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd) self.namespace = self.kubeconfig['contexts'][0]['context']['namespace'] def _render_template(self, key): diff --git a/Tests/kaas/plugin/plugin_gardener.py b/Tests/kaas/plugin/plugin_gardener.py index effc0b588..7cc40fe58 100644 --- a/Tests/kaas/plugin/plugin_gardener.py +++ b/Tests/kaas/plugin/plugin_gardener.py @@ -4,11 +4,12 @@ import time from jinja2 import Environment, StrictUndefined -from kubernetes.client import ApiClient, ApiException +from kubernetes.client import ApiClient, ApiException, Configuration import yaml from interface import KubernetesClusterPlugin import gardener_helper as _gh +import k8s_helper logger = logging.getLogger(__name__) logging.getLogger("kubernetes").setLevel(logging.INFO) @@ -157,8 +158,8 @@ def __init__(self, plugin_config, basepath='.', cwd='.', name=None): self.vars['name'] = self.config['name'] self.secrets = self.config['secrets'] self.kubeconfig = yaml.load(self._render_template('kubeconfig'), Loader=yaml.SafeLoader) - self.client_config = _gh.Configuration() - _gh.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd) + self.client_config = Configuration() + k8s_helper.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd) self.namespace = self.kubeconfig['contexts'][0]['context']['namespace'] def _render_template(self, key): diff --git a/Tests/kaas/plugin/plugin_t8s.py b/Tests/kaas/plugin/plugin_t8s.py new file mode 100644 index 000000000..ed1638ef1 --- /dev/null +++ b/Tests/kaas/plugin/plugin_t8s.py @@ -0,0 +1,212 @@ +import base64 +import logging +import os +import os.path +import time +from typing import Any, cast + +from jinja2 import Environment, Template +from kubernetes.client import ApiClient, ApiException, Configuration, CoreV1Api, CustomObjectsApi, V1Secret +import yaml + +from interface import KubernetesClusterPlugin +import k8s_helper + +logger = logging.getLogger(__name__) +logging.getLogger("kubernetes").setLevel(logging.INFO) + + +TIMEOUTS = [30] * 60 + [0] # wait at most 30 minutes; sentinel value at the end + +HR_GROUP = "helm.toolkit.fluxcd.io" +HR_VERSION = "v2" +HR_PLURAL = "helmreleases" + + +class PluginT8s(KubernetesClusterPlugin): + """ + Plugin to provision Kubernetes clusters on the t8s (teuto k8s) management cluster + via a Flux HelmRelease. + + Creates a HelmRelease on mgmt-bfe2-prod and waits for the resulting cluster's + kubeconfig to appear in the secret '-kubeconfig'. + + Expected config keys: + kubernetesVersion: '1.35' (major.minor) + version_patch: 2 (patch component of the k8s version) + templates: + kubeconfig: t8s-kubeconfig.yaml (management cluster kubeconfig template) + secrets: + token: '{{ clouds_conf.teuto_mgmt_token }}' + name: 'scs-kaas-certification' (optional, defaults to 'scs-kaas-certification'; + the kubeconfig secret name is derived as '-kubeconfig') + cloud: 'bfe2-prod' (optional, defaults to 'bfe2-prod') + controlPlaneHosted: true (optional, defaults to true) + nodePools: (optional, defaults to a single 'pool-0') + pool-0: + flavor: 'standard.2.1905' + replicas: 2 + metadata: (optional; individual keys default as shown) + customerID: 1111 + customerName: 'teuto.net Netzdienste GmbH' + friendlyName: 'scs-kaas-certification' + serviceLevelAgreement: 'None' + """ + + def __init__(self, config: dict[str, Any], basepath: str = ".", cwd: str = ".") -> None: + self.basepath = basepath + self.cwd = cwd + self.env = Environment() + + # All of these are mandated by the ValidatingAdmissionPolicy on the management cluster + # and cannot be changed without updating both the VAP and the RBAC. They are instance + # (rather than module-level) attributes because, in principle, different certification + # targets could be configured with different values here. + self.hr_name = config.get("name", "scs-kaas-certification") + self.hr_namespace = "scs-kaas-certification" + self.hr_kubeconfig_secret = f"{self.hr_name}-kubeconfig" + self.hr_chart_ref: dict[str, str] = { + "kind": "HelmChart", + "name": "scs-kaas-certification", + "namespace": "flux-system", + } + metadata_config: dict[str, Any] = config.get("metadata", {}) + self.hr_values_fixed: dict[str, Any] = { + "cloud": config.get("cloud", "bfe2-prod"), + "controlPlane": {"hosted": config.get("controlPlaneHosted", True)}, + "metadata": { + "customerID": metadata_config.get("customerID", 1111), + "customerName": metadata_config.get("customerName", "teuto.net Netzdienste GmbH"), + "friendlyName": metadata_config.get("friendlyName", "scs-kaas-certification"), + "serviceLevelAgreement": metadata_config.get("serviceLevelAgreement", "None"), + }, + "nodePools": config.get("nodePools", { + "pool-0": {"flavor": "standard.2.1905", "replicas": 2}, + }), + } + + fn: str = config["templates"]["kubeconfig"] + with open(os.path.join(basepath, fn), "r") as f: + self.kubeconfig_template: Template = self.env.from_string(f.read()) + self.secrets: dict[str, Any] = config.get("secrets", {}) + + major, minor = (int(x) for x in config["kubernetesVersion"].split(".")) + self.k8s_version: dict[str, int] = { + "major": major, + "minor": minor, + "patch": int(config.get("version_patch", 0)), + } + + kubeconfig = cast(dict[str, Any], yaml.load( + self.kubeconfig_template.render(**self.secrets), Loader=yaml.SafeLoader + )) + self.client_config: Configuration = Configuration() + k8s_helper.setup_client_config(self.client_config, kubeconfig, cwd=self.cwd) + + def _build_helmrelease(self) -> dict[str, Any]: + return { + "apiVersion": f"{HR_GROUP}/{HR_VERSION}", + "kind": "HelmRelease", + "metadata": {"name": self.hr_name, "namespace": self.hr_namespace}, + "spec": { + "chartRef": self.hr_chart_ref, + "driftDetection": {"mode": "enabled"}, + "interval": "1m", + "values": {**self.hr_values_fixed, "version": self.k8s_version}, + }, + } + + def _delete_helmrelease(self, co_api: CustomObjectsApi) -> None: + try: + co_api.delete_namespaced_custom_object( + HR_GROUP, + HR_VERSION, + self.hr_namespace, + HR_PLURAL, + self.hr_name, + ) + except ApiException as e: + if e.status == 404: + logger.debug(f"HelmRelease {self.hr_name} not present, nothing to delete") + return + raise + # Give Flux time to begin deletion before we try to recreate + logger.debug(f"HelmRelease {self.hr_name} deletion requested; waiting 30s") + time.sleep(30) + + def _apply_helmrelease(self, api_client: ApiClient) -> None: + """Server-side apply the HelmRelease (creates or updates). + + Uses PATCH with application/apply-patch+yaml so the resource name is + part of the URL — meaning resourceNames RBAC constraints are evaluated + correctly, unlike POST (create) where the name is only in the body. + """ + hr = self._build_helmrelease() + api_client.call_api( + '/apis/{group}/{version}/namespaces/{namespace}/{plural}/{name}', + 'PATCH', + path_params={ + 'group': HR_GROUP, + 'version': HR_VERSION, + 'namespace': self.hr_namespace, + 'plural': HR_PLURAL, + 'name': self.hr_name, + }, + query_params=[('fieldManager', 'plugin_t8s'), ('force', 'true')], + header_params={ + 'Accept': 'application/json', + 'Content-Type': 'application/apply-patch+yaml', + }, + body=hr, + post_params=[], + files={}, + response_type='object', + auth_settings=['BearerToken'], + _return_http_data_only=True, + ) + logger.debug(f"HelmRelease {self.hr_name} applied") + + def _get_kubeconfig_from_secret(self, core_api: CoreV1Api) -> bytes: + secret = cast(V1Secret, core_api.read_namespaced_secret(self.hr_kubeconfig_secret, self.hr_namespace)) + data: dict[str, str] = secret.data or {} + if "value" in data: + return base64.standard_b64decode(data["value"].encode()) + raise RuntimeError( + f"kubeconfig secret {self.hr_kubeconfig_secret} is missing key 'value'; has keys: {list(data)}" + ) + + def _wait_for_kubeconfig_secret(self, core_api: CoreV1Api) -> bytes: + timeouts = iter(TIMEOUTS) + while True: + try: + return self._get_kubeconfig_from_secret(core_api) + except ApiException as e: + if e.status != 404: + raise + timeout = next(timeouts) + if not timeout: + raise RuntimeError( + f"Timeout waiting for kubeconfig secret {self.hr_kubeconfig_secret}" + ) + logger.debug( + f"waiting {timeout}s for kubeconfig secret {self.hr_kubeconfig_secret}" + ) + time.sleep(timeout) + + def _write_kubeconfig(self, data: bytes) -> None: + path = os.path.join(self.cwd, "kubeconfig.yaml") + logger.debug(f"writing {path}") + with open(path, "wb") as f: + f.write(data) + + def create_cluster(self) -> None: + with ApiClient(self.client_config) as api_client: + core_api = CoreV1Api(api_client) + self._apply_helmrelease(api_client) + kubeconfig = self._wait_for_kubeconfig_secret(core_api) + self._write_kubeconfig(kubeconfig) + + def delete_cluster(self) -> None: + with ApiClient(self.client_config) as api_client: + co_api = CustomObjectsApi(api_client) + self._delete_helmrelease(co_api) diff --git a/Tests/kaas/plugin/run_plugin.py b/Tests/kaas/plugin/run_plugin.py index f185add72..02a249132 100755 --- a/Tests/kaas/plugin/run_plugin.py +++ b/Tests/kaas/plugin/run_plugin.py @@ -9,6 +9,7 @@ from plugin_gardener import PluginGardener from plugin_kind import PluginKind from plugin_static import PluginStatic +from plugin_t8s import PluginT8s PLUGIN_LOOKUP = { @@ -16,6 +17,7 @@ "gardener": PluginGardener, "kind": PluginKind, "static": PluginStatic, + "t8s": PluginT8s, } BASEPATH = os.path.join(os.path.expanduser('~'), '.config', 'scs') diff --git a/Tests/kaas/plugin/test_plugin_t8s.py b/Tests/kaas/plugin/test_plugin_t8s.py new file mode 100644 index 000000000..2aaf973a6 --- /dev/null +++ b/Tests/kaas/plugin/test_plugin_t8s.py @@ -0,0 +1,274 @@ +import base64 +import os +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from kubernetes.client import ApiException, V1Secret + +from plugin_t8s import ( + HR_GROUP, + HR_VERSION, + PluginT8s, +) + + +MINIMAL_KUBECONFIG = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [{"cluster": { + "certificate-authority-data": base64.b64encode(b"fake-ca").decode(), + "server": "https://test:6443", + }, "name": "test"}], + "contexts": [{"context": {"cluster": "test", "namespace": "scs-kaas-certification", "user": "test"}, "name": "default"}], + "current-context": "default", + "users": [{"name": "test", "user": {"token": "test-token"}}], +} + +KUBECONFIG_TEMPLATE = yaml.dump(MINIMAL_KUBECONFIG) + + +@pytest.fixture +def plugin(tmp_path): + tpl = tmp_path / "t8s-kubeconfig.yaml" + tpl.write_text(KUBECONFIG_TEMPLATE) + config = { + "kubernetesVersion": "1.35", + "version_patch": 2, + "templates": {"kubeconfig": tpl.name}, + "secrets": {}, + } + with patch("k8s_helper.setup_client_config"): + return PluginT8s(config, basepath=str(tmp_path), cwd=str(tmp_path)) + + +# --- _build_helmrelease --- + +def test_helmrelease_apiversion(plugin): + hr = plugin._build_helmrelease() + assert hr["apiVersion"] == f"{HR_GROUP}/{HR_VERSION}" + + +def test_helmrelease_kind(plugin): + hr = plugin._build_helmrelease() + assert hr["kind"] == "HelmRelease" + + +def test_helmrelease_name_namespace(plugin): + hr = plugin._build_helmrelease() + assert hr["metadata"]["name"] == plugin.hr_name + assert hr["metadata"]["namespace"] == plugin.hr_namespace + + +def test_helmrelease_chartref(plugin): + hr = plugin._build_helmrelease() + assert hr["spec"]["chartRef"] == plugin.hr_chart_ref + assert hr["spec"]["chartRef"]["kind"] == "HelmChart" + assert hr["spec"]["chartRef"]["name"] == "scs-kaas-certification" + assert hr["spec"]["chartRef"]["namespace"] == "flux-system" + + +def test_helmrelease_drift_detection(plugin): + hr = plugin._build_helmrelease() + assert hr["spec"]["driftDetection"]["mode"] == "enabled" + + +def test_helmrelease_interval(plugin): + hr = plugin._build_helmrelease() + assert hr["spec"]["interval"] == "1m" + + +def test_helmrelease_values_cloud(plugin): + assert plugin._build_helmrelease()["spec"]["values"]["cloud"] == "bfe2-prod" + + +def test_helmrelease_values_control_plane(plugin): + assert plugin._build_helmrelease()["spec"]["values"]["controlPlane"]["hosted"] is True + + +def test_helmrelease_values_metadata(plugin): + meta = plugin._build_helmrelease()["spec"]["values"]["metadata"] + assert meta["customerID"] == 1111 + assert meta["customerName"] == "teuto.net Netzdienste GmbH" + assert meta["friendlyName"] == "scs-kaas-certification" + assert meta["serviceLevelAgreement"] == "None" + + +def test_helmrelease_values_nodepool(plugin): + pool = plugin._build_helmrelease()["spec"]["values"]["nodePools"]["pool-0"] + assert pool["flavor"] == "standard.2.1905" + assert pool["replicas"] == 2 + + +def test_helmrelease_values_version_135(plugin): + v = plugin._build_helmrelease()["spec"]["values"]["version"] + assert v == {"major": 1, "minor": 35, "patch": 2} + + +def test_helmrelease_values_no_extra_keys(plugin): + allowed = {"cloud", "controlPlane", "metadata", "nodePools", "version"} + assert set(plugin._build_helmrelease()["spec"]["values"].keys()) == allowed + + +def test_helmrelease_metadata_no_extra_keys(plugin): + allowed = {"customerID", "customerName", "friendlyName", "serviceLevelAgreement"} + assert set(plugin._build_helmrelease()["spec"]["values"]["metadata"].keys()) == allowed + + +def test_helmrelease_nodepool_no_extra_keys(plugin): + assert set(plugin._build_helmrelease()["spec"]["values"]["nodePools"]["pool-0"].keys()) == {"flavor", "replicas"} + + +def test_helmrelease_version_no_extra_keys(plugin): + assert set(plugin._build_helmrelease()["spec"]["values"]["version"].keys()) == {"major", "minor", "patch"} + + +def test_helmrelease_single_nodepool(plugin): + nodepools = plugin._build_helmrelease()["spec"]["values"]["nodePools"] + assert len(nodepools) == 1 + assert "pool-0" in nodepools + + +def _make_plugin(tmp_path, **config_overrides): + tpl = tmp_path / "t8s-kubeconfig.yaml" + tpl.write_text(KUBECONFIG_TEMPLATE) + config = { + "kubernetesVersion": "1.35", + "version_patch": 2, + "templates": {"kubeconfig": tpl.name}, + "secrets": {}, + **config_overrides, + } + with patch("k8s_helper.setup_client_config"): + return PluginT8s(config, basepath=str(tmp_path), cwd=str(tmp_path)) + + +def test_helmrelease_values_from_config(tmp_path): + plugin = _make_plugin( + tmp_path, + cloud="other-cloud", + controlPlaneHosted=False, + nodePools={"pool-1": {"flavor": "custom.flavor", "replicas": 5}}, + metadata={ + "customerID": 2222, + "customerName": "Other Customer GmbH", + "friendlyName": "other-friendly-name", + "serviceLevelAgreement": "Gold", + }, + ) + values = plugin._build_helmrelease()["spec"]["values"] + assert values["cloud"] == "other-cloud" + assert values["controlPlane"]["hosted"] is False + assert values["nodePools"] == {"pool-1": {"flavor": "custom.flavor", "replicas": 5}} + assert values["metadata"] == { + "customerID": 2222, + "customerName": "Other Customer GmbH", + "friendlyName": "other-friendly-name", + "serviceLevelAgreement": "Gold", + } + + +def test_hr_name_defaults(plugin): + assert plugin.hr_name == "scs-kaas-certification" + assert plugin.hr_kubeconfig_secret == "scs-kaas-certification-kubeconfig" + + +def test_hr_name_from_config(tmp_path): + plugin = _make_plugin(tmp_path, name="other-target") + assert plugin.hr_name == "other-target" + assert plugin.hr_kubeconfig_secret == "other-target-kubeconfig" + + hr = plugin._build_helmrelease() + assert hr["metadata"]["name"] == "other-target" + + +# --- version parsing --- + +@pytest.mark.parametrize("version,version_patch,expected", [ + ("1.35", 2, {"major": 1, "minor": 35, "patch": 2}), + ("1.36", 0, {"major": 1, "minor": 36, "patch": 0}), + ("1.34", 10, {"major": 1, "minor": 34, "patch": 10}), +]) +def test_version_parsing(tmp_path, version, version_patch, expected): + tpl = tmp_path / "t8s-kubeconfig.yaml" + tpl.write_text(KUBECONFIG_TEMPLATE) + config = { + "kubernetesVersion": version, + "version_patch": version_patch, + "templates": {"kubeconfig": tpl.name}, + "secrets": {}, + } + with patch("k8s_helper.setup_client_config"): + p = PluginT8s(config, basepath=str(tmp_path), cwd=str(tmp_path)) + assert p.k8s_version == expected + + +def test_version_patch_defaults_to_zero(tmp_path): + tpl = tmp_path / "t8s-kubeconfig.yaml" + tpl.write_text(KUBECONFIG_TEMPLATE) + config = { + "kubernetesVersion": "1.35", + "templates": {"kubeconfig": tpl.name}, + "secrets": {}, + } + with patch("k8s_helper.setup_client_config"): + p = PluginT8s(config, basepath=str(tmp_path), cwd=str(tmp_path)) + assert p.k8s_version["patch"] == 0 + + +# --- _get_kubeconfig_from_secret --- + +def test_get_kubeconfig_from_secret(plugin): + raw = b"apiVersion: v1\nkind: Config\n" + secret = MagicMock(spec=V1Secret) + secret.data = {"value": base64.b64encode(raw).decode()} + core_api = MagicMock() + core_api.read_namespaced_secret.return_value = secret + assert plugin._get_kubeconfig_from_secret(core_api) == raw + core_api.read_namespaced_secret.assert_called_once_with(plugin.hr_kubeconfig_secret, plugin.hr_namespace) + + +def test_get_kubeconfig_from_secret_null_data(plugin): + secret = MagicMock(spec=V1Secret) + secret.data = None + core_api = MagicMock() + core_api.read_namespaced_secret.return_value = secret + with pytest.raises(RuntimeError, match="missing key 'value'"): + plugin._get_kubeconfig_from_secret(core_api) + + +def test_get_kubeconfig_from_secret_wrong_key(plugin): + secret = MagicMock(spec=V1Secret) + secret.data = {"kubeconfig": base64.b64encode(b"x").decode()} + core_api = MagicMock() + core_api.read_namespaced_secret.return_value = secret + with pytest.raises(RuntimeError, match="missing key 'value'"): + plugin._get_kubeconfig_from_secret(core_api) + + +# --- _wait_for_kubeconfig_secret --- + +def test_wait_retries_on_404(plugin): + raw = b"kubeconfig-data" + secret = MagicMock(spec=V1Secret) + secret.data = {"value": base64.b64encode(raw).decode()} + not_found = ApiException(status=404) + core_api = MagicMock() + core_api.read_namespaced_secret.side_effect = [not_found, not_found, secret] + + with patch("plugin_t8s.TIMEOUTS", [0.01, 0.01, 0]): + with patch("time.sleep"): + # _get_kubeconfig_from_secret is called, but read_namespaced_secret side_effect + # drives it — patch TIMEOUTS so we don't wait 30s + # Need to make the third call return the secret not raise + core_api.read_namespaced_secret.side_effect = [not_found, not_found, secret] + result = plugin._wait_for_kubeconfig_secret(core_api) + assert result == raw + + +def test_wait_raises_on_non_404(plugin): + core_api = MagicMock() + core_api.read_namespaced_secret.side_effect = ApiException(status=403) + with pytest.raises(ApiException): + plugin._wait_for_kubeconfig_secret(core_api) diff --git a/compliance-monitor/bootstrap.yaml b/compliance-monitor/bootstrap.yaml index 8ca4245cc..0c53ed14e 100644 --- a/compliance-monitor/bootstrap.yaml +++ b/compliance-monitor/bootstrap.yaml @@ -128,3 +128,11 @@ accounts: group: syself delegates: - zuul_ci + - subject: teuto-1.35 + group: teuto + delegates: + - zuul_ci + - subject: teuto-1.36 + group: teuto + delegates: + - zuul_ci diff --git a/compliance-monitor/templates/overview.md.j2 b/compliance-monitor/templates/overview.md.j2 index 6caf8461e..a381b3c7e 100644 --- a/compliance-monitor/templates/overview.md.j2 +++ b/compliance-monitor/templates/overview.md.j2 @@ -63,3 +63,9 @@ These environments are not officially certified. Operators voluntarily list them | [syseleven](https://www.syseleven.de/en/products-services/openstack-cloud/) | Public OpenStack Cloud (2 SCS regions) | SysEleven GmbH | {#- #} [{{ results | pick(iaas, 'group-syseleven') | summary }}]({{ detail_url('group-syseleven', iaas) }}) {# -#} | (soon) | + +| Name | Description | Operator | SCS-compatible KaaS | +|-------|--------------|-----------|----------------------| +| [t8s](https://teuto.net/produkte/t8s/) | Public KaaS cloud based on Cluster API | teuto.net Netzdienste GmbH | {# #} +{#- #} [{{ results | pick(kaas, 'group-teuto') | summary }}]({{ detail_url('group-teuto', kaas) }}) {# -#} +| diff --git a/playbooks/.config/scs/t8s-kubeconfig.yaml b/playbooks/.config/scs/t8s-kubeconfig.yaml new file mode 100644 index 000000000..83e6eeb0f --- /dev/null +++ b/playbooks/.config/scs/t8s-kubeconfig.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM2akNDQWRLZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJMk1ERXhOakV3TlRFME5sb1hEVE0yTURFeE5ERXdOVFkwTmxvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTlRvClFmeFN5R1BmeTVaRWdqV2NYSDErUlBBNU1KcUVwNC9lVEp0OTc5ajZrYTJaa296S3Q5YlJpTk1rRk4zSEhBQ2QKZEtucERhMk51ODVjSFBqQlJyeVl2ZjZHa2duYUpNQ3A2VUhpdnFFWFZwOU1SckVtTjlOZmZnMlozQmJxQ3h6awpRTHJuayt1WkxDb0RtVXl3WFpNRkFoMHVPeXVkcmhGSGxia0MzdFhPWk0rRGdzMHBXZFRjcjV5bnVudEtBeG9MCnE4MFhjQWYwaWtCMlFHanMyRkQvdWdIWE9WQnlkNmQxTkxsMyttN3JWbCtwaDQ2SUNESG1FY0tYWHBQbC83Z3EKc0FML2xyRnBqV0xIZi80a3JVemNUMUQvMG5jK1ZuRDBVNkMvNjBRdEl3THlLOEdDdU1XZ3VlRW9MTkMvN1VNVAp1b1JMUE5oRlNwY1FwRGJFVEhNQ0F3RUFBYU5GTUVNd0RnWURWUjBQQVFIL0JBUURBZ0trTUJJR0ExVWRFd0VCCi93UUlNQVlCQWY4Q0FRQXdIUVlEVlIwT0JCWUVGSitMK2Yra1c1dXJHaERITlUySmxORDVFUnNETUEwR0NTcUcKU0liM0RRRUJDd1VBQTRJQkFRQzNvMnJsSkxPbGQvWVViVm1ycVVjNnY0aDlsUjdGUXE1L3FpNjlaVGVTVUNhegpqL2szVjQ4emltT2pZU1VseWY0WWtsV2tkTzdSUDdGbTNEWFkvclNCZWJkU1pMaTFSNk1yT0ZSeC9jSzFiaFY4Ck5mTjhzdUliT1NOZ0txTS9kUDc3aGlsRXRFYXl4MWE5UVVLemd6UHpPMitCK3pwRHpKMTVDeDU3dTV2YkV5WlgKVjdRSVlEamJTQThCWWYya1NGL0RBRFJzd3JmaDREbGloRnYrZDFDSnlHVXdHUW9qZzVXKzVDc1ZkNWw4MlhBZgpzdDYzVkRWeVpzNFZlVzF6ckpFTVB3VVVVZmxielFNdzgrRFEwa2RaZG5tODY2bmZiOWNVT1d0MEd3UjYyWDJZCmp4Nm1JK0lKMzdyQVcybTc2VGVDZmlycFl3eHZZN0FOVk5ObHNiZTUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://212.8.205.30:6443 + name: mgmt-bfe2-prod +contexts: +- context: + cluster: mgmt-bfe2-prod + namespace: scs-kaas-certification + user: scs-kaas-certification + name: default +current-context: default +kind: Config +preferences: {} +users: +- name: scs-kaas-certification + user: + token: '{{ token }}' diff --git a/playbooks/clusters.yaml.j2 b/playbooks/clusters.yaml.j2 index 0b80fe641..f36088193 100644 --- a/playbooks/clusters.yaml.j2 +++ b/playbooks/clusters.yaml.j2 @@ -209,6 +209,24 @@ clusters: num_worker_nodes: 3 secrets: token: "{{ clouds_conf.syself_token }}" + teuto-1.35: + kind: t8s + config: + kubernetesVersion: '1.35' + version_patch: 2 + templates: + kubeconfig: t8s-kubeconfig.yaml + secrets: + token: "{{ clouds_conf.teuto_mgmt_token }}" + teuto-1.36: + kind: t8s + config: + kubernetesVersion: '1.36' + version_patch: 0 + templates: + kubeconfig: t8s-kubeconfig.yaml + secrets: + token: "{{ clouds_conf.teuto_mgmt_token }}" # dev clusters using kind # latest versions to be found under https://hub.docker.com/r/kindest/node/tags kind-1.32: