1111"""
1212
1313import asyncio
14+ import hashlib
1415import ipaddress
16+ import json
1517import logging
1618import os
1719import subprocess
5961custom_api = kubernetes .client .CustomObjectsApi ()
6062rbac_v1 = kubernetes .client .RbacAuthorizationV1Api ()
6163
64+ _dynamic_client = None
65+
66+ def get_dynamic_client ():
67+ """Return a cached DynamicClient instance."""
68+ global _dynamic_client
69+ if _dynamic_client is None :
70+ _dynamic_client = kubernetes .dynamic .DynamicClient (kubernetes .client .ApiClient ())
71+ return _dynamic_client
72+
6273
6374# ── Helpers ───────────────────────────────────────────────────────────────────
6475
@@ -85,7 +96,7 @@ def scc_name_for_spec(spec: dict) -> str:
8596 """Return the least-privilege SCC name for this instance spec."""
8697 if spec .get ("shell" , {}).get ("enabled" , False ):
8798 return SCC_SHELL
88- if spec .get ("storage" , {}).get ("hostPath" , {}).get ("enabled" , False ):
99+ if spec .get ("storage" , {}).get ("hostPath" , {}).get ("path" ):
89100 return SCC_HOSTPATH
90101 return SCC_RESTRICTED
91102
@@ -326,7 +337,7 @@ def ensure_scc_binding(service_account: str, namespace: str, scc_name: str):
326337 sa_name = f"{ service_account } -tinycode"
327338 sa_ref = f"system:serviceaccount:{ namespace } :{ sa_name } "
328339
329- dyn_client = kubernetes . dynamic . DynamicClient ( kubernetes . client . ApiClient () )
340+ dyn_client = get_dynamic_client ( )
330341 scc_api = dyn_client .resources .get (
331342 api_version = "security.openshift.io/v1" ,
332343 kind = "SecurityContextConstraints" ,
@@ -350,7 +361,7 @@ def remove_scc_binding(name: str, namespace: str):
350361 """Remove the SA from all tinycode SCCs on instance deletion."""
351362 sa_ref = f"system:serviceaccount:{ namespace } :{ name } -tinycode"
352363 try :
353- dyn_client = kubernetes . dynamic . DynamicClient ( kubernetes . client . ApiClient () )
364+ dyn_client = get_dynamic_client ( )
354365 scc_api = dyn_client .resources .get (
355366 api_version = "security.openshift.io/v1" ,
356367 kind = "SecurityContextConstraints" ,
@@ -599,7 +610,7 @@ def validate_cluster_admin(name: str, namespace: str, spec: dict) -> list[dict]:
599610def set_status (name : str , namespace : str , phase : str , ready : bool , message : str ,
600611 url : str = "" , tool_calling_warnings : list | None = None ,
601612 cluster_admin_warnings : list | None = None ,
602- vllm_config_ready : bool | None = None ):
613+ vllm_config_ready : bool | None = None , ** kwargs ):
603614 """Patch the TinycodeInstance status."""
604615 now = time .strftime ("%Y-%m-%dT%H:%M:%SZ" , time .gmtime ())
605616 conditions = [
@@ -671,6 +682,7 @@ def set_status(name: str, namespace: str, phase: str, ready: bool, message: str,
671682 "phase" : phase ,
672683 "url" : url ,
673684 "conditions" : conditions ,
685+ "observedGeneration" : kwargs .get ("body" , {}).get ("metadata" , {}).get ("generation" , 0 ),
674686 }
675687 }
676688 try :
@@ -689,9 +701,7 @@ def set_status(name: str, namespace: str, phase: str, ready: bool, message: str,
689701def get_route_url (name : str , namespace : str ) -> str :
690702 """Return the external URL of the tinycode Route, if it exists."""
691703 try :
692- dyn_client = kubernetes .dynamic .DynamicClient (
693- kubernetes .client .ApiClient ()
694- )
704+ dyn_client = get_dynamic_client ()
695705 route_api = dyn_client .resources .get (
696706 api_version = "route.openshift.io/v1" , kind = "Route"
697707 )
@@ -728,7 +738,7 @@ async def reconcile(
728738 3. Fetch the Route URL and update status.
729739 """
730740 logger .info ("Reconciling TinycodeInstance %s/%s" , namespace , name )
731- set_status (name , namespace , "Deploying" , False , "Reconciliation in progress" )
741+ set_status (name , namespace , "Deploying" , False , "Reconciliation in progress" , ** kwargs )
732742
733743 # Step 1: Validate git spec
734744 git_warnings = await asyncio .get_event_loop ().run_in_executor (
@@ -739,7 +749,7 @@ async def reconcile(
739749 for w in git_errors :
740750 logger .error ("git validation error: %s" , w ["message" ])
741751 msg = f"git validation failed: { git_errors [0 ]['message' ]} "
742- set_status (name , namespace , "Failed" , False , msg )
752+ set_status (name , namespace , "Failed" , False , msg , ** kwargs )
743753 raise kopf .PermanentError (msg )
744754 if git_warnings :
745755 for w in git_warnings :
@@ -754,7 +764,7 @@ async def reconcile(
754764 for w in workspace_errors :
755765 logger .error ("shared workspace validation error: %s" , w ["message" ])
756766 msg = f"shared workspace validation failed: { workspace_errors [0 ]['message' ]} "
757- set_status (name , namespace , "Failed" , False , msg )
767+ set_status (name , namespace , "Failed" , False , msg , ** kwargs )
758768 raise kopf .PermanentError (msg )
759769 if workspace_warnings :
760770 for w in workspace_warnings :
@@ -769,7 +779,7 @@ async def reconcile(
769779 for w in ca_errors :
770780 logger .error ("clusterAdmin validation error: %s" , w ["message" ])
771781 msg = f"clusterAdmin validation failed: { ca_errors [0 ]['message' ]} "
772- set_status (name , namespace , "Failed" , False , msg , cluster_admin_warnings = ca_warnings )
782+ set_status (name , namespace , "Failed" , False , msg , cluster_admin_warnings = ca_warnings , ** kwargs )
773783 raise kopf .PermanentError (msg )
774784 if ca_warnings :
775785 for w in ca_warnings :
@@ -783,7 +793,7 @@ async def reconcile(
783793 except Exception as exc :
784794 msg = f"Failed to bind SCC { scc } : { exc } "
785795 logger .error (msg )
786- set_status (name , namespace , "Failed" , False , msg )
796+ set_status (name , namespace , "Failed" , False , msg , ** kwargs )
787797 raise kopf .PermanentError (msg ) from exc
788798
789799 # Step 3: Build vLLM config and Helm values
@@ -795,6 +805,34 @@ async def reconcile(
795805
796806 values = helm_values_for_spec (name , namespace , spec )
797807 release = helm_release_name (name , namespace )
808+
809+ # Compute hash of Helm values to skip no-op upgrades
810+ values_hash = hashlib .sha256 (json .dumps (values , sort_keys = True ).encode ()).hexdigest ()[:16 ]
811+
812+ # Check if spec has changed
813+ try :
814+ cr = custom_api .get_namespaced_custom_object (
815+ group = GROUP , version = VERSION , namespace = namespace , plural = PLURAL , name = name
816+ )
817+ current_hash = cr .get ("metadata" , {}).get ("annotations" , {}).get ("tinycode.dev/values-hash" , "" )
818+ if current_hash == values_hash :
819+ logger .info ("Spec unchanged (hash=%s), skipping helm upgrade" , values_hash )
820+ # Still update status in case route or other external state changed
821+ url = get_route_url (name , namespace )
822+ tc_warnings = await asyncio .get_event_loop ().run_in_executor (
823+ None , lambda : check_vllm_tool_calling (namespace )
824+ )
825+ msg = f"TinycodeInstance { name } unchanged"
826+ if url :
827+ msg += f". URL: { url } "
828+ ca_warnings_for_status = ca_warnings if spec .get ("clusterAdmin" , {}).get ("enabled" , False ) else None
829+ set_status (name , namespace , "Running" , True , msg , url = url ,
830+ tool_calling_warnings = tc_warnings , cluster_admin_warnings = ca_warnings_for_status ,
831+ vllm_config_ready = vllm_config_ready , ** kwargs )
832+ return
833+ except Exception :
834+ pass # First reconcile or annotation missing — proceed with upgrade
835+
798836 helm_args = ["upgrade" , "--install" ]
799837
800838 ok , output = await asyncio .get_event_loop ().run_in_executor (
@@ -803,7 +841,7 @@ async def reconcile(
803841 if not ok :
804842 msg = f"Helm failed: { output [:500 ]} "
805843 logger .error (msg )
806- set_status (name , namespace , "Failed" , False , msg , vllm_config_ready = vllm_config_ready )
844+ set_status (name , namespace , "Failed" , False , msg , vllm_config_ready = vllm_config_ready , ** kwargs )
807845 raise kopf .TemporaryError (msg , delay = 60 )
808846
809847 # Step 4: Check vLLM tool calling in the namespace and any configured URLs
@@ -825,7 +863,21 @@ async def reconcile(
825863 ca_warnings_for_status = ca_warnings if spec .get ("clusterAdmin" , {}).get ("enabled" , False ) else None
826864 set_status (name , namespace , "Running" , True , msg , url = url ,
827865 tool_calling_warnings = tc_warnings , cluster_admin_warnings = ca_warnings_for_status ,
828- vllm_config_ready = vllm_config_ready )
866+ vllm_config_ready = vllm_config_ready , ** kwargs )
867+
868+ # Update values hash annotation after successful reconcile
869+ try :
870+ custom_api .patch_namespaced_custom_object (
871+ group = GROUP ,
872+ version = VERSION ,
873+ namespace = namespace ,
874+ plural = PLURAL ,
875+ name = name ,
876+ body = {"metadata" : {"annotations" : {"tinycode.dev/values-hash" : values_hash }}},
877+ )
878+ except Exception as exc :
879+ logger .warning ("Failed to update values-hash annotation: %s" , exc )
880+
829881 logger .info ("Reconcile complete: %s" , msg )
830882
831883
0 commit comments