From 14f423ea0c04c624ebaca9be5d6a223bc7402ef1 Mon Sep 17 00:00:00 2001 From: "A. F. Dudley" Date: Sun, 8 Mar 2026 04:32:20 +0000 Subject: [PATCH] fix(k8s): read existing resourceVersion/clusterIP before replace K8s PUT (replace) operations require metadata.resourceVersion for optimistic concurrency control. Services additionally have immutable spec.clusterIP that must be preserved from the existing object. On 409 conflict, all _ensure_* methods now read the existing resource first and copy resourceVersion (and clusterIP for Services) into the body before calling replace. Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/deploy_k8s.py | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index c0272be7..b34e3291 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -202,6 +202,10 @@ class K8sDeployer(Deployer): print(f"ConfigMap created: {resp}") except ApiException as e: if e.status == 409: + existing = self.core_api.read_namespaced_config_map( + name=cfg_map.metadata.name, namespace=self.k8s_namespace + ) + cfg_map.metadata.resource_version = existing.metadata.resource_version resp = self.core_api.replace_namespaced_config_map( name=cfg_map.metadata.name, namespace=self.k8s_namespace, @@ -225,6 +229,13 @@ class K8sDeployer(Deployer): print("Deployment created:") except ApiException as e: if e.status == 409: + existing = self.apps_api.read_namespaced_deployment( + name=deployment.metadata.name, + namespace=self.k8s_namespace, + ) + deployment.metadata.resource_version = ( + existing.metadata.resource_version + ) resp = cast( client.V1Deployment, self.apps_api.replace_namespaced_deployment( @@ -246,7 +257,11 @@ class K8sDeployer(Deployer): print(f"{meta.namespace} {meta.name} {meta.generation} {img}") def _ensure_service(self, service, kind: str = "Service"): - """Create or replace a Service (idempotent).""" + """Create or replace a Service (idempotent). + + Services have immutable fields (spec.clusterIP) that must be + preserved from the existing object on replace. + """ try: resp = self.core_api.create_namespaced_service( namespace=self.k8s_namespace, body=service @@ -255,6 +270,12 @@ class K8sDeployer(Deployer): print(f"{kind} created: {resp}") except ApiException as e: if e.status == 409: + existing = self.core_api.read_namespaced_service( + name=service.metadata.name, namespace=self.k8s_namespace + ) + service.metadata.resource_version = existing.metadata.resource_version + if existing.spec.cluster_ip: + service.spec.cluster_ip = existing.spec.cluster_ip resp = self.core_api.replace_namespaced_service( name=service.metadata.name, namespace=self.k8s_namespace, @@ -275,6 +296,10 @@ class K8sDeployer(Deployer): print(f"Ingress created: {resp}") except ApiException as e: if e.status == 409: + existing = self.networking_api.read_namespaced_ingress( + name=ingress.metadata.name, namespace=self.k8s_namespace + ) + ingress.metadata.resource_version = existing.metadata.resource_version resp = self.networking_api.replace_namespaced_ingress( name=ingress.metadata.name, namespace=self.k8s_namespace,