Archived
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7491d3e0 | ||
|
|
e7483bc7d1 |
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -161,66 +161,70 @@ class ClusterInfo:
|
||||
return nodeports
|
||||
|
||||
def get_ingress(
|
||||
self, use_tls=False, certificate=None, cluster_issuer="letsencrypt-prod"
|
||||
self, use_tls=False, certificates=None, cluster_issuer="letsencrypt-prod"
|
||||
):
|
||||
# No ingress for a deployment that has no http-proxy defined, for now
|
||||
http_proxy_info_list = self.spec.get_http_proxy()
|
||||
ingress = None
|
||||
if http_proxy_info_list:
|
||||
# TODO: handle multiple definitions
|
||||
http_proxy_info = http_proxy_info_list[0]
|
||||
if opts.o.debug:
|
||||
print(f"http-proxy: {http_proxy_info}")
|
||||
# TODO: good enough parsing for webapp deployment for now
|
||||
host_name = http_proxy_info["host-name"]
|
||||
rules = []
|
||||
tls = (
|
||||
[
|
||||
client.V1IngressTLS(
|
||||
hosts=certificate["spec"]["dnsNames"]
|
||||
if certificate
|
||||
else [host_name],
|
||||
secret_name=certificate["spec"]["secretName"]
|
||||
if certificate
|
||||
else f"{self.app_name}-tls",
|
||||
)
|
||||
]
|
||||
if use_tls
|
||||
else None
|
||||
)
|
||||
paths = []
|
||||
for route in http_proxy_info["routes"]:
|
||||
path = route["path"]
|
||||
proxy_to = route["proxy-to"]
|
||||
tls = [] if use_tls else None
|
||||
|
||||
for http_proxy_info in http_proxy_info_list:
|
||||
if opts.o.debug:
|
||||
print(f"proxy config: {path} -> {proxy_to}")
|
||||
# proxy_to has the form <service>:<port>
|
||||
proxy_to_port = int(proxy_to.split(":")[1])
|
||||
paths.append(
|
||||
client.V1HTTPIngressPath(
|
||||
path_type="Prefix",
|
||||
path=path,
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
# TODO: this looks wrong
|
||||
name=f"{self.app_name}-service",
|
||||
# TODO: pull port number from the service
|
||||
port=client.V1ServiceBackendPort(number=proxy_to_port),
|
||||
)
|
||||
),
|
||||
print(f"http-proxy: {http_proxy_info}")
|
||||
host_name = http_proxy_info["host-name"]
|
||||
certificate = (certificates or {}).get(host_name)
|
||||
|
||||
if use_tls:
|
||||
tls.append(
|
||||
client.V1IngressTLS(
|
||||
hosts=certificate["spec"]["dnsNames"]
|
||||
if certificate
|
||||
else [host_name],
|
||||
secret_name=certificate["spec"]["secretName"]
|
||||
if certificate
|
||||
else f"{self.app_name}-{host_name}-tls",
|
||||
)
|
||||
)
|
||||
|
||||
paths = []
|
||||
for route in http_proxy_info["routes"]:
|
||||
path = route["path"]
|
||||
proxy_to = route["proxy-to"]
|
||||
if opts.o.debug:
|
||||
print(f"proxy config: {path} -> {proxy_to}")
|
||||
# proxy_to has the form <service>:<port>
|
||||
proxy_to_port = int(proxy_to.split(":")[1])
|
||||
paths.append(
|
||||
client.V1HTTPIngressPath(
|
||||
path_type="Prefix",
|
||||
path=path,
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
# TODO: this looks wrong
|
||||
name=f"{self.app_name}-service",
|
||||
# TODO: pull port number from the service
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=proxy_to_port
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
rules.append(
|
||||
client.V1IngressRule(
|
||||
host=host_name,
|
||||
http=client.V1HTTPIngressRuleValue(paths=paths),
|
||||
)
|
||||
)
|
||||
rules.append(
|
||||
client.V1IngressRule(
|
||||
host=host_name, http=client.V1HTTPIngressRuleValue(paths=paths)
|
||||
)
|
||||
)
|
||||
|
||||
spec = client.V1IngressSpec(tls=tls, rules=rules)
|
||||
|
||||
ingress_annotations = {
|
||||
"kubernetes.io/ingress.class": "caddy",
|
||||
}
|
||||
if not certificate:
|
||||
if not certificates:
|
||||
ingress_annotations["cert-manager.io/cluster-issuer"] = cluster_issuer
|
||||
|
||||
ingress = client.V1Ingress(
|
||||
@@ -273,19 +277,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 +311,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 +363,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 +394,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 +407,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 +460,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 +581,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 +590,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 +683,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 +702,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 +732,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-<name>.yml -> <name>
|
||||
@@ -722,6 +759,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",
|
||||
|
||||
@@ -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()
|
||||
@@ -426,17 +513,19 @@ class K8sDeployer(Deployer):
|
||||
http_proxy_info = self.cluster_info.spec.get_http_proxy()
|
||||
# Note: we don't support tls for kind (enabling tls causes errors)
|
||||
use_tls = http_proxy_info and not self.is_kind()
|
||||
certificate = (
|
||||
self._find_certificate_for_host_name(http_proxy_info[0]["host-name"])
|
||||
if use_tls
|
||||
else None
|
||||
)
|
||||
if opts.o.debug:
|
||||
if certificate:
|
||||
print(f"Using existing certificate: {certificate}")
|
||||
certificates = None
|
||||
if use_tls:
|
||||
certificates = {}
|
||||
for proxy in http_proxy_info:
|
||||
host_name = proxy["host-name"]
|
||||
cert = self._find_certificate_for_host_name(host_name)
|
||||
if cert:
|
||||
certificates[host_name] = cert
|
||||
if opts.o.debug:
|
||||
print(f"Using existing certificate for {host_name}: {cert}")
|
||||
|
||||
ingress = self.cluster_info.get_ingress(
|
||||
use_tls=use_tls, certificate=certificate
|
||||
use_tls=use_tls, certificates=certificates
|
||||
)
|
||||
if ingress:
|
||||
if opts.o.debug:
|
||||
@@ -473,11 +562,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 +581,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
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user