Add Caddy ingress and k8s cluster management features

- Add Caddy ingress controller manifest for kind deployments
- Add k8s cluster list command for kind cluster management
- Add k8s_command import and registration in deploy.py
- Fix network section merge to preserve http-proxy settings
- Increase default container resources (4 CPUs, 8GB memory)
- Add UDP protocol support for K8s port definitions
- Add command/entrypoint support for K8s deployments
- Implement docker-compose variable expansion for K8s
- Set ConfigMap defaultMode to 0755 for executable scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
A. F. Dudley
2026-01-20 23:14:22 -05:00
co-authored by Claude Opus 4.5
parent 9bd59f29d9
commit 89db6e1e92
6 changed files with 379 additions and 25 deletions
+42 -11
View File
@@ -34,8 +34,8 @@ DEFAULT_VOLUME_RESOURCES = Resources({
})
DEFAULT_CONTAINER_RESOURCES = Resources({
"reservations": {"cpus": "0.1", "memory": "200M"},
"limits": {"cpus": "1.0", "memory": "2000M"},
"reservations": {"cpus": "1.0", "memory": "2000M"},
"limits": {"cpus": "4.0", "memory": "8000M"},
})
@@ -90,23 +90,30 @@ class ClusterInfo:
for raw_port in [str(p) for p in service_info["ports"]]:
if opts.o.debug:
print(f"service port: {raw_port}")
if ":" in raw_port:
parts = raw_port.split(":")
# Parse protocol suffix (e.g., "8001/udp" -> port=8001, protocol=UDP)
protocol = "TCP"
port_str = raw_port
if "/" in raw_port:
port_str, proto = raw_port.rsplit("/", 1)
protocol = proto.upper()
if ":" in port_str:
parts = port_str.split(":")
if len(parts) != 2:
raise Exception(f"Invalid port definition: {raw_port}")
node_port = int(parts[0])
pod_port = int(parts[1])
else:
node_port = None
pod_port = int(raw_port)
pod_port = int(port_str)
service = client.V1Service(
metadata=client.V1ObjectMeta(name=f"{self.app_name}-nodeport-{pod_port}"),
metadata=client.V1ObjectMeta(name=f"{self.app_name}-nodeport-{pod_port}-{protocol.lower()}"),
spec=client.V1ServiceSpec(
type="NodePort",
ports=[client.V1ServicePort(
port=pod_port,
target_port=pod_port,
node_port=node_port
node_port=node_port,
protocol=protocol
)],
selector={"app": self.app_name}
)
@@ -326,14 +333,26 @@ class ClusterInfo:
container_name = service_name
service_info = services[service_name]
image = service_info["image"]
container_ports = []
if "ports" in service_info:
port = int(service_info["ports"][0])
for raw_port in [str(p) for p in service_info["ports"]]:
# Parse protocol suffix (e.g., "8001/udp" -> port=8001, protocol=UDP)
protocol = "TCP"
port_str = raw_port
if "/" in raw_port:
port_str, proto = raw_port.rsplit("/", 1)
protocol = proto.upper()
# Handle host:container port mapping - use container port
if ":" in port_str:
port_str = port_str.split(":")[-1]
port = int(port_str)
container_ports.append(client.V1ContainerPort(container_port=port, protocol=protocol))
if opts.o.debug:
print(f"image: {image}")
print(f"service port: {port}")
print(f"service ports: {container_ports}")
merged_envs = merge_envs(
envs_from_compose_file(
service_info["environment"]), self.environment_variables.map
service_info["environment"], self.environment_variables.map), self.environment_variables.map
) if "environment" in service_info else self.environment_variables.map
envs = envs_from_environment_variables_map(merged_envs)
if opts.o.debug:
@@ -345,12 +364,24 @@ class ClusterInfo:
self.spec.get_image_registry(),
self.app_name) if self.spec.get_image_registry() is not None else image
volume_mounts = volume_mounts_for_service(self.parsed_pod_yaml_map, service_name)
# Handle command/entrypoint from compose file
# In docker-compose: entrypoint -> k8s command, command -> k8s args
container_command = None
container_args = None
if "entrypoint" in service_info:
entrypoint = service_info["entrypoint"]
container_command = entrypoint if isinstance(entrypoint, list) else [entrypoint]
if "command" in service_info:
cmd = service_info["command"]
container_args = cmd if isinstance(cmd, list) else cmd.split()
container = client.V1Container(
name=container_name,
image=image_to_use,
image_pull_policy=image_pull_policy,
command=container_command,
args=container_args,
env=envs,
ports=[client.V1ContainerPort(container_port=port)],
ports=container_ports if container_ports else None,
volume_mounts=volume_mounts,
security_context=client.V1SecurityContext(
privileged=self.spec.get_privileged(),
+25 -13
View File
@@ -165,7 +165,8 @@ def volumes_for_pod_files(parsed_pod_files, spec, app_name):
volumes = parsed_pod_file["volumes"]
for volume_name in volumes.keys():
if volume_name in spec.get_configmaps():
config_map = client.V1ConfigMapVolumeSource(name=f"{app_name}-{volume_name}")
# Set defaultMode=0o755 to make scripts executable
config_map = client.V1ConfigMapVolumeSource(name=f"{app_name}-{volume_name}", default_mode=0o755)
volume = client.V1Volume(name=volume_name, config_map=config_map)
result.append(volume)
else:
@@ -268,23 +269,34 @@ def merge_envs(a: Mapping[str, str], b: Mapping[str, str]) -> Mapping[str, str]:
return result
def _expand_shell_vars(raw_val: str) -> str:
# could be: <string> or ${<env-var-name>} or ${<env-var-name>:-<default-value>}
# TODO: implement support for variable substitution and default values
# if raw_val is like ${<something>} print a warning and substitute an empty string
# otherwise return raw_val
match = re.search(r"^\$\{(.*)\}$", raw_val)
def _expand_shell_vars(raw_val: str, env_map: Mapping[str, str] = None) -> str:
# Expand docker-compose style variable substitution:
# ${VAR} - use VAR value or empty string
# ${VAR:-default} - use VAR value or default if unset/empty
# ${VAR-default} - use VAR value or default if unset
if env_map is None:
env_map = {}
if raw_val is None:
return ""
match = re.search(r"^\$\{([^}]+)\}$", raw_val)
if match:
print(f"WARNING: found unimplemented environment variable substitution: {raw_val}")
else:
return raw_val
inner = match.group(1)
# Check for default value syntax
if ":-" in inner:
var_name, default_val = inner.split(":-", 1)
return env_map.get(var_name, "") or default_val
elif "-" in inner:
var_name, default_val = inner.split("-", 1)
return env_map.get(var_name, default_val)
else:
return env_map.get(inner, "")
return raw_val
# TODO: handle the case where the same env var is defined in multiple places
def envs_from_compose_file(compose_file_envs: Mapping[str, str]) -> Mapping[str, str]:
def envs_from_compose_file(compose_file_envs: Mapping[str, str], env_map: Mapping[str, str] = None) -> Mapping[str, str]:
result = {}
for env_var, env_val in compose_file_envs.items():
expanded_env_val = _expand_shell_vars(env_val)
expanded_env_val = _expand_shell_vars(env_val, env_map)
result.update({env_var: expanded_env_val})
return result
@@ -0,0 +1,43 @@
# Copyright © 2024 Vulcanize
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
import click
from stack_orchestrator.deploy.k8s.helpers import get_kind_cluster
@click.group()
@click.pass_context
def command(ctx):
'''k8s cluster management commands'''
pass
@command.group()
@click.pass_context
def list(ctx):
'''list k8s resources'''
pass
@list.command()
@click.pass_context
def cluster(ctx):
'''Show the existing kind cluster'''
existing_cluster = get_kind_cluster()
if existing_cluster:
print(existing_cluster)
else:
print("No cluster found")