forked from cerc-io/stack-orchestrator
Add init containers, shared namespaces, per-volume sizing, and user/label support (#997)
Reviewed-on: cerc-io/stack-orchestrator#997 Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com> Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
This commit is contained in:
parent
5af6a83fa2
commit
e7483bc7d1
@ -21,7 +21,7 @@ from stack_orchestrator.deploy.deploy_util import VolumeMapping, run_container_c
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
default_spec_file_content = """config:
|
default_spec_file_content = """config:
|
||||||
test-variable-1: test-value-1
|
test_variable_1: test-value-1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -273,19 +273,25 @@ class ClusterInfo:
|
|||||||
result = []
|
result = []
|
||||||
spec_volumes = self.spec.get_volumes()
|
spec_volumes = self.spec.get_volumes()
|
||||||
named_volumes = self._all_named_volumes()
|
named_volumes = self._all_named_volumes()
|
||||||
resources = self.spec.get_volume_resources()
|
global_resources = self.spec.get_volume_resources()
|
||||||
if not resources:
|
if not global_resources:
|
||||||
resources = DEFAULT_VOLUME_RESOURCES
|
global_resources = DEFAULT_VOLUME_RESOURCES
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"Spec Volumes: {spec_volumes}")
|
print(f"Spec Volumes: {spec_volumes}")
|
||||||
print(f"Named Volumes: {named_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():
|
for volume_name, volume_path in spec_volumes.items():
|
||||||
if volume_name not in named_volumes:
|
if volume_name not in named_volumes:
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"{volume_name} not in pod files")
|
print(f"{volume_name} not in pod files")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Per-volume resources override global, which overrides default.
|
||||||
|
vol_resources = (
|
||||||
|
self.spec.get_volume_resources_for(volume_name)
|
||||||
|
or global_resources
|
||||||
|
)
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
"app": self.app_name,
|
"app": self.app_name,
|
||||||
"volume-label": f"{self.app_name}-{volume_name}",
|
"volume-label": f"{self.app_name}-{volume_name}",
|
||||||
@ -301,7 +307,7 @@ class ClusterInfo:
|
|||||||
spec = client.V1PersistentVolumeClaimSpec(
|
spec = client.V1PersistentVolumeClaimSpec(
|
||||||
access_modes=["ReadWriteOnce"],
|
access_modes=["ReadWriteOnce"],
|
||||||
storage_class_name=storage_class_name,
|
storage_class_name=storage_class_name,
|
||||||
resources=to_k8s_resource_requirements(resources),
|
resources=to_k8s_resource_requirements(vol_resources),
|
||||||
volume_name=k8s_volume_name,
|
volume_name=k8s_volume_name,
|
||||||
)
|
)
|
||||||
pvc = client.V1PersistentVolumeClaim(
|
pvc = client.V1PersistentVolumeClaim(
|
||||||
@ -353,9 +359,9 @@ class ClusterInfo:
|
|||||||
result = []
|
result = []
|
||||||
spec_volumes = self.spec.get_volumes()
|
spec_volumes = self.spec.get_volumes()
|
||||||
named_volumes = self._all_named_volumes()
|
named_volumes = self._all_named_volumes()
|
||||||
resources = self.spec.get_volume_resources()
|
global_resources = self.spec.get_volume_resources()
|
||||||
if not resources:
|
if not global_resources:
|
||||||
resources = DEFAULT_VOLUME_RESOURCES
|
global_resources = DEFAULT_VOLUME_RESOURCES
|
||||||
for volume_name, volume_path in spec_volumes.items():
|
for volume_name, volume_path in spec_volumes.items():
|
||||||
# We only need to create a volume if it is fully qualified HostPath.
|
# 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
|
# Otherwise, we create the PVC and expect the node to allocate the volume
|
||||||
@ -384,6 +390,10 @@ class ClusterInfo:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
vol_resources = (
|
||||||
|
self.spec.get_volume_resources_for(volume_name)
|
||||||
|
or global_resources
|
||||||
|
)
|
||||||
if self.spec.is_kind_deployment():
|
if self.spec.is_kind_deployment():
|
||||||
host_path = client.V1HostPathVolumeSource(
|
host_path = client.V1HostPathVolumeSource(
|
||||||
path=get_kind_pv_bind_mount_path(volume_name)
|
path=get_kind_pv_bind_mount_path(volume_name)
|
||||||
@ -393,7 +403,7 @@ class ClusterInfo:
|
|||||||
spec = client.V1PersistentVolumeSpec(
|
spec = client.V1PersistentVolumeSpec(
|
||||||
storage_class_name="manual",
|
storage_class_name="manual",
|
||||||
access_modes=["ReadWriteOnce"],
|
access_modes=["ReadWriteOnce"],
|
||||||
capacity=to_k8s_resource_requirements(resources).requests,
|
capacity=to_k8s_resource_requirements(vol_resources).requests,
|
||||||
host_path=host_path,
|
host_path=host_path,
|
||||||
)
|
)
|
||||||
pv = client.V1PersistentVolume(
|
pv = client.V1PersistentVolume(
|
||||||
@ -446,12 +456,16 @@ class ClusterInfo:
|
|||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Build k8s container specs from parsed compose YAML.
|
"""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
|
- 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)
|
- services: the last services dict processed (used for annotations/labels)
|
||||||
- volumes: list of V1Volume objects
|
- volumes: list of V1Volume objects
|
||||||
"""
|
"""
|
||||||
containers = []
|
containers = []
|
||||||
|
init_containers = []
|
||||||
services = {}
|
services = {}
|
||||||
global_resources = self.spec.get_container_resources()
|
global_resources = self.spec.get_container_resources()
|
||||||
if not global_resources:
|
if not global_resources:
|
||||||
@ -563,6 +577,7 @@ class ClusterInfo:
|
|||||||
volume_mounts=volume_mounts,
|
volume_mounts=volume_mounts,
|
||||||
security_context=client.V1SecurityContext(
|
security_context=client.V1SecurityContext(
|
||||||
privileged=self.spec.get_privileged(),
|
privileged=self.spec.get_privileged(),
|
||||||
|
run_as_user=int(service_info["user"]) if "user" in service_info else None,
|
||||||
capabilities=client.V1Capabilities(
|
capabilities=client.V1Capabilities(
|
||||||
add=self.spec.get_capabilities()
|
add=self.spec.get_capabilities()
|
||||||
)
|
)
|
||||||
@ -571,15 +586,29 @@ class ClusterInfo:
|
|||||||
),
|
),
|
||||||
resources=to_k8s_resource_requirements(container_resources),
|
resources=to_k8s_resource_requirements(container_resources),
|
||||||
)
|
)
|
||||||
|
# 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)
|
containers.append(container)
|
||||||
volumes = volumes_for_pod_files(
|
volumes = volumes_for_pod_files(
|
||||||
parsed_yaml_map, self.spec, self.app_name
|
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
|
# TODO: put things like image pull policy into an object-scope struct
|
||||||
def get_deployment(self, image_pull_policy: Optional[str] = None):
|
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
|
self.parsed_pod_yaml_map, image_pull_policy
|
||||||
)
|
)
|
||||||
registry_config = self.spec.get_image_registry_config()
|
registry_config = self.spec.get_image_registry_config()
|
||||||
@ -650,6 +679,7 @@ class ClusterInfo:
|
|||||||
metadata=client.V1ObjectMeta(annotations=annotations, labels=labels),
|
metadata=client.V1ObjectMeta(annotations=annotations, labels=labels),
|
||||||
spec=client.V1PodSpec(
|
spec=client.V1PodSpec(
|
||||||
containers=containers,
|
containers=containers,
|
||||||
|
init_containers=init_containers or None,
|
||||||
image_pull_secrets=image_pull_secrets,
|
image_pull_secrets=image_pull_secrets,
|
||||||
volumes=volumes,
|
volumes=volumes,
|
||||||
affinity=affinity,
|
affinity=affinity,
|
||||||
@ -668,7 +698,10 @@ class ClusterInfo:
|
|||||||
deployment = client.V1Deployment(
|
deployment = client.V1Deployment(
|
||||||
api_version="apps/v1",
|
api_version="apps/v1",
|
||||||
kind="Deployment",
|
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,
|
spec=spec,
|
||||||
)
|
)
|
||||||
return deployment
|
return deployment
|
||||||
@ -695,8 +728,8 @@ class ClusterInfo:
|
|||||||
for job_file in self.parsed_job_yaml_map:
|
for job_file in self.parsed_job_yaml_map:
|
||||||
# Build containers for this single job file
|
# Build containers for this single job file
|
||||||
single_job_map = {job_file: self.parsed_job_yaml_map[job_file]}
|
single_job_map = {job_file: self.parsed_job_yaml_map[job_file]}
|
||||||
containers, _services, volumes = self._build_containers(
|
containers, init_containers, _services, volumes = (
|
||||||
single_job_map, image_pull_policy
|
self._build_containers(single_job_map, image_pull_policy)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Derive job name from file path: docker-compose-<name>.yml -> <name>
|
# Derive job name from file path: docker-compose-<name>.yml -> <name>
|
||||||
@ -722,6 +755,7 @@ class ClusterInfo:
|
|||||||
),
|
),
|
||||||
spec=client.V1PodSpec(
|
spec=client.V1PodSpec(
|
||||||
containers=containers,
|
containers=containers,
|
||||||
|
init_containers=init_containers or None,
|
||||||
image_pull_secrets=image_pull_secrets,
|
image_pull_secrets=image_pull_secrets,
|
||||||
volumes=volumes,
|
volumes=volumes,
|
||||||
restart_policy="Never",
|
restart_policy="Never",
|
||||||
|
|||||||
@ -122,9 +122,9 @@ class K8sDeployer(Deployer):
|
|||||||
return
|
return
|
||||||
self.deployment_dir = deployment_context.deployment_dir
|
self.deployment_dir = deployment_context.deployment_dir
|
||||||
self.deployment_context = deployment_context
|
self.deployment_context = deployment_context
|
||||||
self.kind_cluster_name = compose_project_name
|
self.kind_cluster_name = deployment_context.spec.get_kind_cluster_name() or compose_project_name
|
||||||
# Use deployment-specific namespace for resource isolation and easy cleanup
|
# Use spec namespace if provided, otherwise derive from cluster-id
|
||||||
self.k8s_namespace = f"laconic-{compose_project_name}"
|
self.k8s_namespace = deployment_context.spec.get_namespace() or f"laconic-{compose_project_name}"
|
||||||
self.cluster_info = ClusterInfo()
|
self.cluster_info = ClusterInfo()
|
||||||
# stack.name may be an absolute path (from spec "stack:" key after
|
# stack.name may be an absolute path (from spec "stack:" key after
|
||||||
# path resolution). Extract just the directory basename for labels.
|
# path resolution). Extract just the directory basename for labels.
|
||||||
@ -204,6 +204,93 @@ class K8sDeployer(Deployer):
|
|||||||
else:
|
else:
|
||||||
raise
|
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):
|
def _create_volume_data(self):
|
||||||
# Create the host-path-mounted PVs for this deployment
|
# Create the host-path-mounted PVs for this deployment
|
||||||
pvs = self.cluster_info.get_pvs()
|
pvs = self.cluster_info.get_pvs()
|
||||||
@ -473,11 +560,13 @@ class K8sDeployer(Deployer):
|
|||||||
self.skip_cluster_management = skip_cluster_management
|
self.skip_cluster_management = skip_cluster_management
|
||||||
self.connect_api()
|
self.connect_api()
|
||||||
|
|
||||||
|
app_label = f"app={self.cluster_info.app_name}"
|
||||||
|
|
||||||
# PersistentVolumes are cluster-scoped (not namespaced), so delete by label
|
# PersistentVolumes are cluster-scoped (not namespaced), so delete by label
|
||||||
if volumes:
|
if volumes:
|
||||||
try:
|
try:
|
||||||
pvs = self.core_api.list_persistent_volume(
|
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:
|
for pv in pvs.items:
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
@ -490,8 +579,13 @@ class K8sDeployer(Deployer):
|
|||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"Error listing PVs: {e}")
|
print(f"Error listing PVs: {e}")
|
||||||
|
|
||||||
# Delete the deployment namespace - this cascades to all namespaced resources
|
# When namespace is explicitly set in the spec, it may be shared with
|
||||||
# (PVCs, ConfigMaps, Deployments, Services, Ingresses, etc.)
|
# 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()
|
self._delete_namespace()
|
||||||
|
|
||||||
if self.is_kind() and not self.skip_cluster_management:
|
if self.is_kind() and not self.skip_cluster_management:
|
||||||
|
|||||||
@ -149,9 +149,48 @@ class Spec:
|
|||||||
self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {})
|
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):
|
def get_http_proxy(self):
|
||||||
return self.obj.get(constants.network_key, {}).get(constants.http_proxy_key, [])
|
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):
|
def get_annotations(self):
|
||||||
return self.obj.get(constants.annotations_key, {})
|
return self.obj.get(constants.annotations_key, {})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user