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
A. F. Dudley 2026-03-18 21:55:28 +00:00
parent 0e4ecc3602
commit 25e5ff09d9
6 changed files with 97 additions and 59 deletions

View File

@ -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)

View File

@ -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",
is_init = str( "1",
svc_labels.get("laconic.init-container", "") "yes",
).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>
@ -775,7 +785,7 @@ class ClusterInfo:
# Strip docker-compose- prefix and .yml suffix # Strip docker-compose- prefix and .yml suffix
job_name = base job_name = base
if job_name.startswith("docker-compose-"): if job_name.startswith("docker-compose-"):
job_name = job_name[len("docker-compose-"):] job_name = job_name[len("docker-compose-") :]
if job_name.endswith(".yml"): if job_name.endswith(".yml"):
job_name = job_name[: -len(".yml")] job_name = job_name[: -len(".yml")]
elif job_name.endswith(".yaml"): elif job_name.endswith(".yaml"):
@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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