88LABEL_KEY = "cabotage.io/tailscale-operator"
99LABEL_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
1913def _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-
201148def _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
248209def _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" )
469344def 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