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
parent
9c5b8e3f4e
commit
974eed0c73
|
|
@ -8,3 +8,4 @@ __pycache__
|
||||||
package
|
package
|
||||||
stack_orchestrator/data/build_tag.txt
|
stack_orchestrator/data/build_tag.txt
|
||||||
/build
|
/build
|
||||||
|
.worktrees
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue