diff --git a/stack_orchestrator/data/stacks/test/deploy/commands.py b/stack_orchestrator/data/stacks/test/deploy/commands.py index 356338af..1740199b 100644 --- a/stack_orchestrator/data/stacks/test/deploy/commands.py +++ b/stack_orchestrator/data/stacks/test/deploy/commands.py @@ -21,7 +21,7 @@ from stack_orchestrator.deploy.deploy_util import VolumeMapping, run_container_c from pathlib import Path default_spec_file_content = """config: - test-variable-1: test-value-1 + test_variable_1: test-value-1 """ diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 8c530fc9..1d95fa6a 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -273,19 +273,25 @@ class ClusterInfo: result = [] spec_volumes = self.spec.get_volumes() named_volumes = self._all_named_volumes() - resources = self.spec.get_volume_resources() - if not resources: - resources = DEFAULT_VOLUME_RESOURCES + global_resources = self.spec.get_volume_resources() + if not global_resources: + global_resources = DEFAULT_VOLUME_RESOURCES if opts.o.debug: print(f"Spec Volumes: {spec_volumes}") print(f"Named Volumes: {named_volumes}") - print(f"Resources: {resources}") + print(f"Resources: {global_resources}") for volume_name, volume_path in spec_volumes.items(): if volume_name not in named_volumes: if opts.o.debug: print(f"{volume_name} not in pod files") continue + # Per-volume resources override global, which overrides default. + vol_resources = ( + self.spec.get_volume_resources_for(volume_name) + or global_resources + ) + labels = { "app": self.app_name, "volume-label": f"{self.app_name}-{volume_name}", @@ -301,7 +307,7 @@ class ClusterInfo: spec = client.V1PersistentVolumeClaimSpec( access_modes=["ReadWriteOnce"], storage_class_name=storage_class_name, - resources=to_k8s_resource_requirements(resources), + resources=to_k8s_resource_requirements(vol_resources), volume_name=k8s_volume_name, ) pvc = client.V1PersistentVolumeClaim( @@ -353,9 +359,9 @@ class ClusterInfo: result = [] spec_volumes = self.spec.get_volumes() named_volumes = self._all_named_volumes() - resources = self.spec.get_volume_resources() - if not resources: - resources = DEFAULT_VOLUME_RESOURCES + global_resources = self.spec.get_volume_resources() + if not global_resources: + global_resources = DEFAULT_VOLUME_RESOURCES for volume_name, volume_path in spec_volumes.items(): # We only need to create a volume if it is fully qualified HostPath. # Otherwise, we create the PVC and expect the node to allocate the volume @@ -384,6 +390,10 @@ class ClusterInfo: ) continue + vol_resources = ( + self.spec.get_volume_resources_for(volume_name) + or global_resources + ) if self.spec.is_kind_deployment(): host_path = client.V1HostPathVolumeSource( path=get_kind_pv_bind_mount_path(volume_name) @@ -393,7 +403,7 @@ class ClusterInfo: spec = client.V1PersistentVolumeSpec( storage_class_name="manual", access_modes=["ReadWriteOnce"], - capacity=to_k8s_resource_requirements(resources).requests, + capacity=to_k8s_resource_requirements(vol_resources).requests, host_path=host_path, ) pv = client.V1PersistentVolume( @@ -446,12 +456,16 @@ class ClusterInfo: ) -> tuple: """Build k8s container specs from parsed compose YAML. - Returns a tuple of (containers, services, volumes) where: + Returns a tuple of (containers, init_containers, services, volumes) + where: - containers: list of V1Container objects + - init_containers: list of V1Container objects for init containers + (compose services with label ``laconic.init-container: "true"``) - services: the last services dict processed (used for annotations/labels) - volumes: list of V1Volume objects """ containers = [] + init_containers = [] services = {} global_resources = self.spec.get_container_resources() if not global_resources: @@ -563,6 +577,7 @@ class ClusterInfo: volume_mounts=volume_mounts, security_context=client.V1SecurityContext( privileged=self.spec.get_privileged(), + run_as_user=int(service_info["user"]) if "user" in service_info else None, capabilities=client.V1Capabilities( add=self.spec.get_capabilities() ) @@ -571,15 +586,29 @@ class ClusterInfo: ), resources=to_k8s_resource_requirements(container_resources), ) - containers.append(container) + # Services with laconic.init-container label become + # k8s init containers instead of regular containers. + svc_labels = service_info.get("labels", {}) + if isinstance(svc_labels, list): + # docker-compose labels can be a list of "key=value" + svc_labels = dict( + item.split("=", 1) for item in svc_labels + ) + is_init = str( + svc_labels.get("laconic.init-container", "") + ).lower() in ("true", "1", "yes") + if is_init: + init_containers.append(container) + else: + containers.append(container) volumes = volumes_for_pod_files( parsed_yaml_map, self.spec, self.app_name ) - return containers, services, volumes + return containers, init_containers, services, volumes # TODO: put things like image pull policy into an object-scope struct def get_deployment(self, image_pull_policy: Optional[str] = None): - containers, services, volumes = self._build_containers( + containers, init_containers, services, volumes = self._build_containers( self.parsed_pod_yaml_map, image_pull_policy ) registry_config = self.spec.get_image_registry_config() @@ -650,6 +679,7 @@ class ClusterInfo: metadata=client.V1ObjectMeta(annotations=annotations, labels=labels), spec=client.V1PodSpec( containers=containers, + init_containers=init_containers or None, image_pull_secrets=image_pull_secrets, volumes=volumes, affinity=affinity, @@ -668,7 +698,10 @@ class ClusterInfo: deployment = client.V1Deployment( api_version="apps/v1", kind="Deployment", - metadata=client.V1ObjectMeta(name=f"{self.app_name}-deployment"), + metadata=client.V1ObjectMeta( + name=f"{self.app_name}-deployment", + labels={"app": self.app_name, **({"app.kubernetes.io/stack": self.stack_name} if self.stack_name else {})}, + ), spec=spec, ) return deployment @@ -695,8 +728,8 @@ class ClusterInfo: for job_file in self.parsed_job_yaml_map: # Build containers for this single job file single_job_map = {job_file: self.parsed_job_yaml_map[job_file]} - containers, _services, volumes = self._build_containers( - single_job_map, image_pull_policy + containers, init_containers, _services, volumes = ( + self._build_containers(single_job_map, image_pull_policy) ) # Derive job name from file path: docker-compose-.yml -> @@ -722,6 +755,7 @@ class ClusterInfo: ), spec=client.V1PodSpec( containers=containers, + init_containers=init_containers or None, image_pull_secrets=image_pull_secrets, volumes=volumes, restart_policy="Never", diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index 9ad61838..d19b26d8 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -122,9 +122,9 @@ class K8sDeployer(Deployer): return self.deployment_dir = deployment_context.deployment_dir self.deployment_context = deployment_context - self.kind_cluster_name = compose_project_name - # Use deployment-specific namespace for resource isolation and easy cleanup - self.k8s_namespace = f"laconic-{compose_project_name}" + self.kind_cluster_name = deployment_context.spec.get_kind_cluster_name() or compose_project_name + # Use spec namespace if provided, otherwise derive from cluster-id + self.k8s_namespace = deployment_context.spec.get_namespace() or f"laconic-{compose_project_name}" self.cluster_info = ClusterInfo() # stack.name may be an absolute path (from spec "stack:" key after # path resolution). Extract just the directory basename for labels. @@ -204,6 +204,93 @@ class K8sDeployer(Deployer): else: raise + def _delete_resources_by_label(self, label_selector: str, delete_volumes: bool): + """Delete only this stack's resources from a shared namespace.""" + ns = self.k8s_namespace + if opts.o.dry_run: + print(f"Dry run: would delete resources with {label_selector} in {ns}") + return + + # Deployments + try: + deps = self.apps_api.list_namespaced_deployment( + namespace=ns, label_selector=label_selector + ) + for dep in deps.items: + print(f"Deleting Deployment {dep.metadata.name}") + self.apps_api.delete_namespaced_deployment( + name=dep.metadata.name, namespace=ns + ) + except ApiException as e: + _check_delete_exception(e) + + # Jobs + try: + jobs = self.batch_api.list_namespaced_job( + namespace=ns, label_selector=label_selector + ) + for job in jobs.items: + print(f"Deleting Job {job.metadata.name}") + self.batch_api.delete_namespaced_job( + name=job.metadata.name, namespace=ns, + body=client.V1DeleteOptions(propagation_policy="Background"), + ) + except ApiException as e: + _check_delete_exception(e) + + # Services (NodePorts created by SO) + try: + svcs = self.core_api.list_namespaced_service( + namespace=ns, label_selector=label_selector + ) + for svc in svcs.items: + print(f"Deleting Service {svc.metadata.name}") + self.core_api.delete_namespaced_service( + name=svc.metadata.name, namespace=ns + ) + except ApiException as e: + _check_delete_exception(e) + + # Ingresses + try: + ings = self.networking_api.list_namespaced_ingress( + namespace=ns, label_selector=label_selector + ) + for ing in ings.items: + print(f"Deleting Ingress {ing.metadata.name}") + self.networking_api.delete_namespaced_ingress( + name=ing.metadata.name, namespace=ns + ) + except ApiException as e: + _check_delete_exception(e) + + # ConfigMaps + try: + cms = self.core_api.list_namespaced_config_map( + namespace=ns, label_selector=label_selector + ) + for cm in cms.items: + print(f"Deleting ConfigMap {cm.metadata.name}") + self.core_api.delete_namespaced_config_map( + name=cm.metadata.name, namespace=ns + ) + except ApiException as e: + _check_delete_exception(e) + + # PVCs (only if --delete-volumes) + if delete_volumes: + try: + pvcs = self.core_api.list_namespaced_persistent_volume_claim( + namespace=ns, label_selector=label_selector + ) + for pvc in pvcs.items: + print(f"Deleting PVC {pvc.metadata.name}") + self.core_api.delete_namespaced_persistent_volume_claim( + name=pvc.metadata.name, namespace=ns + ) + except ApiException as e: + _check_delete_exception(e) + def _create_volume_data(self): # Create the host-path-mounted PVs for this deployment pvs = self.cluster_info.get_pvs() @@ -473,11 +560,13 @@ class K8sDeployer(Deployer): self.skip_cluster_management = skip_cluster_management self.connect_api() + app_label = f"app={self.cluster_info.app_name}" + # PersistentVolumes are cluster-scoped (not namespaced), so delete by label if volumes: try: pvs = self.core_api.list_persistent_volume( - label_selector=f"app={self.cluster_info.app_name}" + label_selector=app_label ) for pv in pvs.items: if opts.o.debug: @@ -490,9 +579,14 @@ class K8sDeployer(Deployer): if opts.o.debug: print(f"Error listing PVs: {e}") - # Delete the deployment namespace - this cascades to all namespaced resources - # (PVCs, ConfigMaps, Deployments, Services, Ingresses, etc.) - self._delete_namespace() + # When namespace is explicitly set in the spec, it may be shared with + # other stacks — delete only this stack's resources by label. + # Otherwise the namespace is owned by this deployment, delete it entirely. + shared_namespace = self.deployment_context.spec.get_namespace() is not None + if shared_namespace: + self._delete_resources_by_label(app_label, volumes) + else: + self._delete_namespace() if self.is_kind() and not self.skip_cluster_management: # Destroy the kind cluster diff --git a/stack_orchestrator/deploy/spec.py b/stack_orchestrator/deploy/spec.py index e77b9581..83762b35 100644 --- a/stack_orchestrator/deploy/spec.py +++ b/stack_orchestrator/deploy/spec.py @@ -149,9 +149,48 @@ class Spec: self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {}) ) + def get_volume_resources_for(self, volume_name: str) -> typing.Optional[Resources]: + """Look up per-volume resource overrides from spec.yml. + + Supports two formats under resources.volumes: + + Global (original): + resources: + volumes: + reservations: + storage: 5Gi + + Per-volume (new): + resources: + volumes: + my-volume: + reservations: + storage: 10Gi + + Returns the per-volume Resources if found, otherwise None. + The caller should fall back to get_volume_resources() then the default. + """ + vol_section = ( + self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {}) + ) + if volume_name not in vol_section: + return None + entry = vol_section[volume_name] + if isinstance(entry, dict) and ( + "reservations" in entry or "limits" in entry + ): + return Resources(entry) + return None + def get_http_proxy(self): return self.obj.get(constants.network_key, {}).get(constants.http_proxy_key, []) + def get_namespace(self): + return self.obj.get("namespace") + + def get_kind_cluster_name(self): + return self.obj.get("kind-cluster-name") + def get_annotations(self): return self.obj.get(constants.annotations_key, {})