From 974eed0c733324da2b3d844821a7923297843b6b Mon Sep 17 00:00:00 2001 From: "A. F. Dudley" Date: Sun, 8 Mar 2026 06:56:34 +0000 Subject: [PATCH] feat: add `deployment prepare` command (so-076.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 prepare Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + stack_orchestrator/deploy/deploy.py | 6 ++ stack_orchestrator/deploy/deployer.py | 9 +++ stack_orchestrator/deploy/deployment.py | 22 +++++++ stack_orchestrator/deploy/k8s/deploy_k8s.py | 65 +++++++++++---------- 5 files changed, 73 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 3aaa220b..6abbf941 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__ package stack_orchestrator/data/build_tag.txt /build +.worktrees diff --git a/stack_orchestrator/deploy/deploy.py b/stack_orchestrator/deploy/deploy.py index f2bf977c..6e914b92 100644 --- a/stack_orchestrator/deploy/deploy.py +++ b/stack_orchestrator/deploy/deploy.py @@ -182,6 +182,12 @@ def status_operation(ctx): 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): ctx.obj.deployer.update_envs() diff --git a/stack_orchestrator/deploy/deployer.py b/stack_orchestrator/deploy/deployer.py index 11fb6592..b950e29b 100644 --- a/stack_orchestrator/deploy/deployer.py +++ b/stack_orchestrator/deploy/deployer.py @@ -69,6 +69,15 @@ class Deployer(ABC): def run_job(self, job_name: str, release_name: Optional[str] = None): 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): def __init__(self, *args: object) -> None: diff --git a/stack_orchestrator/deploy/deployment.py b/stack_orchestrator/deploy/deployment.py index 1182d23f..0dc9ac37 100644 --- a/stack_orchestrator/deploy/deployment.py +++ b/stack_orchestrator/deploy/deployment.py @@ -23,6 +23,7 @@ from stack_orchestrator.deploy.images import push_images_operation from stack_orchestrator.deploy.deploy import ( up_operation, down_operation, + prepare_operation, ps_operation, port_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) +@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 @command.command() @click.option( diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index b34e3291..1eee8ffd 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -371,22 +371,15 @@ class K8sDeployer(Deployer): self._ensure_config_map(cfg_map) def _create_deployment(self): - # Process compose files into a Deployment + """Create the k8s Deployment resource (which starts pods).""" deployment = self.cluster_info.get_deployment( image_pull_policy=None if self.is_kind() else "Always" ) - # Create the k8s objects if opts.o.debug: print(f"Sending this deployment: {deployment}") if not opts.o.dry_run: 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): all_certificates = self.custom_obj_api.list_namespaced_custom_object( group="cert-manager.io", @@ -424,24 +417,25 @@ class K8sDeployer(Deployer): return None 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 if not opts.o.dry_run: if self.is_kind() and not self.skip_cluster_management: - # Create the kind cluster (or reuse existing one) kind_config = str( 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 - # 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( "containers", [] ) if local_containers: - # Filter image_set to only images matching local containers local_images = { img for img in self.cluster_info.image_set @@ -449,47 +443,48 @@ class K8sDeployer(Deployer): } if 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() - # Create deployment-specific namespace for resource isolation self._ensure_namespace() 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(): 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() - # Create RuntimeClass if unlimited_memlock is enabled if self.cluster_info.spec.get_unlimited_memlock(): _create_runtime_class( constants.high_memlock_runtime, constants.high_memlock_runtime, ) - else: 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 create_registry_secret(self.cluster_info.spec, self.cluster_info.app_name) 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() - # Note: we don't support tls for kind (enabling tls causes errors) use_tls = http_proxy_info and not self.is_kind() certificate = ( self._find_certificate_for_host_name(http_proxy_info[0]["host-name"]) if use_tls else None ) - if opts.o.debug: - if certificate: - print(f"Using existing certificate: {certificate}") + if opts.o.debug and certificate: + print(f"Using existing certificate: {certificate}") ingress = self.cluster_info.get_ingress( use_tls=use_tls, certificate=certificate @@ -499,9 +494,8 @@ class K8sDeployer(Deployer): print(f"Sending this ingress: {ingress}") if not opts.o.dry_run: self._ensure_ingress(ingress) - else: - if opts.o.debug: - print("No ingress configured") + elif opts.o.debug: + print("No ingress configured") nodeports: List[client.V1Service] = self.cluster_info.get_nodeports() for nodeport in nodeports: @@ -510,6 +504,17 @@ class K8sDeployer(Deployer): if not opts.o.dry_run: 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): self.skip_cluster_management = skip_cluster_management self.connect_api()