Skip to content

Commit 725b1a5

Browse files
committed
feat: add t8s kaas offering
Signed-off-by: Chris Werner Rau <cwrau@cwrau.info>
1 parent a13247b commit 725b1a5

10 files changed

Lines changed: 511 additions & 1 deletion

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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
- 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
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
@@ -46,6 +46,8 @@ subjects = [
4646
"scaleup-1.35",
4747
"syself-1.34",
4848
"syself-1.35",
49+
"teuto-1.35",
50+
"teuto-1.36",
4951
]
5052
workers = 16
5153

@@ -55,7 +57,7 @@ scopes = [
5557
"scs-compatible-kaas",
5658
]
5759
subjects = [
58-
# add 1.36 subjects here
60+
"teuto-1.36",
5961
]
6062
workers = 4
6163

@@ -80,6 +82,7 @@ subjects = [
8082
"noris-1.35",
8183
"scaleup-1.35",
8284
"syself-1.35",
85+
"teuto-1.35",
8386
]
8487
workers = 4
8588

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/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 cs_helper as _csh
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 = _csh.Configuration()
88+
_csh.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} has unexpected keys: {list(data.keys())}"
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+
co_api = CustomObjectsApi(api_client)
190+
self._apply_helmrelease(api_client)
191+
self._write_kubeconfig(self._wait_for_kubeconfig_secret(core_api))
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)

Tests/kaas/plugin/run_plugin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
from plugin_gardener import PluginGardener
1010
from plugin_kind import PluginKind
1111
from plugin_static import PluginStatic
12+
from plugin_t8s import PluginT8s
1213

1314

1415
PLUGIN_LOOKUP = {
1516
"clusterstacks": PluginClusterStacks,
1617
"gardener": PluginGardener,
1718
"kind": PluginKind,
1819
"static": PluginStatic,
20+
"t8s": PluginT8s,
1921
}
2022
BASEPATH = os.path.join(os.path.expanduser('~'), '.config', 'scs')
2123

0 commit comments

Comments
 (0)