diff --git a/stack_orchestrator/deploy/k8s/helpers.py b/stack_orchestrator/deploy/k8s/helpers.py index a125d4f5..f7603b5e 100644 --- a/stack_orchestrator/deploy/k8s/helpers.py +++ b/stack_orchestrator/deploy/k8s/helpers.py @@ -27,6 +27,48 @@ from stack_orchestrator.deploy.deployer import DeployerException from stack_orchestrator import constants +def is_host_path_mount(volume_name: str) -> bool: + """Check if a volume name is a host path mount (starts with /, ., or ~).""" + return volume_name.startswith(("/", ".", "~")) + + +def sanitize_host_path_to_volume_name(host_path: str) -> str: + """Convert a host path to a valid k8s volume name. + + K8s volume names must be lowercase, alphanumeric, with - allowed. + E.g., '../config/test/script.sh' -> 'host-path-config-test-script-sh' + """ + # Remove leading ./ or ../ + clean_path = re.sub(r"^\.+/", "", host_path) + # Replace path separators and dots with hyphens + name = re.sub(r"[/.]", "-", clean_path) + # Remove any non-alphanumeric characters except hyphens + name = re.sub(r"[^a-zA-Z0-9-]", "", name) + # Convert to lowercase + name = name.lower() + # Remove leading/trailing hyphens and collapse multiple hyphens + name = re.sub(r"-+", "-", name).strip("-") + # Prefix with 'host-path-' to distinguish from named volumes + return f"host-path-{name}" + + +def resolve_host_path_for_kind(host_path: str, deployment_dir: Path) -> Path: + """Resolve a host path mount (relative to compose file) to absolute path. + + Compose files are in deployment_dir/compose/, so '../config/foo' + resolves to deployment_dir/config/foo. + """ + # The path is relative to the compose directory + compose_dir = deployment_dir.joinpath("compose") + resolved = compose_dir.joinpath(host_path).resolve() + return resolved + + +def get_kind_host_path_mount_path(sanitized_name: str) -> str: + """Get the path inside the kind node where a host path mount will be available.""" + return f"/mnt/{sanitized_name}" + + def get_kind_cluster(): """Get an existing kind cluster, if any. @@ -177,6 +219,7 @@ def volume_mounts_for_service(parsed_pod_files, service): for mount_string in volumes: # Looks like: test-data:/data # or test-data:/data:ro or test-data:/data:rw + # or ../config/file.sh:/opt/file.sh (host path mount) if opts.o.debug: print(f"mount_string: {mount_string}") mount_split = mount_string.split(":") @@ -185,13 +228,21 @@ def volume_mounts_for_service(parsed_pod_files, service): mount_options = ( mount_split[2] if len(mount_split) == 3 else None ) + # For host path mounts, use sanitized name + if is_host_path_mount(volume_name): + k8s_volume_name = sanitize_host_path_to_volume_name( + volume_name + ) + else: + k8s_volume_name = volume_name if opts.o.debug: print(f"volume_name: {volume_name}") + print(f"k8s_volume_name: {k8s_volume_name}") print(f"mount path: {mount_path}") print(f"mount options: {mount_options}") volume_device = client.V1VolumeMount( mount_path=mount_path, - name=volume_name, + name=k8s_volume_name, read_only="ro" == mount_options, ) result.append(volume_device) @@ -200,8 +251,12 @@ def volume_mounts_for_service(parsed_pod_files, service): def volumes_for_pod_files(parsed_pod_files, spec, app_name): result = [] + seen_host_path_volumes = set() # Track host path volumes to avoid duplicates + for pod in parsed_pod_files: parsed_pod_file = parsed_pod_files[pod] + + # Handle named volumes from top-level volumes section if "volumes" in parsed_pod_file: volumes = parsed_pod_file["volumes"] for volume_name in volumes.keys(): @@ -220,6 +275,35 @@ def volumes_for_pod_files(parsed_pod_files, spec, app_name): name=volume_name, persistent_volume_claim=claim ) result.append(volume) + + # Handle host path mounts from service volumes + if "services" in parsed_pod_file: + services = parsed_pod_file["services"] + for service_name in services: + service_obj = services[service_name] + if "volumes" in service_obj: + for mount_string in service_obj["volumes"]: + mount_split = mount_string.split(":") + volume_source = mount_split[0] + if is_host_path_mount(volume_source): + sanitized_name = sanitize_host_path_to_volume_name( + volume_source + ) + if sanitized_name not in seen_host_path_volumes: + seen_host_path_volumes.add(sanitized_name) + # Create hostPath volume for mount inside kind node + kind_mount_path = get_kind_host_path_mount_path( + sanitized_name + ) + host_path_source = client.V1HostPathVolumeSource( + path=kind_mount_path, type="FileOrCreate" + ) + volume = client.V1Volume( + name=sanitized_name, host_path=host_path_source + ) + result.append(volume) + if opts.o.debug: + print(f"Created hostPath volume: {sanitized_name}") return result @@ -238,6 +322,8 @@ def _make_absolute_host_path(data_mount_path: Path, deployment_dir: Path) -> Pat def _generate_kind_mounts(parsed_pod_files, deployment_dir, deployment_context): volume_definitions = [] volume_host_path_map = _get_host_paths_for_volumes(deployment_context) + seen_host_path_mounts = set() # Track to avoid duplicate mounts + # Note these paths are relative to the location of the pod files (at present) # So we need to fix up to make them correct and absolute because kind assumes # relative to the cwd. @@ -252,28 +338,58 @@ def _generate_kind_mounts(parsed_pod_files, deployment_dir, deployment_context): for mount_string in volumes: # Looks like: test-data:/data # or test-data:/data:ro or test-data:/data:rw + # or ../config/file.sh:/opt/file.sh (host path mount) if opts.o.debug: print(f"mount_string: {mount_string}") mount_split = mount_string.split(":") volume_name = mount_split[0] mount_path = mount_split[1] - if opts.o.debug: - print(f"volume_name: {volume_name}") - print(f"map: {volume_host_path_map}") - print(f"mount path: {mount_path}") - if volume_name not in deployment_context.spec.get_configmaps(): - if volume_host_path_map[volume_name]: - host_path = _make_absolute_host_path( - volume_host_path_map[volume_name], - deployment_dir, + + if is_host_path_mount(volume_name): + # Host path mount - add extraMount for kind + sanitized_name = sanitize_host_path_to_volume_name( + volume_name + ) + if sanitized_name not in seen_host_path_mounts: + seen_host_path_mounts.add(sanitized_name) + # Resolve path relative to compose directory + host_path = resolve_host_path_for_kind( + volume_name, deployment_dir ) - container_path = get_kind_pv_bind_mount_path( - volume_name + container_path = get_kind_host_path_mount_path( + sanitized_name ) volume_definitions.append( f" - hostPath: {host_path}\n" f" containerPath: {container_path}\n" ) + if opts.o.debug: + print(f"Added host path mount: {host_path}") + else: + # Named volume + if opts.o.debug: + print(f"volume_name: {volume_name}") + print(f"map: {volume_host_path_map}") + print(f"mount path: {mount_path}") + if ( + volume_name + not in deployment_context.spec.get_configmaps() + ): + if ( + volume_name in volume_host_path_map + and volume_host_path_map[volume_name] + ): + host_path = _make_absolute_host_path( + volume_host_path_map[volume_name], + deployment_dir, + ) + container_path = get_kind_pv_bind_mount_path( + volume_name + ) + volume_definitions.append( + f" - hostPath: {host_path}\n" + f" containerPath: {container_path}\n" + ) return ( "" if len(volume_definitions) == 0