Pluggable app deployment (backend + frontend hosting) — Plan

Goal

Generalize deploying the Nagelfluh application itself (backend + frontend pods, their exposure, and their config/secrets) onto whatever cluster a deployment's default Cluster row points at — today this only works for Minikube, via prod/runall-minikube.sh's raw shell/kubectl orchestration, entirely outside the pluggable-backend system. This plan adds the hook a ClusterProvider needs to support "also host the app," mirroring exactly how Cluster/ ClusterProvider already make process/analysis Job execution pluggable (docs/plans/done/registry-backend-hooks.md Design decision 8, ensure_cluster_job_ready()).

This plan is host-repo work only. It defines the hook, a shared K8s-API helper for the provider-agnostic parts, and reference implementations for the two cluster types core already ships (same-as-backend, minikube). It does not implement support for any cloud-managed cluster type — that is a separate, dependent plan for whichever plugin adds one, implementing this same hook for its own ClusterProvider, the same relationship a plugin's own bootstrap() implementation already has to registry-backend-hooks.md.

Background — current state

(Confirmed by reading the implemented code, not just the docs.)

Design decisions (settled in discussion)

  1. No new pluggable axis. App hosting always targets the same Cluster row that job execution's provider (same-as-backend/kubeconfig/minikube/any future plugin-provided type) already resolves. This generalizes the existing ClusterProvider ABC; it does not introduce a second Cluster-like model.
  2. Two new ClusterProvider capability methods, both optional (default NotImplementedError, gated behind a new supports_app_deployment class flag mirroring self_service_registration):
  3. deploy_app(k8s_client, provider_config, images, app_config, secrets) -> None — applies the workload-level resources (Deployments, Services, ConfigMap, Secret, migration Job) via the K8s API.
  4. expose_app(k8s_client, provider_config, app_config) -> {"url": str, ...} — the genuinely provider-specific part: how external traffic reaches the app and whether/how TLS is terminated. same-as-backend/minikube implement this as today's NodePort (parameterized, not hardcoded); a future plugin-provided cloud cluster type would implement it with whatever managed load balancer/certificate mechanism that cloud offers (separate, dependent plan).
  5. A shared, provider-agnostic helper does the identical K8s-API work every provider's deploy_app() calls into: new backend/services/app_deployment.py, apply_app_workloads(k8s_client, namespace, images, app_config, secrets) — written against kubernetes_asyncio (same library K8sClient/ensure_cluster_job_ready() already use, not shell/kubectl), applying:
  6. backend + frontend Deployment/Service (parameterized by images["backend"]/ images["frontend"], replacing the hardcoded nagelfluh-backend:prod/ nagelfluh-frontend:prod + imagePullPolicy: Never),
  7. the nagelfluh-backend-config/nagelfluh-backend-secret ConfigMap/Secret (replacing the imperative kubectl create secret block),
  8. the DB migration Job (replacing the hardcoded-image heredoc in runall-minikube.sh). This mirrors the shape of cluster_job_provisioning.py's ensure_cluster_job_ready(): a shared utility, not itself part of the ABC, called by every provider's own hook method.
  9. Backend/frontend images are pushed through the existing registry axis, not built into a local Docker daemon. deploy_app() receives already-resolved image_url() strings (per the registry axis's existing RegistryProtocolHandler.image_url()); pods use the registry's per-Job/per-Deployment pull credential mechanism from registry-backend-hooks.md Phase 3 instead of imagePullPolicy: Never. This is a real behavior change for local dev/Minikube (today's fast "build straight into the daemon" path) — see Open items.
  10. JWT-key (and any other generate-once) secret persistence moves from a host file to check-before-generate against the K8s API: apply_app_workloads() reads the existing nagelfluh-backend-secret first and reuses its JWT_SECRET_KEY if present, only generating a new one if the Secret doesn't exist yet. This works identically for Minikube (replaces the NAGELFLUH_DATA_DIR host-file mechanism) and any remote cluster (no shared filesystem needed).
  11. APP_DOMAIN becomes a new, optional config.env value, threaded through unchanged as part of app_config into expose_app(). This plan does not interpret it — it's meaningless for same-as-backend/minikube (NodePort has no concept of a domain) and entirely up to whichever provider's expose_app() wants to use it.
  12. Providers that don't set supports_app_deployment = True are unaffected — an operator using the generic kubeconfig cluster type continues to expose/manage the app manually via k8s/*.yaml, exactly as today; this plan adds a capability, it doesn't require every provider to implement it.

Open items to confirm at implementation time

Phases

Phase 1 — Shared workload-apply helper

Phase 2 — ClusterProvider ABC extension + reference implementations

Phase 3 — Backend/frontend images through the registry axis

Phase 4 — JWT/secret persistence via the K8s API

Phase 5 — Orchestration entry point + wiring

Phase 6 — Config

Manual verification