From dcba9e74d9488dd08c0848d51114ba72e5dfc42b Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Tue, 10 Mar 2026 10:38:24 +0000 Subject: [PATCH 1/7] feat(k8s): support init containers via compose labels Add support for k8s init containers defined in docker-compose files using the `laconic.init-container` label. Services with this label set to "true" are built as init containers instead of regular containers in the pod spec. This enables stacks to fetch runtime-created ConfigMaps (e.g. from deployer jobs) before the main containers start, without requiring manual operator steps between deployments. Example compose usage: services: fetch-config: image: bitnami/kubectl:latest labels: laconic.init-container: "true" command: ["sh", "-c", "kubectl get configmap ..."] volumes: - shared-config:/config Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/cluster_info.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 8c530fc9..1f6d14f2 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -446,12 +446,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: @@ -571,15 +575,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( + l.split("=", 1) for l 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 +668,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, @@ -695,8 +714,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 +741,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", -- 2.45.2 From cfbede2ee80bc0ed982dd95cc2034a5f723e2dd0 Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Tue, 10 Mar 2026 13:12:42 +0000 Subject: [PATCH 2/7] Support namespace and kind-cluster-name overrides in spec Add optional 'namespace' and 'kind-cluster-name' fields to spec files. When 'namespace' is set, SO uses it instead of deriving one from the cluster-id. When 'kind-cluster-name' is set, SO uses it for the kube context instead of the cluster-id. Together these allow multiple stacks with different cluster-ids (unique resource names) to share a namespace and kind cluster. Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/deploy_k8s.py | 6 +++--- stack_orchestrator/deploy/spec.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index 9ad61838..d9acf672 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. diff --git a/stack_orchestrator/deploy/spec.py b/stack_orchestrator/deploy/spec.py index e77b9581..effb4981 100644 --- a/stack_orchestrator/deploy/spec.py +++ b/stack_orchestrator/deploy/spec.py @@ -152,6 +152,12 @@ class Spec: 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, {}) -- 2.45.2 From da3850a7272ef49a42cb1a9f06815ff6054b87bc Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Wed, 11 Mar 2026 05:28:08 +0000 Subject: [PATCH 3/7] fix(k8s): delete resources by label in shared namespaces When the spec explicitly sets a namespace (shared with other stacks), down() now deletes only this stack's resources by app label instead of deleting the entire namespace. Prevents stopping one stack from wiping all other stacks in the same namespace. Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/deploy_k8s.py | 102 +++++++++++++++++++- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index d9acf672..d19b26d8 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -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 -- 2.45.2 From 577576fd6975115375b672f1963f4e8d7f502056 Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Wed, 11 Mar 2026 06:01:16 +0000 Subject: [PATCH 4/7] Add app label to Deployment metadata for label-based cleanup Deployments were the only resource type missing the `app` label on their metadata. Services, ConfigMaps, PVCs, and Jobs all had it. Without it, _delete_resources_by_label() in down() couldn't find Deployments when cleaning up in a shared namespace. Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/cluster_info.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 1f6d14f2..d1a9e72d 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -687,7 +687,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 -- 2.45.2 From 36385f065de317d259d9e4c87a95698d20736e4e Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Wed, 11 Mar 2026 06:24:50 +0000 Subject: [PATCH 5/7] Support compose user: directive as k8s runAsUser Read the user: field from compose service definitions and map it to securityContext.runAsUser on the k8s container spec. Needed for init containers using images that run as non-root by default (e.g. bitnami/kubectl runs as UID 1001). Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/cluster_info.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index d1a9e72d..5a202ebd 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -567,6 +567,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() ) -- 2.45.2 From a9bd11000121f5a1f80deb17ffb4c27a2d23ce8e Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Wed, 11 Mar 2026 09:12:14 +0000 Subject: [PATCH 6/7] Support per-volume resource sizing in spec files Add get_volume_resources_for(volume_name) to look up per-volume storage sizes from the spec. Supports both the original global format and a new per-volume format: # Global (unchanged, backwards compatible) resources: volumes: reservations: storage: 5Gi # Per-volume (new) resources: volumes: my-data: reservations: storage: 10Gi my-cache: reservations: storage: 1Gi Fallback chain: per-volume -> global -> default (2Gi). Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/deploy/k8s/cluster_info.py | 28 +++++++++++----- stack_orchestrator/deploy/spec.py | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 5a202ebd..9833c988 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( diff --git a/stack_orchestrator/deploy/spec.py b/stack_orchestrator/deploy/spec.py index effb4981..83762b35 100644 --- a/stack_orchestrator/deploy/spec.py +++ b/stack_orchestrator/deploy/spec.py @@ -149,6 +149,39 @@ 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, []) -- 2.45.2 From 6c00176048727053b494dbcb1e578e11491e163b Mon Sep 17 00:00:00 2001 From: Prathamesh Musale Date: Thu, 12 Mar 2026 10:32:57 +0000 Subject: [PATCH 7/7] Fix lint and deploy test failures - Rename ambiguous variable 'l' to 'item' in cluster_info.py (flake8 E741) - Use underscore in test config variable name (Docker Compose rejects hyphens in env var names) Co-Authored-By: Claude Opus 4.6 --- stack_orchestrator/data/stacks/test/deploy/commands.py | 2 +- stack_orchestrator/deploy/k8s/cluster_info.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 9833c988..1d95fa6a 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -592,7 +592,7 @@ class ClusterInfo: if isinstance(svc_labels, list): # docker-compose labels can be a list of "key=value" svc_labels = dict( - l.split("=", 1) for l in svc_labels + item.split("=", 1) for item in svc_labels ) is_init = str( svc_labels.get("laconic.init-container", "") -- 2.45.2