From 1da69cf739352ffc8632c7612dd5a2b3541b8afc Mon Sep 17 00:00:00 2001 From: "A. F. Dudley" Date: Sun, 8 Mar 2026 04:15:03 +0000 Subject: [PATCH] fix(k8s): make deploy_k8s.py idempotent with create-or-replace semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All K8s resource creation in deploy_k8s.py now uses try-create, catch ApiException(409), then replace — matching the pattern already used for secrets in deployment_create.py. This allows `deployment start` to be safely re-run without 409 Conflict errors. Resources made idempotent: - Deployment (create_namespaced_deployment → replace on 409) - Service (create_namespaced_service → replace on 409) - Ingress (create_namespaced_ingress → replace on 409) - NodePort services (same as Service) - ConfigMap (create_namespaced_config_map → replace on 409) - PV/PVC: bare `except: pass` replaced with explicit ApiException catch for 404 Extracted _ensure_deployment(), _ensure_service(), _ensure_ingress(), and _ensure_config_map() helpers to keep cyclomatic complexity in check. Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/deploy_k8s.py | 149 ++++++++++++++------ 1 file changed, 104 insertions(+), 45 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index 3b235538..c0272be7 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -192,6 +192,99 @@ class K8sDeployer(Deployer): else: raise + def _ensure_config_map(self, cfg_map): + """Create or replace a ConfigMap (idempotent).""" + try: + resp = self.core_api.create_namespaced_config_map( + body=cfg_map, namespace=self.k8s_namespace + ) + if opts.o.debug: + print(f"ConfigMap created: {resp}") + except ApiException as e: + if e.status == 409: + resp = self.core_api.replace_namespaced_config_map( + name=cfg_map.metadata.name, + namespace=self.k8s_namespace, + body=cfg_map, + ) + if opts.o.debug: + print(f"ConfigMap updated: {resp}") + else: + raise + + def _ensure_deployment(self, deployment): + """Create or replace a Deployment (idempotent).""" + try: + resp = cast( + client.V1Deployment, + self.apps_api.create_namespaced_deployment( + body=deployment, namespace=self.k8s_namespace + ), + ) + if opts.o.debug: + print("Deployment created:") + except ApiException as e: + if e.status == 409: + resp = cast( + client.V1Deployment, + self.apps_api.replace_namespaced_deployment( + name=deployment.metadata.name, + namespace=self.k8s_namespace, + body=deployment, + ), + ) + if opts.o.debug: + print("Deployment updated:") + else: + raise + if opts.o.debug: + meta = resp.metadata + spec = resp.spec + if meta and spec and spec.template.spec: + containers = spec.template.spec.containers + img = containers[0].image if containers else None + print(f"{meta.namespace} {meta.name} {meta.generation} {img}") + + def _ensure_service(self, service, kind: str = "Service"): + """Create or replace a Service (idempotent).""" + try: + resp = self.core_api.create_namespaced_service( + namespace=self.k8s_namespace, body=service + ) + if opts.o.debug: + print(f"{kind} created: {resp}") + except ApiException as e: + if e.status == 409: + resp = self.core_api.replace_namespaced_service( + name=service.metadata.name, + namespace=self.k8s_namespace, + body=service, + ) + if opts.o.debug: + print(f"{kind} updated: {resp}") + else: + raise + + def _ensure_ingress(self, ingress): + """Create or replace an Ingress (idempotent).""" + try: + resp = self.networking_api.create_namespaced_ingress( + namespace=self.k8s_namespace, body=ingress + ) + if opts.o.debug: + print(f"Ingress created: {resp}") + except ApiException as e: + if e.status == 409: + resp = self.networking_api.replace_namespaced_ingress( + name=ingress.metadata.name, + namespace=self.k8s_namespace, + body=ingress, + ) + if opts.o.debug: + print(f"Ingress updated: {resp}") + else: + raise + def _create_volume_data(self): # Create the host-path-mounted PVs for this deployment pvs = self.cluster_info.get_pvs() @@ -208,8 +301,9 @@ class K8sDeployer(Deployer): print("PVs already present:") print(f"{pv_resp}") continue - except: # noqa: E722 - pass + except ApiException as e: + if e.status != 404: + raise pv_resp = self.core_api.create_persistent_volume(body=pv) if opts.o.debug: @@ -232,8 +326,9 @@ class K8sDeployer(Deployer): print("PVCs already present:") print(f"{pvc_resp}") continue - except: # noqa: E722 - pass + except ApiException as e: + if e.status != 404: + raise pvc_resp = self.core_api.create_namespaced_persistent_volume_claim( body=pvc, namespace=self.k8s_namespace @@ -248,12 +343,7 @@ class K8sDeployer(Deployer): if opts.o.debug: print(f"Sending this ConfigMap: {cfg_map}") if not opts.o.dry_run: - cfg_rsp = self.core_api.create_namespaced_config_map( - body=cfg_map, namespace=self.k8s_namespace - ) - if opts.o.debug: - print("ConfigMap created:") - print(f"{cfg_rsp}") + self._ensure_config_map(cfg_map) def _create_deployment(self): # Process compose files into a Deployment @@ -264,34 +354,13 @@ class K8sDeployer(Deployer): if opts.o.debug: print(f"Sending this deployment: {deployment}") if not opts.o.dry_run: - deployment_resp = cast( - client.V1Deployment, - self.apps_api.create_namespaced_deployment( - body=deployment, namespace=self.k8s_namespace - ), - ) - if opts.o.debug: - print("Deployment created:") - meta = deployment_resp.metadata - spec = deployment_resp.spec - if meta and spec and spec.template.spec: - ns = meta.namespace - name = meta.name - gen = meta.generation - containers = spec.template.spec.containers - img = containers[0].image if containers else None - print(f"{ns} {name} {gen} {img}") + self._ensure_deployment(deployment) service = self.cluster_info.get_service() if opts.o.debug: print(f"Sending this service: {service}") if service and not opts.o.dry_run: - service_resp = self.core_api.create_namespaced_service( - namespace=self.k8s_namespace, body=service - ) - if opts.o.debug: - print("Service created:") - print(f"{service_resp}") + self._ensure_service(service) def _find_certificate_for_host_name(self, host_name): all_certificates = self.custom_obj_api.list_namespaced_custom_object( @@ -404,12 +473,7 @@ class K8sDeployer(Deployer): if opts.o.debug: print(f"Sending this ingress: {ingress}") if not opts.o.dry_run: - ingress_resp = self.networking_api.create_namespaced_ingress( - namespace=self.k8s_namespace, body=ingress - ) - if opts.o.debug: - print("Ingress created:") - print(f"{ingress_resp}") + self._ensure_ingress(ingress) else: if opts.o.debug: print("No ingress configured") @@ -419,12 +483,7 @@ class K8sDeployer(Deployer): if opts.o.debug: print(f"Sending this nodeport: {nodeport}") if not opts.o.dry_run: - nodeport_resp = self.core_api.create_namespaced_service( - namespace=self.k8s_namespace, body=nodeport - ) - if opts.o.debug: - print("NodePort created:") - print(f"{nodeport_resp}") + self._ensure_service(nodeport, kind="NodePort") def down(self, timeout, volumes, skip_cluster_management): self.skip_cluster_management = skip_cluster_management