Compare commits

...

2 Commits

Author SHA1 Message Date
A. F. Dudley
8426d99ed9 Add kind cluster reuse and list command
- Add get_kind_cluster() to detect existing kind clusters
- Modify create_cluster() to reuse existing clusters automatically
- Add 'laconic-so deploy k8s list cluster' command
- Skip --stack requirement for k8s subcommand

This allows multiple deployments to share the same kind cluster,
simplifying local development workflows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:08:16 -05:00
A. F. Dudley
3606b5dd90 Add Caddy ingress controller support for kind deployments
Replace nginx with Caddy as the default ingress controller for kind
deployments. Caddy provides automatic HTTPS via Let's Encrypt without
requiring cert-manager.

Changes:
- Add ingress-caddy-kind-deploy.yaml manifest with full RBAC setup
- Modify helpers.py to support configurable ingress_type parameter
- Update cluster_info.py to use caddy ingress class
- Add port 443 mapping for HTTPS support in kind config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 02:39:01 -05:00
6 changed files with 389 additions and 18 deletions

View File

@ -0,0 +1,250 @@
# Caddy Ingress Controller for kind
# Based on: https://github.com/caddyserver/ingress
# Provides automatic HTTPS with Let's Encrypt
apiVersion: v1
kind: Namespace
metadata:
name: caddy-system
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: caddy-ingress-controller
namespace: caddy-system
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: caddy-ingress-controller
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
rules:
- apiGroups:
- ""
resources:
- configmaps
- endpoints
- nodes
- pods
- secrets
- namespaces
- services
verbs:
- list
- watch
- get
- apiGroups:
- ""
resources:
- nodes
verbs:
- get
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- networking.k8s.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- networking.k8s.io
resources:
- ingresses/status
verbs:
- update
- apiGroups:
- networking.k8s.io
resources:
- ingressclasses
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- create
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: caddy-ingress-controller
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: caddy-ingress-controller
subjects:
- kind: ServiceAccount
name: caddy-ingress-controller
namespace: caddy-system
---
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-ingress-controller-configmap
namespace: caddy-system
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
data:
# Caddy global options
acmeCA: "https://acme-v02.api.letsencrypt.org/directory"
email: ""
---
apiVersion: v1
kind: Service
metadata:
name: caddy-ingress-controller
namespace: caddy-system
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
app.kubernetes.io/component: controller
spec:
type: NodePort
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
- name: https
port: 443
targetPort: https
protocol: TCP
selector:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
app.kubernetes.io/component: controller
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: caddy-ingress-controller
namespace: caddy-system
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
app.kubernetes.io/component: controller
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
app.kubernetes.io/component: controller
template:
metadata:
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
app.kubernetes.io/component: controller
spec:
serviceAccountName: caddy-ingress-controller
terminationGracePeriodSeconds: 60
nodeSelector:
ingress-ready: "true"
kubernetes.io/os: linux
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/master
operator: Equal
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
operator: Equal
containers:
- name: caddy-ingress-controller
image: ghcr.io/caddyserver/ingress:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
hostPort: 80
protocol: TCP
- name: https
containerPort: 443
hostPort: 443
protocol: TCP
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
args:
- -config-map=caddy-system/caddy-ingress-controller-configmap
- -class-name=caddy
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 512Mi
readinessProbe:
httpGet:
path: /healthz
port: 9765
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 9765
initialDelaySeconds: 3
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: true
capabilities:
add:
- NET_BIND_SERVICE
drop:
- ALL
runAsUser: 0
runAsGroup: 0
volumeMounts:
- name: caddy-data
mountPath: /data
- name: caddy-config
mountPath: /config
volumes:
- name: caddy-data
emptyDir: {}
- name: caddy-config
emptyDir: {}
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: caddy
labels:
app.kubernetes.io/name: caddy-ingress-controller
app.kubernetes.io/instance: caddy-ingress
annotations:
ingressclass.kubernetes.io/is-default-class: "true"
spec:
controller: caddy.io/ingress-controller

View File

@ -42,6 +42,7 @@ from stack_orchestrator.deploy.deployment_context import DeploymentContext
from stack_orchestrator.deploy.deployment_create import create as deployment_create
from stack_orchestrator.deploy.deployment_create import init as deployment_init
from stack_orchestrator.deploy.deployment_create import setup as deployment_setup
from stack_orchestrator.deploy.k8s import k8s_command
@click.group()
@ -54,6 +55,10 @@ from stack_orchestrator.deploy.deployment_create import setup as deployment_setu
def command(ctx, include, exclude, env_file, cluster, deploy_to):
'''deploy a stack'''
# k8s subcommand doesn't require stack
if ctx.invoked_subcommand == "k8s":
return
# Although in theory for some subcommands (e.g. deploy create) the stack can be inferred,
# Click doesn't allow us to know that here, so we make providing the stack mandatory
stack = global_options2(ctx).stack
@ -460,3 +465,4 @@ def _orchestrate_cluster_config(ctx, cluster_config, deployer, container_exec_en
command.add_command(deployment_init)
command.add_command(deployment_create)
command.add_command(deployment_setup)
command.add_command(k8s_command.command, "k8s")

View File

@ -162,10 +162,12 @@ class ClusterInfo:
)
ingress_annotations = {
"kubernetes.io/ingress.class": "nginx",
"kubernetes.io/ingress.class": "caddy",
}
if not certificate:
ingress_annotations["cert-manager.io/cluster-issuer"] = cluster_issuer
# Note: Caddy handles TLS automatically via Let's Encrypt, no cert-manager needed
if not certificate and cluster_issuer:
# Only add cert-manager annotation if using nginx ingress with cert-manager
pass # Caddy handles certificates automatically
ingress = client.V1Ingress(
metadata=client.V1ObjectMeta(

View File

@ -210,8 +210,12 @@ class K8sDeployer(Deployer):
self.skip_cluster_management = skip_cluster_management
if not opts.o.dry_run:
if self.is_kind() and not self.skip_cluster_management:
# Create the kind cluster
create_cluster(self.kind_cluster_name, self.deployment_dir.joinpath(constants.kind_config_filename))
# Create the kind cluster (or reuse existing one)
kind_config = self.deployment_dir.joinpath(constants.kind_config_filename)
actual_cluster = create_cluster(self.kind_cluster_name, kind_config)
if actual_cluster != self.kind_cluster_name:
# An existing cluster was found, use it instead
self.kind_cluster_name = actual_cluster
# Ensure the referenced containers are copied into kind
load_images_into_kind(self.kind_cluster_name, self.cluster_info.image_set)
self.connect_api()

View File

@ -35,40 +35,106 @@ def _run_command(command: str):
return result
def get_kind_cluster():
"""Get an existing kind cluster, if any.
Uses `kind get clusters` to find existing clusters.
Returns the cluster name or None if no cluster exists.
"""
result = subprocess.run(
"kind get clusters",
shell=True,
capture_output=True,
text=True
)
if result.returncode != 0:
return None
clusters = result.stdout.strip().splitlines()
if clusters:
return clusters[0] # Return the first cluster found
return None
def create_cluster(name: str, config_file: str):
"""Create a kind cluster, or reuse an existing one.
Checks if any kind cluster already exists. If so, uses that cluster
instead of creating a new one. This allows multiple deployments to
share the same kind cluster.
Args:
name: The desired cluster name (used only if creating new)
config_file: Path to kind config file (used only if creating new)
Returns:
The name of the cluster being used (either existing or newly created)
"""
existing = get_kind_cluster()
if existing:
print(f"Using existing cluster: {existing}")
return existing
print(f"Creating new cluster: {name}")
result = _run_command(f"kind create cluster --name {name} --config {config_file}")
if result.returncode != 0:
raise DeployerException(f"kind create cluster failed: {result}")
return name
def destroy_cluster(name: str):
_run_command(f"kind delete cluster --name {name}")
def wait_for_ingress_in_kind():
def wait_for_ingress_in_kind(ingress_type="caddy"):
"""Wait for ingress controller to become ready.
Args:
ingress_type: "caddy" or "nginx" - determines which namespace and labels to check
"""
core_v1 = client.CoreV1Api()
if ingress_type == "caddy":
namespace = "caddy-system"
label_selector = "app.kubernetes.io/component=controller"
else:
namespace = "ingress-nginx"
label_selector = "app.kubernetes.io/component=controller"
for i in range(20):
warned_waiting = False
w = watch.Watch()
for event in w.stream(func=core_v1.list_namespaced_pod,
namespace="ingress-nginx",
label_selector="app.kubernetes.io/component=controller",
namespace=namespace,
label_selector=label_selector,
timeout_seconds=30):
if event['object'].status.container_statuses:
if event['object'].status.container_statuses[0].ready is True:
if warned_waiting:
print("Ingress controller is ready")
print(f"{ingress_type.capitalize()} ingress controller is ready")
return
print("Waiting for ingress controller to become ready...")
print(f"Waiting for {ingress_type} ingress controller to become ready...")
warned_waiting = True
error_exit("ERROR: Timed out waiting for ingress to become ready")
error_exit(f"ERROR: Timed out waiting for {ingress_type} ingress to become ready")
def install_ingress_for_kind():
def install_ingress_for_kind(ingress_type="caddy"):
"""Install ingress controller in kind cluster.
Args:
ingress_type: "caddy" or "nginx" - determines which ingress controller to install
"""
api_client = client.ApiClient()
ingress_install = os.path.abspath(get_k8s_dir().joinpath("components", "ingress", "ingress-nginx-kind-deploy.yaml"))
if opts.o.debug:
print("Installing nginx ingress controller in kind cluster")
if ingress_type == "caddy":
ingress_install = os.path.abspath(get_k8s_dir().joinpath("components", "ingress", "ingress-caddy-kind-deploy.yaml"))
if opts.o.debug:
print("Installing Caddy ingress controller in kind cluster")
else:
ingress_install = os.path.abspath(get_k8s_dir().joinpath("components", "ingress", "ingress-nginx-kind-deploy.yaml"))
if opts.o.debug:
print("Installing nginx ingress controller in kind cluster")
utils.create_from_yaml(api_client, yaml_file=ingress_install)
@ -251,9 +317,9 @@ def _generate_kind_port_mappings_from_services(parsed_pod_files):
def _generate_kind_port_mappings(parsed_pod_files):
port_definitions = []
# For now we just map port 80 for the nginx ingress controller we install in kind
port_string = "80"
port_definitions.append(f" - containerPort: {port_string}\n hostPort: {port_string}\n")
# Map port 80 (HTTP) and 443 (HTTPS) for the ingress controller
for port_string in ["80", "443"]:
port_definitions.append(f" - containerPort: {port_string}\n hostPort: {port_string}\n")
return (
"" if len(port_definitions) == 0 else (
" extraPortMappings:\n"

View File

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