Skip to content

Commit 779dff7

Browse files
committed
feat: add t8s kaas offering
Signed-off-by: Chris Werner Rau <cwrau@cwrau.info>
1 parent 384d0c4 commit 779dff7

15 files changed

Lines changed: 541 additions & 35 deletions

File tree

.zuul.d/secure.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,37 @@
426426
Iek4Bf2Vsh3IcXe4WPgooBm9y0wejEmRVwApwcsArxILaa/VUJQO6zVeaLKTGTBNVKolm
427427
pP2x748l+uFWMUGjmzao4OTyfjBKx2cXNqe/OmIyscCr7lDZkipFdDTWrR1p/FLHQiCIc
428428
jBsgwuDMuVcVslw+cy3CPiPKZNWSJLop5cIvCEzGPBzXto9YTVQwPwnmcl+Rnc=
429+
teuto_mgmt_token: !encrypted/pkcs1-oaep
430+
- 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
- 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
- bRJ2EzLqZ/nes0hpujZy+jqOCkR+v2EntKSB7s4kxf/4GxpeqegdSddJh1yRdju/PFXjI
451+
moEKXw3Gr+PeKu8Tl+pjk1PCDwvlHU/rFRKT2cE2pC7m2/+p9DhwDibuDLWJziRU2yX4D
452+
7cj7/YavxrG7NtwglkfVoW03B2sFCAcTsp15ITrjoQI2oLFZ7F83HwepDV+aLRD4MBxOP
453+
plK40hml/MBlrolou0T/LRhsXeTuv73LFzSY7gK7H8+aZSy106uI4Ege+mnIIMjCtXDKY
454+
3YADbpGu1ORu4yB4Md7O6C2TrPCD/ZJGP7hqYEhAEMVFgiR6upq4mHAKiXtgL3dONjl1e
455+
lvmDAAY39TFkcNfLdWUSnkWSxvNvd6W59NdgQ+9/1tH42po17jn5ZYh4BMtVIJyXxBF1J
456+
H2IHzIi+HcI+tjde4ifkvLF4UKNZLm4lb4nDZS2CuB+7zsy66NbHZzfs/4twB+Cc2FpgX
457+
FE9Pw12G4hZc25imjYbjkd9IbI+/PXwWr0R8/GqhwrUwAVFG8vb38hSjzfShwhlYcBvio
458+
zeW7rBRtT5L78err96rrhFTuFqYtldpewF2QqOzgqpT3sISdFHhF92+etTPBZ0KIivD0b
459+
vh/1NAp07jx5C7Kp7cG4JrV9+AP9B/tjoplCl2itzGrk9rBu+nVE7vkcpECFxg=
429460
noris_token: !encrypted/pkcs1-oaep
430461
- o3ipramOzntQWHvfA11URFQfJa1z8ycIH8ax1GFBDkYxwlvLcTnzruleYjP5glU3v7cLK
431462
ycjGb0BvhEKy72cmTKrI7NnbL88VKngEGhkCG8hLr5cy8cfLvXHHERul2CiTGRPwYf5gy

Tests/config.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ subjects = [
4545
"scaleup-1.35",
4646
"syself-1.34",
4747
"syself-1.35",
48+
"teuto-1.35",
49+
"teuto-1.36",
4850
]
4951
workers = 16
5052

@@ -54,7 +56,7 @@ scopes = [
5456
"scs-compatible-kaas",
5557
]
5658
subjects = [
57-
# add 1.36 subjects here
59+
"teuto-1.36",
5860
]
5961
workers = 4
6062

@@ -79,6 +81,7 @@ subjects = [
7981
"noris-1.35",
8082
"scaleup-1.35",
8183
"syself-1.35",
84+
"teuto-1.35",
8285
]
8386
workers = 4
8487

Tests/kaas/plugin/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import sys
2+
import os
3+
4+
sys.path.insert(0, os.path.dirname(__file__))

Tests/kaas/plugin/cs_helper.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
"""helper functions for ClusterStacks plugin"""
22
import base64
3-
import os
43

5-
from kubernetes.client import Configuration, CoreV1Api, CustomObjectsApi
6-
7-
8-
def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'):
9-
"""transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s"""
10-
token = kubeconfig['users'][0]['user']['token']
11-
client_config.api_key['authorization'] = 'Bearer {}'.format(token)
12-
client_config.host = kubeconfig['clusters'][0]['cluster']['server']
13-
client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt'))
14-
with open(client_config.ssl_ca_cert, "wb") as fileobj:
15-
fileobj.write(base64.standard_b64decode(
16-
kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode()
17-
))
4+
from kubernetes.client import CoreV1Api, CustomObjectsApi
185

196

207
def create_cr(api_instance: CustomObjectsApi, namespace, resource_dict):

Tests/kaas/plugin/gardener_helper.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,15 @@
11
"""helper functions for Gardener plugin"""
22
import base64
33
import json
4-
import os
54

6-
from kubernetes.client import Configuration, CustomObjectsApi, ApiClient
5+
from kubernetes.client import CustomObjectsApi, ApiClient
76

87

98
GARDENER_GROUP = 'core.gardener.cloud'
109
GARDENER_VERSION = 'v1beta1'
1110
GARDENER_PLURAL = 'shoots'
1211

1312

14-
def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'):
15-
"""transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s"""
16-
token = kubeconfig['users'][0]['user']['token']
17-
client_config.api_key['authorization'] = 'Bearer {}'.format(token)
18-
client_config.host = kubeconfig['clusters'][0]['cluster']['server']
19-
client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt'))
20-
with open(client_config.ssl_ca_cert, "wb") as fileobj:
21-
fileobj.write(base64.standard_b64decode(
22-
kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode()
23-
))
24-
25-
2613
def get_cloudprofile(api_instance: CustomObjectsApi, namespace, name):
2714
"""mimic `kubectl get cloudprofile`"""
2815
return api_instance.get_cluster_custom_object(

Tests/kaas/plugin/k8s_helper.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""helper functions shared by the Kubernetes-based cluster plugins"""
2+
import base64
3+
import os
4+
5+
from kubernetes.client import Configuration
6+
7+
8+
def setup_client_config(client_config: Configuration, kubeconfig, cwd='.'):
9+
"""transfer authentication data from kubeconfig to client_config, creating file `ca.crt`s"""
10+
token = kubeconfig['users'][0]['user']['token']
11+
client_config.api_key['authorization'] = 'Bearer {}'.format(token)
12+
client_config.host = kubeconfig['clusters'][0]['cluster']['server']
13+
client_config.ssl_ca_cert = os.path.abspath(os.path.join(cwd, 'ca.crt'))
14+
with open(client_config.ssl_ca_cert, "wb") as fileobj:
15+
fileobj.write(base64.standard_b64decode(
16+
kubeconfig['clusters'][0]['cluster']['certificate-authority-data'].encode()
17+
))

Tests/kaas/plugin/plugin_clusterstacks.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import time
55

66
from jinja2 import Environment
7-
from kubernetes.client import ApiClient, ApiException
7+
from kubernetes.client import ApiClient, ApiException, Configuration
88
import yaml
99

1010
from interface import KubernetesClusterPlugin
1111
import cs_helper as _csh
12+
import k8s_helper
1213

1314
logger = logging.getLogger(__name__)
1415
logging.getLogger("kubernetes").setLevel(logging.INFO)
@@ -147,8 +148,8 @@ def __init__(self, config, basepath='.', cwd='.'):
147148
self.vars['name'] = self.config['name']
148149
self.secrets = self.config['secrets']
149150
self.kubeconfig = yaml.load(self._render_template('kubeconfig'), Loader=yaml.SafeLoader)
150-
self.client_config = _csh.Configuration()
151-
_csh.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd)
151+
self.client_config = Configuration()
152+
k8s_helper.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd)
152153
self.namespace = self.kubeconfig['contexts'][0]['context']['namespace']
153154

154155
def _render_template(self, key):

Tests/kaas/plugin/plugin_gardener.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import time
55

66
from jinja2 import Environment, StrictUndefined
7-
from kubernetes.client import ApiClient, ApiException
7+
from kubernetes.client import ApiClient, ApiException, Configuration
88
import yaml
99

1010
from interface import KubernetesClusterPlugin
1111
import gardener_helper as _gh
12+
import k8s_helper
1213

1314
logger = logging.getLogger(__name__)
1415
logging.getLogger("kubernetes").setLevel(logging.INFO)
@@ -157,8 +158,8 @@ def __init__(self, plugin_config, basepath='.', cwd='.', name=None):
157158
self.vars['name'] = self.config['name']
158159
self.secrets = self.config['secrets']
159160
self.kubeconfig = yaml.load(self._render_template('kubeconfig'), Loader=yaml.SafeLoader)
160-
self.client_config = _gh.Configuration()
161-
_gh.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd)
161+
self.client_config = Configuration()
162+
k8s_helper.setup_client_config(self.client_config, self.kubeconfig, cwd=self.cwd)
162163
self.namespace = self.kubeconfig['contexts'][0]['context']['namespace']
163164

164165
def _render_template(self, key):

Tests/kaas/plugin/plugin_t8s.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import base64
2+
import logging
3+
import os
4+
import os.path
5+
import time
6+
from typing import Any, cast
7+
8+
from jinja2 import Environment, Template
9+
from kubernetes.client import ApiClient, ApiException, Configuration, CoreV1Api, CustomObjectsApi, V1Secret
10+
import yaml
11+
12+
from interface import KubernetesClusterPlugin
13+
import k8s_helper
14+
15+
logger = logging.getLogger(__name__)
16+
logging.getLogger("kubernetes").setLevel(logging.INFO)
17+
18+
19+
TIMEOUTS = [30] * 60 + [0] # wait at most 30 minutes; sentinel value at the end
20+
21+
HR_GROUP = "helm.toolkit.fluxcd.io"
22+
HR_VERSION = "v2"
23+
HR_PLURAL = "helmreleases"
24+
25+
# All of these are mandated by the ValidatingAdmissionPolicy on the management cluster
26+
# and cannot be changed without updating both the VAP and the RBAC.
27+
HR_NAME = "scs-kaas-certification"
28+
HR_NAMESPACE = "scs-kaas-certification"
29+
HR_KUBECONFIG_SECRET = "scs-kaas-certification-kubeconfig"
30+
HR_CHART_REF = {
31+
"kind": "HelmChart",
32+
"name": "scs-kaas-certification",
33+
"namespace": "flux-system",
34+
}
35+
HR_VALUES_FIXED = {
36+
"cloud": "bfe2-prod",
37+
"controlPlane": {"hosted": True},
38+
"metadata": {
39+
"customerID": 1111,
40+
"customerName": "teuto.net Netzdienste GmbH",
41+
"friendlyName": "scs-kaas-certification",
42+
"serviceLevelAgreement": "None",
43+
},
44+
"nodePools": {
45+
"pool-0": {"flavor": "standard.2.1905", "replicas": 2},
46+
},
47+
}
48+
49+
50+
class PluginT8s(KubernetesClusterPlugin):
51+
"""
52+
Plugin to provision Kubernetes clusters on the t8s (teuto k8s) management cluster
53+
via a Flux HelmRelease.
54+
55+
Creates a HelmRelease on mgmt-bfe2-prod and waits for the resulting cluster's
56+
kubeconfig to appear in the secret scs-kaas-certification-kubeconfig.
57+
58+
Expected config keys:
59+
kubernetesVersion: '1.35' (major.minor)
60+
version_patch: 2 (patch component of the k8s version)
61+
templates:
62+
kubeconfig: t8s-kubeconfig.yaml (management cluster kubeconfig template)
63+
secrets:
64+
token: '{{ clouds_conf.teuto_mgmt_token }}'
65+
"""
66+
67+
def __init__(self, config: dict[str, Any], basepath: str = ".", cwd: str = ".") -> None:
68+
self.basepath = basepath
69+
self.cwd = cwd
70+
self.env = Environment()
71+
72+
fn: str = config["templates"]["kubeconfig"]
73+
with open(os.path.join(basepath, fn), "r") as f:
74+
self.kubeconfig_template: Template = self.env.from_string(f.read())
75+
self.secrets: dict[str, Any] = config.get("secrets", {})
76+
77+
major, minor = (int(x) for x in config["kubernetesVersion"].split("."))
78+
self.k8s_version: dict[str, int] = {
79+
"major": major,
80+
"minor": minor,
81+
"patch": int(config.get("version_patch", 0)),
82+
}
83+
84+
kubeconfig = cast(dict[str, Any], yaml.load(
85+
self.kubeconfig_template.render(**self.secrets), Loader=yaml.SafeLoader
86+
))
87+
self.client_config: Configuration = Configuration()
88+
k8s_helper.setup_client_config(self.client_config, kubeconfig, cwd=self.cwd)
89+
90+
def _build_helmrelease(self) -> dict[str, Any]:
91+
return {
92+
"apiVersion": f"{HR_GROUP}/{HR_VERSION}",
93+
"kind": "HelmRelease",
94+
"metadata": {"name": HR_NAME, "namespace": HR_NAMESPACE},
95+
"spec": {
96+
"chartRef": HR_CHART_REF,
97+
"driftDetection": {"mode": "enabled"},
98+
"interval": "1m",
99+
"values": {**HR_VALUES_FIXED, "version": self.k8s_version},
100+
},
101+
}
102+
103+
def _delete_helmrelease(self, co_api: CustomObjectsApi) -> None:
104+
try:
105+
co_api.delete_namespaced_custom_object(
106+
HR_GROUP,
107+
HR_VERSION,
108+
HR_NAMESPACE,
109+
HR_PLURAL,
110+
HR_NAME,
111+
)
112+
except ApiException as e:
113+
if e.status == 404:
114+
logger.debug(f"HelmRelease {HR_NAME} not present, nothing to delete")
115+
return
116+
raise
117+
# Give Flux time to begin deletion before we try to recreate
118+
logger.debug(f"HelmRelease {HR_NAME} deletion requested; waiting 30s")
119+
time.sleep(30)
120+
121+
def _apply_helmrelease(self, api_client: ApiClient) -> None:
122+
"""Server-side apply the HelmRelease (creates or updates).
123+
124+
Uses PATCH with application/apply-patch+yaml so the resource name is
125+
part of the URL — meaning resourceNames RBAC constraints are evaluated
126+
correctly, unlike POST (create) where the name is only in the body.
127+
"""
128+
hr = self._build_helmrelease()
129+
api_client.call_api(
130+
'/apis/{group}/{version}/namespaces/{namespace}/{plural}/{name}',
131+
'PATCH',
132+
path_params={
133+
'group': HR_GROUP,
134+
'version': HR_VERSION,
135+
'namespace': HR_NAMESPACE,
136+
'plural': HR_PLURAL,
137+
'name': HR_NAME,
138+
},
139+
query_params=[('fieldManager', 'plugin_t8s'), ('force', 'true')],
140+
header_params={
141+
'Accept': 'application/json',
142+
'Content-Type': 'application/apply-patch+yaml',
143+
},
144+
body=hr,
145+
post_params=[],
146+
files={},
147+
response_type='object',
148+
auth_settings=['BearerToken'],
149+
_return_http_data_only=True,
150+
)
151+
logger.debug(f"HelmRelease {HR_NAME} applied")
152+
153+
def _get_kubeconfig_from_secret(self, core_api: CoreV1Api) -> bytes:
154+
secret = cast(V1Secret, core_api.read_namespaced_secret(HR_KUBECONFIG_SECRET, HR_NAMESPACE))
155+
data: dict[str, str] = secret.data or {}
156+
if "value" in data:
157+
return base64.standard_b64decode(data["value"].encode())
158+
raise RuntimeError(
159+
f"kubeconfig secret {HR_KUBECONFIG_SECRET} is missing key 'value'; has keys: {list(data)}"
160+
)
161+
162+
def _wait_for_kubeconfig_secret(self, core_api: CoreV1Api) -> bytes:
163+
timeouts = iter(TIMEOUTS)
164+
while True:
165+
try:
166+
return self._get_kubeconfig_from_secret(core_api)
167+
except ApiException as e:
168+
if e.status != 404:
169+
raise
170+
timeout = next(timeouts)
171+
if not timeout:
172+
raise RuntimeError(
173+
f"Timeout waiting for kubeconfig secret {HR_KUBECONFIG_SECRET}"
174+
)
175+
logger.debug(
176+
f"waiting {timeout}s for kubeconfig secret {HR_KUBECONFIG_SECRET}"
177+
)
178+
time.sleep(timeout)
179+
180+
def _write_kubeconfig(self, data: bytes) -> None:
181+
path = os.path.join(self.cwd, "kubeconfig.yaml")
182+
logger.debug(f"writing {path}")
183+
with open(path, "wb") as f:
184+
f.write(data)
185+
186+
def create_cluster(self) -> None:
187+
with ApiClient(self.client_config) as api_client:
188+
core_api = CoreV1Api(api_client)
189+
self._apply_helmrelease(api_client)
190+
kubeconfig = self._wait_for_kubeconfig_secret(core_api)
191+
self._write_kubeconfig(kubeconfig)
192+
193+
def delete_cluster(self) -> None:
194+
with ApiClient(self.client_config) as api_client:
195+
co_api = CustomObjectsApi(api_client)
196+
self._delete_helmrelease(co_api)

0 commit comments

Comments
 (0)