Compare commits

..

4 Commits

Author SHA1 Message Date
A. F. Dudley
1473c5ec4d Revert local k8s helpers changes
Some checks failed
Lint Checks / Run linter (pull_request) Successful in 16s
Lint Checks / Run linter (push) Successful in 16s
K8s Deployment Control Test / Run deployment control suite on kind/k8s (pull_request) Failing after 42s
K8s Deploy Test / Run deploy test suite on kind/k8s (pull_request) Failing after 1m43s
Deploy Test / Run deploy test suite (pull_request) Successful in 2m14s
External Stack Test / Run external stack test suite (push) Failing after 2m16s
Database Test / Run database hosting test on kind/k8s (push) Failing after 3m6s
Webapp Test / Run webapp test suite (pull_request) Successful in 3m45s
Smoke Test / Run basic test suite (pull_request) Successful in 3m45s
The hostPath support fix was merged via Roy's PR #984.
Reverting local changes to sync with upstream main.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:58:04 -05:00
A. F. Dudley
d31f76eb5a Add etcd + PKI extraMounts for offline data recovery
Mount /var/lib/etcd and /etc/kubernetes/pki to host filesystem
so cluster state is preserved for offline recovery. Each deployment
gets its own backup directory keyed by deployment ID.

Directory structure:
  data/cluster-backups/{deployment_id}/etcd/
  data/cluster-backups/{deployment_id}/pki/

This enables extracting secrets from etcd backups using etcdctl
with the preserved PKI certificates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:50:51 -05:00
A. F. Dudley
21d0975e71 feat(k8s): support acme-email config for Caddy ingress
Adds support for configuring ACME email for Let's Encrypt certificates
in kind deployments. The email can be specified in the spec under
network.acme-email and will be used to configure the Caddy ingress
controller ConfigMap.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:50:10 -05:00
A. F. Dudley
411e777980 fix(deploy): merge volumes from stack init() instead of overwriting
Previously, volumes defined in a stack's commands.py init() function
were being overwritten by volumes discovered from compose files.
This prevented stacks from adding infrastructure volumes like caddy-data
that aren't defined in the compose files.

Now volumes are merged, with init() volumes taking precedence over
compose-discovered defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:50:09 -05:00
3 changed files with 17 additions and 129 deletions

View File

@ -301,7 +301,8 @@ class K8sDeployer(Deployer):
self.connect_api() self.connect_api()
if self.is_kind() and not self.skip_cluster_management: if self.is_kind() and not self.skip_cluster_management:
# Configure ingress controller (not installed by default in kind) # Configure ingress controller (not installed by default in kind)
install_ingress_for_kind() acme_email = self.cluster_info.spec.get_acme_email()
install_ingress_for_kind(acme_email=acme_email)
# Wait for ingress to start # Wait for ingress to start
# (deployment provisioning will fail unless this is done) # (deployment provisioning will fail unless this is done)
wait_for_ingress_in_kind() wait_for_ingress_in_kind()

View File

@ -27,48 +27,6 @@ from stack_orchestrator.deploy.deployer import DeployerException
from stack_orchestrator import constants 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(): def get_kind_cluster():
"""Get an existing kind cluster, if any. """Get an existing kind cluster, if any.
@ -219,7 +177,6 @@ def volume_mounts_for_service(parsed_pod_files, service):
for mount_string in volumes: for mount_string in volumes:
# Looks like: test-data:/data # Looks like: test-data:/data
# or test-data:/data:ro or test-data:/data:rw # or test-data:/data:ro or test-data:/data:rw
# or ../config/file.sh:/opt/file.sh (host path mount)
if opts.o.debug: if opts.o.debug:
print(f"mount_string: {mount_string}") print(f"mount_string: {mount_string}")
mount_split = mount_string.split(":") mount_split = mount_string.split(":")
@ -228,21 +185,13 @@ def volume_mounts_for_service(parsed_pod_files, service):
mount_options = ( mount_options = (
mount_split[2] if len(mount_split) == 3 else None 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: if opts.o.debug:
print(f"volume_name: {volume_name}") print(f"volume_name: {volume_name}")
print(f"k8s_volume_name: {k8s_volume_name}")
print(f"mount path: {mount_path}") print(f"mount path: {mount_path}")
print(f"mount options: {mount_options}") print(f"mount options: {mount_options}")
volume_device = client.V1VolumeMount( volume_device = client.V1VolumeMount(
mount_path=mount_path, mount_path=mount_path,
name=k8s_volume_name, name=volume_name,
read_only="ro" == mount_options, read_only="ro" == mount_options,
) )
result.append(volume_device) result.append(volume_device)
@ -251,12 +200,8 @@ def volume_mounts_for_service(parsed_pod_files, service):
def volumes_for_pod_files(parsed_pod_files, spec, app_name): def volumes_for_pod_files(parsed_pod_files, spec, app_name):
result = [] result = []
seen_host_path_volumes = set() # Track host path volumes to avoid duplicates
for pod in parsed_pod_files: for pod in parsed_pod_files:
parsed_pod_file = parsed_pod_files[pod] parsed_pod_file = parsed_pod_files[pod]
# Handle named volumes from top-level volumes section
if "volumes" in parsed_pod_file: if "volumes" in parsed_pod_file:
volumes = parsed_pod_file["volumes"] volumes = parsed_pod_file["volumes"]
for volume_name in volumes.keys(): for volume_name in volumes.keys():
@ -275,35 +220,6 @@ def volumes_for_pod_files(parsed_pod_files, spec, app_name):
name=volume_name, persistent_volume_claim=claim name=volume_name, persistent_volume_claim=claim
) )
result.append(volume) 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 return result
@ -322,8 +238,6 @@ def _make_absolute_host_path(data_mount_path: Path, deployment_dir: Path) -> Pat
def _generate_kind_mounts(parsed_pod_files, deployment_dir, deployment_context): def _generate_kind_mounts(parsed_pod_files, deployment_dir, deployment_context):
volume_definitions = [] volume_definitions = []
volume_host_path_map = _get_host_paths_for_volumes(deployment_context) 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) # 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 # So we need to fix up to make them correct and absolute because kind assumes
# relative to the cwd. # relative to the cwd.
@ -338,58 +252,28 @@ def _generate_kind_mounts(parsed_pod_files, deployment_dir, deployment_context):
for mount_string in volumes: for mount_string in volumes:
# Looks like: test-data:/data # Looks like: test-data:/data
# or test-data:/data:ro or test-data:/data:rw # or test-data:/data:ro or test-data:/data:rw
# or ../config/file.sh:/opt/file.sh (host path mount)
if opts.o.debug: if opts.o.debug:
print(f"mount_string: {mount_string}") print(f"mount_string: {mount_string}")
mount_split = mount_string.split(":") mount_split = mount_string.split(":")
volume_name = mount_split[0] volume_name = mount_split[0]
mount_path = mount_split[1] mount_path = mount_split[1]
if opts.o.debug:
if is_host_path_mount(volume_name): print(f"volume_name: {volume_name}")
# Host path mount - add extraMount for kind print(f"map: {volume_host_path_map}")
sanitized_name = sanitize_host_path_to_volume_name( print(f"mount path: {mount_path}")
volume_name if volume_name not in deployment_context.spec.get_configmaps():
) if volume_host_path_map[volume_name]:
if sanitized_name not in seen_host_path_mounts: host_path = _make_absolute_host_path(
seen_host_path_mounts.add(sanitized_name) volume_host_path_map[volume_name],
# Resolve path relative to compose directory deployment_dir,
host_path = resolve_host_path_for_kind(
volume_name, deployment_dir
) )
container_path = get_kind_host_path_mount_path( container_path = get_kind_pv_bind_mount_path(
sanitized_name volume_name
) )
volume_definitions.append( volume_definitions.append(
f" - hostPath: {host_path}\n" f" - hostPath: {host_path}\n"
f" containerPath: {container_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 ( return (
"" ""
if len(volume_definitions) == 0 if len(volume_definitions) == 0

View File

@ -117,6 +117,9 @@ class Spec:
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_acme_email(self):
return self.obj.get(constants.network_key, {}).get("acme-email", "")
def get_annotations(self): def get_annotations(self):
return self.obj.get(constants.annotations_key, {}) return self.obj.get(constants.annotations_key, {})