feat: add `deployment prepare` command (so-076.1)

Refactors K8sDeployer.up() into three composable methods:
- _setup_cluster_and_namespace(): kind cluster, API, namespace, ingress
- _create_infrastructure(): PVs, PVCs, ConfigMaps, Services, NodePorts
- _create_deployment(): Deployment resource (pods)

`prepare` calls the first two only — creates all cluster infrastructure
without starting pods. This eliminates the scale-to-0 workaround where
operators had to run `deployment start` then immediately scale down.

Usage: laconic-so deployment --dir <dir> prepare

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix/kind-mount-propagation
A. F. Dudley 2026-03-08 06:56:34 +00:00
parent 9c5b8e3f4e
commit 974eed0c73
5 changed files with 73 additions and 30 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ __pycache__
package package
stack_orchestrator/data/build_tag.txt stack_orchestrator/data/build_tag.txt
/build /build
.worktrees

View File

@ -182,6 +182,12 @@ def status_operation(ctx):
ctx.obj.deployer.status() ctx.obj.deployer.status()
def prepare_operation(ctx, skip_cluster_management=False):
ctx.obj.deployer.prepare(
skip_cluster_management=skip_cluster_management,
)
def update_envs_operation(ctx): def update_envs_operation(ctx):
ctx.obj.deployer.update_envs() ctx.obj.deployer.update_envs()

View File

@ -69,6 +69,15 @@ class Deployer(ABC):
def run_job(self, job_name: str, release_name: Optional[str] = None): def run_job(self, job_name: str, release_name: Optional[str] = None):
pass pass
def prepare(self, skip_cluster_management):
"""Create cluster infrastructure (namespace, PVs, services) without starting pods.
Only supported for k8s deployers. Compose deployers raise an error.
"""
raise DeployerException(
"prepare is only supported for k8s deployments"
)
class DeployerException(Exception): class DeployerException(Exception):
def __init__(self, *args: object) -> None: def __init__(self, *args: object) -> None:

View File

@ -23,6 +23,7 @@ from stack_orchestrator.deploy.images import push_images_operation
from stack_orchestrator.deploy.deploy import ( from stack_orchestrator.deploy.deploy import (
up_operation, up_operation,
down_operation, down_operation,
prepare_operation,
ps_operation, ps_operation,
port_operation, port_operation,
status_operation, status_operation,
@ -125,6 +126,27 @@ def start(ctx, stay_attached, skip_cluster_management, extra_args):
up_operation(ctx, services_list, stay_attached, skip_cluster_management) up_operation(ctx, services_list, stay_attached, skip_cluster_management)
@command.command()
@click.option(
"--skip-cluster-management/--perform-cluster-management",
default=False,
help="Skip cluster initialization (only for kind-k8s deployments)",
)
@click.pass_context
def prepare(ctx, skip_cluster_management):
"""Create cluster infrastructure without starting pods.
Sets up the kind cluster, namespace, PVs, PVCs, ConfigMaps, Services,
and Ingresses everything that 'start' does EXCEPT creating the
Deployment resource. No pods will be scheduled.
Use 'start --skip-cluster-management' afterward to create the Deployment
and start pods when ready.
"""
ctx.obj = make_deploy_context(ctx)
prepare_operation(ctx, skip_cluster_management)
# TODO: remove legacy up command since it's an alias for stop # TODO: remove legacy up command since it's an alias for stop
@command.command() @command.command()
@click.option( @click.option(

View File

@ -371,22 +371,15 @@ class K8sDeployer(Deployer):
self._ensure_config_map(cfg_map) self._ensure_config_map(cfg_map)
def _create_deployment(self): def _create_deployment(self):
# Process compose files into a Deployment """Create the k8s Deployment resource (which starts pods)."""
deployment = self.cluster_info.get_deployment( deployment = self.cluster_info.get_deployment(
image_pull_policy=None if self.is_kind() else "Always" image_pull_policy=None if self.is_kind() else "Always"
) )
# Create the k8s objects
if opts.o.debug: if opts.o.debug:
print(f"Sending this deployment: {deployment}") print(f"Sending this deployment: {deployment}")
if not opts.o.dry_run: if not opts.o.dry_run:
self._ensure_deployment(deployment) self._ensure_deployment(deployment)
service = self.cluster_info.get_service()
if opts.o.debug:
print(f"Sending this service: {service}")
if service and not opts.o.dry_run:
self._ensure_service(service)
def _find_certificate_for_host_name(self, host_name): def _find_certificate_for_host_name(self, host_name):
all_certificates = self.custom_obj_api.list_namespaced_custom_object( all_certificates = self.custom_obj_api.list_namespaced_custom_object(
group="cert-manager.io", group="cert-manager.io",
@ -424,24 +417,25 @@ class K8sDeployer(Deployer):
return None return None
def up(self, detach, skip_cluster_management, services): def up(self, detach, skip_cluster_management, services):
self._setup_cluster_and_namespace(skip_cluster_management)
self._create_infrastructure()
self._create_deployment()
def _setup_cluster_and_namespace(self, skip_cluster_management):
"""Create kind cluster (if needed) and namespace. Shared by up() and prepare()."""
self.skip_cluster_management = skip_cluster_management self.skip_cluster_management = skip_cluster_management
if not opts.o.dry_run: if not opts.o.dry_run:
if self.is_kind() and not self.skip_cluster_management: if self.is_kind() and not self.skip_cluster_management:
# Create the kind cluster (or reuse existing one)
kind_config = str( kind_config = str(
self.deployment_dir.joinpath(constants.kind_config_filename) self.deployment_dir.joinpath(constants.kind_config_filename)
) )
actual_cluster = create_cluster(self.kind_cluster_name, kind_config) actual_cluster = create_cluster(self.kind_cluster_name, kind_config)
if actual_cluster != self.kind_cluster_name: if actual_cluster != self.kind_cluster_name:
# An existing cluster was found, use it instead
self.kind_cluster_name = actual_cluster self.kind_cluster_name = actual_cluster
# Only load locally-built images into kind
# Registry images (docker.io, ghcr.io, etc.) will be pulled by k8s
local_containers = self.deployment_context.stack.obj.get( local_containers = self.deployment_context.stack.obj.get(
"containers", [] "containers", []
) )
if local_containers: if local_containers:
# Filter image_set to only images matching local containers
local_images = { local_images = {
img img
for img in self.cluster_info.image_set for img in self.cluster_info.image_set
@ -449,47 +443,48 @@ class K8sDeployer(Deployer):
} }
if local_images: if local_images:
load_images_into_kind(self.kind_cluster_name, local_images) load_images_into_kind(self.kind_cluster_name, local_images)
# Note: if no local containers defined, all images come from registries
self.connect_api() self.connect_api()
# Create deployment-specific namespace for resource isolation
self._ensure_namespace() self._ensure_namespace()
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)
# Skip if already running (idempotent for shared cluster)
if not is_ingress_running(): if not is_ingress_running():
install_ingress_for_kind(self.cluster_info.spec.get_acme_email()) install_ingress_for_kind(self.cluster_info.spec.get_acme_email())
# Wait for ingress to start
# (deployment provisioning will fail unless this is done)
wait_for_ingress_in_kind() wait_for_ingress_in_kind()
# Create RuntimeClass if unlimited_memlock is enabled
if self.cluster_info.spec.get_unlimited_memlock(): if self.cluster_info.spec.get_unlimited_memlock():
_create_runtime_class( _create_runtime_class(
constants.high_memlock_runtime, constants.high_memlock_runtime,
constants.high_memlock_runtime, constants.high_memlock_runtime,
) )
else: else:
print("Dry run mode enabled, skipping k8s API connect") print("Dry run mode enabled, skipping k8s API connect")
# Create registry secret if configured def _create_infrastructure(self):
"""Create PVs, PVCs, ConfigMaps, Services, Ingresses, NodePorts.
Everything except the Deployment resource (which starts pods).
Shared by up() and prepare().
"""
from stack_orchestrator.deploy.deployment_create import create_registry_secret from stack_orchestrator.deploy.deployment_create import create_registry_secret
create_registry_secret(self.cluster_info.spec, self.cluster_info.app_name) create_registry_secret(self.cluster_info.spec, self.cluster_info.app_name)
self._create_volume_data() self._create_volume_data()
self._create_deployment()
# Create the ClusterIP service (paired with the deployment)
service = self.cluster_info.get_service()
if service and not opts.o.dry_run:
if opts.o.debug:
print(f"Sending this service: {service}")
self._ensure_service(service)
http_proxy_info = self.cluster_info.spec.get_http_proxy() http_proxy_info = self.cluster_info.spec.get_http_proxy()
# Note: we don't support tls for kind (enabling tls causes errors)
use_tls = http_proxy_info and not self.is_kind() use_tls = http_proxy_info and not self.is_kind()
certificate = ( certificate = (
self._find_certificate_for_host_name(http_proxy_info[0]["host-name"]) self._find_certificate_for_host_name(http_proxy_info[0]["host-name"])
if use_tls if use_tls
else None else None
) )
if opts.o.debug: if opts.o.debug and certificate:
if certificate: print(f"Using existing certificate: {certificate}")
print(f"Using existing certificate: {certificate}")
ingress = self.cluster_info.get_ingress( ingress = self.cluster_info.get_ingress(
use_tls=use_tls, certificate=certificate use_tls=use_tls, certificate=certificate
@ -499,9 +494,8 @@ class K8sDeployer(Deployer):
print(f"Sending this ingress: {ingress}") print(f"Sending this ingress: {ingress}")
if not opts.o.dry_run: if not opts.o.dry_run:
self._ensure_ingress(ingress) self._ensure_ingress(ingress)
else: elif opts.o.debug:
if opts.o.debug: print("No ingress configured")
print("No ingress configured")
nodeports: List[client.V1Service] = self.cluster_info.get_nodeports() nodeports: List[client.V1Service] = self.cluster_info.get_nodeports()
for nodeport in nodeports: for nodeport in nodeports:
@ -510,6 +504,17 @@ class K8sDeployer(Deployer):
if not opts.o.dry_run: if not opts.o.dry_run:
self._ensure_service(nodeport, kind="NodePort") self._ensure_service(nodeport, kind="NodePort")
def prepare(self, skip_cluster_management):
"""Create cluster infrastructure without starting pods.
Sets up kind cluster, namespace, PVs, PVCs, ConfigMaps, Services,
Ingresses, and NodePorts everything that up() does EXCEPT creating
the Deployment resource.
"""
self._setup_cluster_and_namespace(skip_cluster_management)
self._create_infrastructure()
print("Cluster infrastructure prepared (no pods started).")
def down(self, timeout, volumes, skip_cluster_management): def down(self, timeout, volumes, skip_cluster_management):
self.skip_cluster_management = skip_cluster_management self.skip_cluster_management = skip_cluster_management
self.connect_api() self.connect_api()