so-m3m: add credentials-files spec key for on-disk credential injection
_write_config_file() now reads each file listed under the credentials-files top-level spec key and appends its contents to config.env after config vars. Paths support ~ expansion. Missing files fail hard with sys.exit(1). Also adds get_credentials_files() to Spec class following the same pattern as get_image_registry_config(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>afd-dumpster-local-testing
parent
0e4ecc3602
commit
25e5ff09d9
|
|
@ -695,6 +695,19 @@ def _write_config_file(
|
||||||
continue
|
continue
|
||||||
output_file.write(f"{variable_name}={variable_value}\n")
|
output_file.write(f"{variable_name}={variable_value}\n")
|
||||||
|
|
||||||
|
# Append contents of credentials files listed in spec
|
||||||
|
credentials_files = spec_content.get("credentials-files", []) or []
|
||||||
|
for cred_path_str in credentials_files:
|
||||||
|
cred_path = Path(cred_path_str).expanduser()
|
||||||
|
if not cred_path.exists():
|
||||||
|
print(f"Error: credentials file does not exist: {cred_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
output_file.write(f"# From credentials file: {cred_path_str}\n")
|
||||||
|
contents = cred_path.read_text()
|
||||||
|
output_file.write(contents)
|
||||||
|
if not contents.endswith("\n"):
|
||||||
|
output_file.write("\n")
|
||||||
|
|
||||||
|
|
||||||
def _write_kube_config_file(external_path: Path, internal_path: Path):
|
def _write_kube_config_file(external_path: Path, internal_path: Path):
|
||||||
if not external_path.exists():
|
if not external_path.exists():
|
||||||
|
|
@ -1041,12 +1054,8 @@ def _write_deployment_files(
|
||||||
for configmap in parsed_spec.get_configmaps():
|
for configmap in parsed_spec.get_configmaps():
|
||||||
source_config_dir = resolve_config_dir(stack_name, configmap)
|
source_config_dir = resolve_config_dir(stack_name, configmap)
|
||||||
if os.path.exists(source_config_dir):
|
if os.path.exists(source_config_dir):
|
||||||
destination_config_dir = target_dir.joinpath(
|
destination_config_dir = target_dir.joinpath("configmaps", configmap)
|
||||||
"configmaps", configmap
|
copytree(source_config_dir, destination_config_dir, dirs_exist_ok=True)
|
||||||
)
|
|
||||||
copytree(
|
|
||||||
source_config_dir, destination_config_dir, dirs_exist_ok=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy the job files into the target dir
|
# Copy the job files into the target dir
|
||||||
jobs = get_job_list(parsed_stack)
|
jobs = get_job_list(parsed_stack)
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,14 @@ class ClusterInfo:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.parsed_job_yaml_map = {}
|
self.parsed_job_yaml_map = {}
|
||||||
|
|
||||||
def int(self, pod_files: List[str], compose_env_file, deployment_name, spec: Spec, stack_name=""):
|
def int(
|
||||||
|
self,
|
||||||
|
pod_files: List[str],
|
||||||
|
compose_env_file,
|
||||||
|
deployment_name,
|
||||||
|
spec: Spec,
|
||||||
|
stack_name="",
|
||||||
|
):
|
||||||
self.parsed_pod_yaml_map = parsed_pod_files_map_from_file_names(pod_files)
|
self.parsed_pod_yaml_map = parsed_pod_files_map_from_file_names(pod_files)
|
||||||
# Find the set of images in the pods
|
# Find the set of images in the pods
|
||||||
self.image_set = images_for_deployment(pod_files)
|
self.image_set = images_for_deployment(pod_files)
|
||||||
|
|
@ -314,8 +321,7 @@ class ClusterInfo:
|
||||||
|
|
||||||
# Per-volume resources override global, which overrides default.
|
# Per-volume resources override global, which overrides default.
|
||||||
vol_resources = (
|
vol_resources = (
|
||||||
self.spec.get_volume_resources_for(volume_name)
|
self.spec.get_volume_resources_for(volume_name) or global_resources
|
||||||
or global_resources
|
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
|
@ -417,8 +423,7 @@ class ClusterInfo:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
vol_resources = (
|
vol_resources = (
|
||||||
self.spec.get_volume_resources_for(volume_name)
|
self.spec.get_volume_resources_for(volume_name) or global_resources
|
||||||
or global_resources
|
|
||||||
)
|
)
|
||||||
if self.spec.is_kind_deployment():
|
if self.spec.is_kind_deployment():
|
||||||
host_path = client.V1HostPathVolumeSource(
|
host_path = client.V1HostPathVolumeSource(
|
||||||
|
|
@ -554,9 +559,7 @@ class ClusterInfo:
|
||||||
if self.spec.get_image_registry() is not None
|
if self.spec.get_image_registry() is not None
|
||||||
else image
|
else image
|
||||||
)
|
)
|
||||||
volume_mounts = volume_mounts_for_service(
|
volume_mounts = volume_mounts_for_service(parsed_yaml_map, service_name)
|
||||||
parsed_yaml_map, service_name
|
|
||||||
)
|
|
||||||
# Handle command/entrypoint from compose file
|
# Handle command/entrypoint from compose file
|
||||||
# In docker-compose: entrypoint -> k8s command, command -> k8s args
|
# In docker-compose: entrypoint -> k8s command, command -> k8s args
|
||||||
container_command = None
|
container_command = None
|
||||||
|
|
@ -615,7 +618,9 @@ class ClusterInfo:
|
||||||
readiness_probe=readiness_probe,
|
readiness_probe=readiness_probe,
|
||||||
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,
|
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()
|
||||||
)
|
)
|
||||||
|
|
@ -629,19 +634,17 @@ class ClusterInfo:
|
||||||
svc_labels = service_info.get("labels", {})
|
svc_labels = service_info.get("labels", {})
|
||||||
if isinstance(svc_labels, list):
|
if isinstance(svc_labels, list):
|
||||||
# docker-compose labels can be a list of "key=value"
|
# docker-compose labels can be a list of "key=value"
|
||||||
svc_labels = dict(
|
svc_labels = dict(item.split("=", 1) for item in svc_labels)
|
||||||
item.split("=", 1) for item in svc_labels
|
is_init = str(svc_labels.get("laconic.init-container", "")).lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
)
|
)
|
||||||
is_init = str(
|
|
||||||
svc_labels.get("laconic.init-container", "")
|
|
||||||
).lower() in ("true", "1", "yes")
|
|
||||||
if is_init:
|
if is_init:
|
||||||
init_containers.append(container)
|
init_containers.append(container)
|
||||||
else:
|
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, init_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
|
||||||
|
|
@ -738,7 +741,14 @@ class ClusterInfo:
|
||||||
kind="Deployment",
|
kind="Deployment",
|
||||||
metadata=client.V1ObjectMeta(
|
metadata=client.V1ObjectMeta(
|
||||||
name=f"{self.app_name}-deployment",
|
name=f"{self.app_name}-deployment",
|
||||||
labels={"app": self.app_name, **({"app.kubernetes.io/stack": self.stack_name} if self.stack_name else {})},
|
labels={
|
||||||
|
"app": self.app_name,
|
||||||
|
**(
|
||||||
|
{"app.kubernetes.io/stack": self.stack_name}
|
||||||
|
if self.stack_name
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
spec=spec,
|
spec=spec,
|
||||||
)
|
)
|
||||||
|
|
@ -766,8 +776,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, init_containers, _services, volumes = (
|
containers, init_containers, _services, volumes = self._build_containers(
|
||||||
self._build_containers(single_job_map, image_pull_policy)
|
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>
|
||||||
|
|
@ -785,12 +795,14 @@ class ClusterInfo:
|
||||||
# picked up by pods_in_deployment() which queries app={app_name}.
|
# picked up by pods_in_deployment() which queries app={app_name}.
|
||||||
pod_labels = {
|
pod_labels = {
|
||||||
"app": f"{self.app_name}-job",
|
"app": f"{self.app_name}-job",
|
||||||
**({"app.kubernetes.io/stack": self.stack_name} if self.stack_name else {}),
|
**(
|
||||||
|
{"app.kubernetes.io/stack": self.stack_name}
|
||||||
|
if self.stack_name
|
||||||
|
else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
template = client.V1PodTemplateSpec(
|
template = client.V1PodTemplateSpec(
|
||||||
metadata=client.V1ObjectMeta(
|
metadata=client.V1ObjectMeta(labels=pod_labels),
|
||||||
labels=pod_labels
|
|
||||||
),
|
|
||||||
spec=client.V1PodSpec(
|
spec=client.V1PodSpec(
|
||||||
containers=containers,
|
containers=containers,
|
||||||
init_containers=init_containers or None,
|
init_containers=init_containers or None,
|
||||||
|
|
@ -803,7 +815,14 @@ class ClusterInfo:
|
||||||
template=template,
|
template=template,
|
||||||
backoff_limit=0,
|
backoff_limit=0,
|
||||||
)
|
)
|
||||||
job_labels = {"app": self.app_name, **({"app.kubernetes.io/stack": self.stack_name} if self.stack_name else {})}
|
job_labels = {
|
||||||
|
"app": self.app_name,
|
||||||
|
**(
|
||||||
|
{"app.kubernetes.io/stack": self.stack_name}
|
||||||
|
if self.stack_name
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
job = client.V1Job(
|
job = client.V1Job(
|
||||||
api_version="batch/v1",
|
api_version="batch/v1",
|
||||||
kind="Job",
|
kind="Job",
|
||||||
|
|
|
||||||
|
|
@ -122,9 +122,13 @@ 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 = deployment_context.spec.get_kind_cluster_name() or 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
|
# 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.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.
|
||||||
|
|
@ -269,7 +273,8 @@ class K8sDeployer(Deployer):
|
||||||
for job in jobs.items:
|
for job in jobs.items:
|
||||||
print(f"Deleting Job {job.metadata.name}")
|
print(f"Deleting Job {job.metadata.name}")
|
||||||
self.batch_api.delete_namespaced_job(
|
self.batch_api.delete_namespaced_job(
|
||||||
name=job.metadata.name, namespace=ns,
|
name=job.metadata.name,
|
||||||
|
namespace=ns,
|
||||||
body=client.V1DeleteOptions(propagation_policy="Background"),
|
body=client.V1DeleteOptions(propagation_policy="Background"),
|
||||||
)
|
)
|
||||||
except ApiException as e:
|
except ApiException as e:
|
||||||
|
|
@ -406,9 +411,7 @@ class K8sDeployer(Deployer):
|
||||||
print("No pods defined, skipping Deployment creation")
|
print("No pods defined, skipping Deployment creation")
|
||||||
return
|
return
|
||||||
# Process compose files into a Deployment
|
# Process compose files into a Deployment
|
||||||
deployment = self.cluster_info.get_deployment(
|
deployment = self.cluster_info.get_deployment(image_pull_policy="Always")
|
||||||
image_pull_policy="Always"
|
|
||||||
)
|
|
||||||
# Create or update the k8s Deployment
|
# Create or update the k8s Deployment
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"Sending this deployment: {deployment}")
|
print(f"Sending this deployment: {deployment}")
|
||||||
|
|
@ -470,9 +473,7 @@ class K8sDeployer(Deployer):
|
||||||
|
|
||||||
def _create_jobs(self):
|
def _create_jobs(self):
|
||||||
# Process job compose files into k8s Jobs
|
# Process job compose files into k8s Jobs
|
||||||
jobs = self.cluster_info.get_jobs(
|
jobs = self.cluster_info.get_jobs(image_pull_policy="Always")
|
||||||
image_pull_policy="Always"
|
|
||||||
)
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"Sending this job: {job}")
|
print(f"Sending this job: {job}")
|
||||||
|
|
@ -646,7 +647,10 @@ class K8sDeployer(Deployer):
|
||||||
|
|
||||||
# Call start() hooks — stacks can create additional k8s resources
|
# Call start() hooks — stacks can create additional k8s resources
|
||||||
if self.deployment_context:
|
if self.deployment_context:
|
||||||
from stack_orchestrator.deploy.deployment_create import call_stack_deploy_start
|
from stack_orchestrator.deploy.deployment_create import (
|
||||||
|
call_stack_deploy_start,
|
||||||
|
)
|
||||||
|
|
||||||
call_stack_deploy_start(self.deployment_context)
|
call_stack_deploy_start(self.deployment_context)
|
||||||
|
|
||||||
def down(self, timeout, volumes, skip_cluster_management):
|
def down(self, timeout, volumes, skip_cluster_management):
|
||||||
|
|
@ -658,9 +662,7 @@ class K8sDeployer(Deployer):
|
||||||
# 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=app_label)
|
||||||
label_selector=app_label
|
|
||||||
)
|
|
||||||
for pv in pvs.items:
|
for pv in pvs.items:
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"Deleting PV: {pv.metadata.name}")
|
print(f"Deleting PV: {pv.metadata.name}")
|
||||||
|
|
@ -804,14 +806,18 @@ class K8sDeployer(Deployer):
|
||||||
|
|
||||||
def logs(self, services, tail, follow, stream):
|
def logs(self, services, tail, follow, stream):
|
||||||
self.connect_api()
|
self.connect_api()
|
||||||
pods = pods_in_deployment(self.core_api, self.cluster_info.app_name, namespace=self.k8s_namespace)
|
pods = pods_in_deployment(
|
||||||
|
self.core_api, self.cluster_info.app_name, namespace=self.k8s_namespace
|
||||||
|
)
|
||||||
if len(pods) > 1:
|
if len(pods) > 1:
|
||||||
print("Warning: more than one pod in the deployment")
|
print("Warning: more than one pod in the deployment")
|
||||||
if len(pods) == 0:
|
if len(pods) == 0:
|
||||||
log_data = "******* Pods not running ********\n"
|
log_data = "******* Pods not running ********\n"
|
||||||
else:
|
else:
|
||||||
k8s_pod_name = pods[0]
|
k8s_pod_name = pods[0]
|
||||||
containers = containers_in_pod(self.core_api, k8s_pod_name, namespace=self.k8s_namespace)
|
containers = containers_in_pod(
|
||||||
|
self.core_api, k8s_pod_name, namespace=self.k8s_namespace
|
||||||
|
)
|
||||||
# If pod not started, logs request below will throw an exception
|
# If pod not started, logs request below will throw an exception
|
||||||
try:
|
try:
|
||||||
log_data = ""
|
log_data = ""
|
||||||
|
|
@ -910,9 +916,7 @@ class K8sDeployer(Deployer):
|
||||||
else:
|
else:
|
||||||
# Non-Helm path: create job from ClusterInfo
|
# Non-Helm path: create job from ClusterInfo
|
||||||
self.connect_api()
|
self.connect_api()
|
||||||
jobs = self.cluster_info.get_jobs(
|
jobs = self.cluster_info.get_jobs(image_pull_policy="Always")
|
||||||
image_pull_policy="Always"
|
|
||||||
)
|
|
||||||
# Find the matching job by name
|
# Find the matching job by name
|
||||||
target_name = f"{self.cluster_info.app_name}-job-{job_name}"
|
target_name = f"{self.cluster_info.app_name}-job-{job_name}"
|
||||||
matched_job = None
|
matched_job = None
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,9 @@ def load_images_into_kind(kind_cluster_name: str, image_set: Set[str]):
|
||||||
raise DeployerException(f"kind load docker-image failed: {result}")
|
raise DeployerException(f"kind load docker-image failed: {result}")
|
||||||
|
|
||||||
|
|
||||||
def pods_in_deployment(core_api: client.CoreV1Api, deployment_name: str, namespace: str = "default"):
|
def pods_in_deployment(
|
||||||
|
core_api: client.CoreV1Api, deployment_name: str, namespace: str = "default"
|
||||||
|
):
|
||||||
pods = []
|
pods = []
|
||||||
pod_response = core_api.list_namespaced_pod(
|
pod_response = core_api.list_namespaced_pod(
|
||||||
namespace=namespace, label_selector=f"app={deployment_name}"
|
namespace=namespace, label_selector=f"app={deployment_name}"
|
||||||
|
|
@ -406,7 +408,9 @@ def pods_in_deployment(core_api: client.CoreV1Api, deployment_name: str, namespa
|
||||||
return pods
|
return pods
|
||||||
|
|
||||||
|
|
||||||
def containers_in_pod(core_api: client.CoreV1Api, pod_name: str, namespace: str = "default") -> List[str]:
|
def containers_in_pod(
|
||||||
|
core_api: client.CoreV1Api, pod_name: str, namespace: str = "default"
|
||||||
|
) -> List[str]:
|
||||||
containers: List[str] = []
|
containers: List[str] = []
|
||||||
pod_response = cast(
|
pod_response = cast(
|
||||||
client.V1Pod, core_api.read_namespaced_pod(pod_name, namespace=namespace)
|
client.V1Pod, core_api.read_namespaced_pod(pod_name, namespace=namespace)
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,10 @@ class Spec:
|
||||||
def get_image_registry(self):
|
def get_image_registry(self):
|
||||||
return self.obj.get(constants.image_registry_key)
|
return self.obj.get(constants.image_registry_key)
|
||||||
|
|
||||||
|
def get_credentials_files(self) -> typing.List[str]:
|
||||||
|
"""Returns list of credential file paths to append to config.env."""
|
||||||
|
return self.obj.get("credentials-files", [])
|
||||||
|
|
||||||
def get_image_registry_config(self) -> typing.Optional[typing.Dict]:
|
def get_image_registry_config(self) -> typing.Optional[typing.Dict]:
|
||||||
"""Returns registry auth config: {server, username, token-env}.
|
"""Returns registry auth config: {server, username, token-env}.
|
||||||
|
|
||||||
|
|
@ -167,15 +171,13 @@ class Spec:
|
||||||
Returns the per-volume Resources if found, otherwise None.
|
Returns the per-volume Resources if found, otherwise None.
|
||||||
The caller should fall back to get_volume_resources() then the default.
|
The caller should fall back to get_volume_resources() then the default.
|
||||||
"""
|
"""
|
||||||
vol_section = (
|
vol_section = self.obj.get(constants.resources_key, {}).get(
|
||||||
self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {})
|
constants.volumes_key, {}
|
||||||
)
|
)
|
||||||
if volume_name not in vol_section:
|
if volume_name not in vol_section:
|
||||||
return None
|
return None
|
||||||
entry = vol_section[volume_name]
|
entry = vol_section[volume_name]
|
||||||
if isinstance(entry, dict) and (
|
if isinstance(entry, dict) and ("reservations" in entry or "limits" in entry):
|
||||||
"reservations" in entry or "limits" in entry
|
|
||||||
):
|
|
||||||
return Resources(entry)
|
return Resources(entry)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue