Skip to content

Commit 32ddcd8

Browse files
committed
omnibus tailscale-operator
1 parent e753072 commit 32ddcd8

1 file changed

Lines changed: 65 additions & 209 deletions

File tree

containers/tailscale-operator-manager/operator.py

Lines changed: 65 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@
88
LABEL_KEY = "cabotage.io/tailscale-operator"
99
LABEL_VALUE = "true"
1010

11-
# ClusterRoleBinding that all per-namespace operator SAs get added to
12-
CLUSTER_ROLE_BINDING_NAME = "tailscale-operator"
13-
14-
15-
def _operator_name(crd_name):
16-
return f"tailscale-operator-{crd_name}"
1711

1812

1913
def _proxy_group_name(crd_name):
@@ -151,53 +145,6 @@ def _ensure_role_binding(rbac_api, namespace, name, role_name, sa_name, labels):
151145
raise
152146

153147

154-
def _ensure_cluster_role_binding_subject(rbac_api, namespace, sa_name, logger):
155-
"""Add a ServiceAccount to the pre-provisioned tailscale-operator ClusterRoleBinding."""
156-
try:
157-
crb = rbac_api.read_cluster_role_binding(CLUSTER_ROLE_BINDING_NAME)
158-
except ApiException as exc:
159-
if exc.status == 404:
160-
logger.warning(f"ClusterRoleBinding {CLUSTER_ROLE_BINDING_NAME} not found")
161-
return
162-
raise
163-
164-
subjects = crb.subjects or []
165-
if any(s.name == sa_name and s.namespace == namespace for s in subjects):
166-
return
167-
168-
subjects.append(
169-
kubernetes.client.RbacV1Subject(
170-
kind="ServiceAccount",
171-
name=sa_name,
172-
namespace=namespace,
173-
)
174-
)
175-
crb.subjects = subjects
176-
rbac_api.replace_cluster_role_binding(CLUSTER_ROLE_BINDING_NAME, crb)
177-
logger.info(f"Added {sa_name} in {namespace} to ClusterRoleBinding")
178-
179-
180-
def _remove_cluster_role_binding_subject(rbac_api, namespace, sa_name, logger):
181-
"""Remove a ServiceAccount from the ClusterRoleBinding."""
182-
try:
183-
crb = rbac_api.read_cluster_role_binding(CLUSTER_ROLE_BINDING_NAME)
184-
except ApiException as exc:
185-
if exc.status == 404:
186-
return
187-
raise
188-
189-
subjects = crb.subjects or []
190-
filtered = [
191-
s for s in subjects if not (s.name == sa_name and s.namespace == namespace)
192-
]
193-
if len(filtered) == len(subjects):
194-
return
195-
196-
crb.subjects = filtered
197-
rbac_api.replace_cluster_role_binding(CLUSTER_ROLE_BINDING_NAME, crb)
198-
logger.info(f"Removed {sa_name} in {namespace} from ClusterRoleBinding")
199-
200-
201148
def _delete_if_exists(fn, *args, logger=None):
202149
try:
203150
fn(*args)
@@ -207,48 +154,65 @@ def _delete_if_exists(fn, *args, logger=None):
207154
logger.warning(f"Failed to delete {args}: {exc}")
208155

209156

210-
def _ingress_class_name(crd_name):
211-
return f"tailscale-{crd_name}"
157+
TAILNET_API = {
158+
"group": "tailscale.com",
159+
"version": "v1alpha1",
160+
"plural": "tailnets",
161+
}
212162

213163

214-
def _ensure_ingress_class(networking_api, crd_name, labels, logger):
215-
"""Create per-org IngressClass so the operator only watches its own ingresses."""
216-
ic_name = _ingress_class_name(crd_name)
217-
body = kubernetes.client.V1IngressClass(
218-
metadata=kubernetes.client.V1ObjectMeta(
219-
name=ic_name,
220-
labels=labels,
221-
),
222-
spec=kubernetes.client.V1IngressClassSpec(
223-
controller="tailscale.com/ts-ingress",
224-
),
225-
)
164+
def _ensure_tailnet(custom_api, crd_name, secret_name, labels, logger):
165+
"""Create the Tailnet CRD so the operator authenticates to the org's tailnet."""
166+
body = {
167+
"apiVersion": f"{TAILNET_API['group']}/{TAILNET_API['version']}",
168+
"kind": "Tailnet",
169+
"metadata": {
170+
"name": crd_name,
171+
"labels": labels,
172+
},
173+
"spec": {
174+
"credentials": {
175+
"secretName": secret_name,
176+
},
177+
},
178+
}
226179
try:
227-
networking_api.read_ingress_class(ic_name)
228-
logger.info(f"IngressClass {ic_name} already exists")
180+
custom_api.get_cluster_custom_object(
181+
TAILNET_API["group"], TAILNET_API["version"],
182+
TAILNET_API["plural"], crd_name,
183+
)
184+
logger.info(f"Tailnet {crd_name} already exists")
229185
except ApiException as exc:
230186
if exc.status == 404:
231-
networking_api.create_ingress_class(body)
232-
logger.info(f"Created IngressClass {ic_name}")
187+
custom_api.create_cluster_custom_object(
188+
TAILNET_API["group"], TAILNET_API["version"],
189+
TAILNET_API["plural"], body,
190+
)
191+
logger.info(f"Created Tailnet {crd_name}")
233192
else:
234193
raise
235194

236195

237-
def _delete_ingress_class(networking_api, crd_name, logger):
238-
"""Delete per-org IngressClass."""
239-
ic_name = _ingress_class_name(crd_name)
196+
def _delete_tailnet(custom_api, crd_name, logger):
197+
"""Delete the Tailnet CRD."""
240198
try:
241-
networking_api.delete_ingress_class(ic_name)
242-
logger.info(f"Deleted IngressClass {ic_name}")
199+
custom_api.delete_cluster_custom_object(
200+
TAILNET_API["group"], TAILNET_API["version"],
201+
TAILNET_API["plural"], crd_name,
202+
)
203+
logger.info(f"Deleted Tailnet {crd_name}")
243204
except ApiException as exc:
244205
if exc.status != 404:
245-
logger.warning(f"Failed to delete IngressClass {ic_name}: {exc}")
206+
logger.warning(f"Failed to delete Tailnet {crd_name}: {exc}")
246207

247208

248209
def _ensure_proxy_group(custom_api, crd_name, labels, default_tags, logger):
249210
"""Create the ProxyGroup so ingresses appear as Tailscale Services."""
250211
pg_name = _proxy_group_name(crd_name)
251-
spec = {"type": "ingress"}
212+
spec = {
213+
"type": "ingress",
214+
"tailnet": crd_name, # References the Tailnet CRD for this org
215+
}
252216
if default_tags:
253217
spec["tags"] = [t.strip() for t in default_tags.split(",") if t.strip()]
254218
body = {
@@ -334,180 +298,72 @@ def reconcile_operator(spec, name, namespace, memo, logger, retry, **kwargs):
334298
org_slug = spec["organizationSlug"]
335299

336300
labels = _labels(org_slug)
337-
operator_sa = _operator_name(name)
338-
jwt_secret_name = f"tailscale-oidc-jwt-{name}"
301+
tailnet_secret_name = f"tailscale-tailnet-{name}"
302+
# The single operator runs in the cabotage namespace
303+
operator_namespace = "tailscale"
339304

340305
core_api = kubernetes.client.CoreV1Api()
341-
apps_api = kubernetes.client.AppsV1Api()
342306
rbac_api = kubernetes.client.RbacAuthorizationV1Api()
343307
custom_api = kubernetes.client.CustomObjectsApi()
344308

345-
# 1. Ensure JWT Secret exists (cabotage's refresh task writes the token)
309+
# 1. Ensure Tailnet credential Secret exists in operator namespace
310+
# (cabotage's refresh task writes client_id + jwt)
346311
try:
347-
core_api.read_namespaced_secret(jwt_secret_name, namespace)
348-
logger.info(f"JWT Secret {jwt_secret_name} exists")
312+
core_api.read_namespaced_secret(tailnet_secret_name, operator_namespace)
313+
logger.info(f"Tailnet Secret {tailnet_secret_name} exists")
349314
except ApiException as exc:
350315
if exc.status == 404:
351-
# Create placeholder — cabotage's periodic task will populate it
352316
_ensure_secret(
353-
core_api, namespace, jwt_secret_name,
354-
{"token": ""},
317+
core_api, operator_namespace, tailnet_secret_name,
318+
{"client_id": client_id, "jwt": ""},
355319
labels,
356320
)
357-
logger.info(f"Created placeholder JWT Secret {jwt_secret_name}")
321+
logger.info(f"Created placeholder Tailnet Secret {tailnet_secret_name}")
358322
else:
359323
raise
360324

361-
# 2. Per-org IngressClass
362-
networking_api = kubernetes.client.NetworkingV1Api()
363-
_ensure_ingress_class(networking_api, name, labels, logger)
325+
# 2. Tailnet CRD (cluster-scoped) — references the credential Secret
326+
_ensure_tailnet(custom_api, name, tailnet_secret_name, labels, logger)
364327

365-
# 3. ServiceAccounts
366-
_ensure_service_account(core_api, namespace, operator_sa, labels)
367-
_ensure_service_account(core_api, namespace, "proxies", labels)
368-
logger.info("Ensured ServiceAccounts")
369-
370-
# 3. ClusterRoleBinding subject (grants operator cluster-wide access
371-
# via the pre-provisioned tailscale-operator ClusterRole — no
372-
# redundant namespace Role needed)
373-
_ensure_cluster_role_binding_subject(rbac_api, namespace, operator_sa, logger)
328+
# 3. ProxyGroup (cluster-scoped) — references the Tailnet
329+
_ensure_proxy_group(custom_api, name, labels, default_tags, logger)
374330

375-
# 4. Proxies Role + RoleBinding (proxies don't have a ClusterRole,
376-
# they only need namespace-scoped secret access for their state)
331+
# 4. Proxies SA + RBAC in org namespace (for proxy pods)
332+
_ensure_service_account(core_api, namespace, "proxies", labels)
377333
_ensure_role(rbac_api, namespace, "proxies", _proxies_role_rules(), labels)
378334
_ensure_role_binding(rbac_api, namespace, "proxies", "proxies", "proxies", labels)
379335
logger.info("Ensured proxies RBAC")
380336

381-
# 6. Operator Deployment (WIF mode — JWT from cabotage OIDC issuer)
382-
deploy_name = operator_sa
383-
env_vars = [
384-
kubernetes.client.V1EnvVar(
385-
name="OPERATOR_HOSTNAME", value=f"ts-operator-{namespace}"
386-
),
387-
kubernetes.client.V1EnvVar(name="OPERATOR_NAMESPACE", value=namespace),
388-
kubernetes.client.V1EnvVar(
389-
name="OPERATOR_SECRET", value=f"{deploy_name}-state"
390-
),
391-
# CLIENT_ID (not CLIENT_ID_FILE) triggers WIF mode in the operator
392-
kubernetes.client.V1EnvVar(name="CLIENT_ID", value=client_id),
393-
# Per-org IngressClass so this operator only watches its own ingresses
394-
kubernetes.client.V1EnvVar(
395-
name="OPERATOR_INGRESS_CLASS_NAME", value=_ingress_class_name(name)
396-
),
397-
]
398-
if default_tags:
399-
env_vars.append(
400-
kubernetes.client.V1EnvVar(name="OPERATOR_INITIAL_TAGS", value=default_tags)
401-
)
402-
403-
deploy_labels = {**labels, "app": deploy_name}
404-
deployment = kubernetes.client.V1Deployment(
405-
metadata=kubernetes.client.V1ObjectMeta(
406-
name=deploy_name,
407-
namespace=namespace,
408-
labels=deploy_labels,
409-
),
410-
spec=kubernetes.client.V1DeploymentSpec(
411-
replicas=1,
412-
selector=kubernetes.client.V1LabelSelector(
413-
match_labels={"app": deploy_name}
414-
),
415-
template=kubernetes.client.V1PodTemplateSpec(
416-
metadata=kubernetes.client.V1ObjectMeta(labels=deploy_labels),
417-
spec=kubernetes.client.V1PodSpec(
418-
service_account_name=deploy_name,
419-
containers=[
420-
kubernetes.client.V1Container(
421-
name="operator",
422-
image=operator_image,
423-
env=env_vars,
424-
volume_mounts=[
425-
kubernetes.client.V1VolumeMount(
426-
name="oidc-jwt",
427-
mount_path="/var/run/secrets/tailscale/serviceaccount",
428-
read_only=True,
429-
),
430-
],
431-
resources=kubernetes.client.V1ResourceRequirements(
432-
requests={"cpu": "50m", "memory": "64Mi"},
433-
limits={"cpu": "200m", "memory": "128Mi"},
434-
),
435-
),
436-
],
437-
volumes=[
438-
kubernetes.client.V1Volume(
439-
name="oidc-jwt",
440-
secret=kubernetes.client.V1SecretVolumeSource(
441-
secret_name=jwt_secret_name,
442-
),
443-
),
444-
],
445-
),
446-
),
447-
),
448-
)
449-
try:
450-
apps_api.read_namespaced_deployment(deploy_name, namespace)
451-
apps_api.replace_namespaced_deployment(deploy_name, namespace, deployment)
452-
except ApiException as exc:
453-
if exc.status == 404:
454-
apps_api.create_namespaced_deployment(namespace, deployment)
455-
else:
456-
raise
457-
logger.info(f"Ensured Deployment {deploy_name}")
458-
459-
# 7. ProxyGroup for HA ingress (Tailscale Services instead of devices)
460-
_ensure_proxy_group(custom_api, name, labels, default_tags, logger)
461-
462-
# Extract version from image tag
337+
# Extract version from operator image tag
463338
version = operator_image.rsplit(":", 1)[-1] if ":" in operator_image else "unknown"
464339

465340
return {"state": "deployed", "operatorVersion": version}
466341

467342

468343
@kopf.on.delete("cabotagetailscaleoperatorconfigs")
469344
def delete_operator(spec, name, namespace, logger, **kwargs):
470-
org_slug = spec.get("organizationSlug", "")
471-
operator_sa = _operator_name(name)
472-
jwt_secret_name = f"tailscale-oidc-jwt-{name}"
345+
tailnet_secret_name = f"tailscale-tailnet-{name}"
346+
operator_namespace = "tailscale"
473347

474348
core_api = kubernetes.client.CoreV1Api()
475-
apps_api = kubernetes.client.AppsV1Api()
476349
rbac_api = kubernetes.client.RbacAuthorizationV1Api()
477350
custom_api = kubernetes.client.CustomObjectsApi()
478351

479352
# Delete in reverse order
480-
networking_api = kubernetes.client.NetworkingV1Api()
481-
_delete_ingress_class(networking_api, name, logger)
482-
_delete_proxy_group(custom_api, name, logger)
483-
_delete_if_exists(
484-
apps_api.delete_namespaced_deployment, operator_sa, namespace, logger=logger
485-
)
486353
_delete_if_exists(
487354
rbac_api.delete_namespaced_role_binding, "proxies", namespace, logger=logger
488355
)
489356
_delete_if_exists(
490357
rbac_api.delete_namespaced_role, "proxies", namespace, logger=logger
491358
)
492-
_delete_if_exists(
493-
core_api.delete_namespaced_service_account,
494-
operator_sa,
495-
namespace,
496-
logger=logger,
497-
)
498359
_delete_if_exists(
499360
core_api.delete_namespaced_service_account, "proxies", namespace, logger=logger
500361
)
501-
_delete_if_exists(
502-
core_api.delete_namespaced_secret, jwt_secret_name, namespace, logger=logger
503-
)
362+
_delete_proxy_group(custom_api, name, logger)
363+
_delete_tailnet(custom_api, name, logger)
504364
_delete_if_exists(
505365
core_api.delete_namespaced_secret,
506-
f"{operator_sa}-state",
507-
namespace,
508-
logger=logger,
366+
tailnet_secret_name, operator_namespace, logger=logger,
509367
)
510368

511-
_remove_cluster_role_binding_subject(rbac_api, namespace, operator_sa, logger)
512-
513-
logger.info(f"Cleaned up all Tailscale operator resources in {namespace}")
369+
logger.info(f"Cleaned up Tailscale resources for {name}")

0 commit comments

Comments
 (0)