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.)
Cluster/ClusterProvider(backend/services/cluster_providers/__init__.py) already gives a pluggable, credentialedK8sClientpercluster_type, but it's used today only for process/analysis Jobs (backend/services/job_orchestrator.py,backend/services/cluster_job_provisioning.py'sensure_cluster_job_ready()). Nothing routes app hosting through it.- The default
Clusterseeded from config.env is always the same cluster the backend itself runs on (confirmed assumption, settled in discussion) — "which cluster runs Jobs" and "which cluster hosts the app" are the same row. This plan does not add a new pluggable axis/model; it extends the existingClusterProviderABC with app-hosting capability methods. prod/runall-minikube.shis pure shell, bypassesCluster/ClusterProviderentirely, and is the only place any of this is implemented today:- Builds backend and frontend images directly into Minikube's Docker daemon
(
eval $(minikube docker-env)thendocker build) — never pushed anywhere.k8s/backend/deployment.yaml/k8s/frontend/deployment.yamlhardcodeimagePullPolicy: Never, which only works because the image already exists in-daemon. - Config/secrets (JWT key, MinIO creds, registry auth, bootstrap-provisioned
<AXIS>_PROTOCOL/<AXIS>_CONFIG_JSON, admin creds) are created imperatively viakubectl create secret ... --dry-run=client -o yaml | kubectl apply -f -, against whichever kubeconfig context is currently active on the host running the script — not resolved through anyClusterrow. - The JWT signing key is persisted on the host filesystem
(
${NAGELFLUH_DATA_DIR}/jwt_secret_key, default~/.nagelfluh/data) so it survivesminikube delete && minikube start. This mechanism assumes the deploying host and the cluster share a filesystem — true for Minikube, not true for a cluster only reachable over the network. - Exposure is a hardcoded
NodePort(k8s/frontend/service.yaml, port 30080), published on the host by Minikube's docker driver;SERVER_URLdefaults tohttp://$(hostname -I):30080. - The DB migration step is a raw
kubectl applyof abatch/v1 Jobwith a hardcodedimage: nagelfluh-backend:prodand a literalDATABASE_URL(lines ~374–398 of the script) — duplicated again as an initContainer on the backend Deployment itself. kubectl apply -R -f "${PROJECT_ROOT}/k8s/"applies namespaces, Postgres, backend/frontend, RBAC, Headlamp/pgAdmin — assumes one single reachable cluster/kubeconfig context for everything.- No TLS termination exists in-cluster today (grepped the repo for
letsencrypt|cert-manager|acme|certbot|ManagedCertificate: zero hits). Whatever HTTPS the current production deployment has is handled entirely outside this repo, in front of the cluster. This plan doesn't touch that; it only adds a hook so a cloud-managed cluster with no such external edge can terminate TLS itself, via whichever provider-specific plugin implementsexpose_app()for that cluster type. - The registry axis already exists and is reusable here.
docs/plans/done/registry-backend-hooks.mdaddedRegistryBackend/RegistryProtocolHandler,image_url()/pull_credentials()/configure_push_auth(), and per-Job ephemeral pull secrets — today wired up only for the process-runner image (docker/build.sh→backend/bin/nagelfluh-registry-push). Backend/frontend images are not pushed through it at all. Any cluster that isn't sharing a Docker daemon with the build host (i.e. anything but Minikube's local-daemon trick) needs its app images to go through this same axis. self_service_registrationonClusterProvideris the existing precedent for a per-type capability flag that changes control flow without touching the router (seebackend/services/cluster_providers/__init__.py:29) — the natural shape for gating whether a givencluster_typesupports automated app deployment at all (a generickubeconfigbring-your-own cluster realistically can't auto-know its own Ingress class, for example).
Design decisions (settled in discussion)
- No new pluggable axis. App hosting always targets the same
Clusterrow that job execution's provider (same-as-backend/kubeconfig/minikube/any future plugin-provided type) already resolves. This generalizes the existingClusterProviderABC; it does not introduce a secondCluster-like model. - Two new
ClusterProvidercapability methods, both optional (defaultNotImplementedError, gated behind a newsupports_app_deploymentclass flag mirroringself_service_registration): 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.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/minikubeimplement 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).- A shared, provider-agnostic helper does the identical K8s-API work every provider's
deploy_app()calls into: newbackend/services/app_deployment.py,apply_app_workloads(k8s_client, namespace, images, app_config, secrets)— written againstkubernetes_asyncio(same libraryK8sClient/ensure_cluster_job_ready()already use, not shell/kubectl), applying: - backend + frontend
Deployment/Service(parameterized byimages["backend"]/images["frontend"], replacing the hardcodednagelfluh-backend:prod/nagelfluh-frontend:prod+imagePullPolicy: Never), - the
nagelfluh-backend-config/nagelfluh-backend-secretConfigMap/Secret(replacing the imperativekubectl create secretblock), - the DB migration
Job(replacing the hardcoded-image heredoc inrunall-minikube.sh). This mirrors the shape ofcluster_job_provisioning.py'sensure_cluster_job_ready(): a shared utility, not itself part of the ABC, called by every provider's own hook method. - Backend/frontend images are pushed through the existing registry axis, not built into a
local Docker daemon.
deploy_app()receives already-resolvedimage_url()strings (per the registry axis's existingRegistryProtocolHandler.image_url()); pods use the registry's per-Job/per-Deployment pull credential mechanism fromregistry-backend-hooks.mdPhase 3 instead ofimagePullPolicy: Never. This is a real behavior change for local dev/Minikube (today's fast "build straight into the daemon" path) — see Open items. - 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 existingnagelfluh-backend-secretfirst and reuses itsJWT_SECRET_KEYif present, only generating a new one if the Secret doesn't exist yet. This works identically for Minikube (replaces theNAGELFLUH_DATA_DIRhost-file mechanism) and any remote cluster (no shared filesystem needed). APP_DOMAINbecomes a new, optionalconfig.envvalue, threaded through unchanged as part ofapp_configintoexpose_app(). This plan does not interpret it — it's meaningless forsame-as-backend/minikube(NodePort has no concept of a domain) and entirely up to whichever provider'sexpose_app()wants to use it.- Providers that don't set
supports_app_deployment = Trueare unaffected — an operator using the generickubeconfigcluster type continues to expose/manage the app manually viak8s/*.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
- Whether
same-as-backend/minikubekeep a fast local-Docker-daemon build path as a special case, or uniformly push backend/frontend images through the registry axis now too (simpler, one code path, but changes today's local dev/Minikube build speed and requires the registry to be reachable duringdeploy_app()) — confirm before implementing Phase 3. - Exact shape of
app_config(what's in it beyondAPP_DOMAIN— replica counts? resource requests?) — start minimal, matching what's needed for parity with today'srunall-minikube.sh; extend later as plugin-provided providers need more, rather than over-designing now. - Whether
prod/runall-minikube.shitself gets rewritten to call through the newdeploy_app()/expose_app()mechanism (dogfooding it, same rationale asdocker-v2being the registry axis's reference implementation) or keeps its own parallel shell logic short-term with the new hook exercised only by a plugin-provided provider initially. Leaning towards dogfooding for the same reasonensure_cluster_job_ready()replaced both existing shell implementations rather than just the new one — but flag as open since it's a larger refactor of a script that works today. - New orchestration entry point name/shape —
backend/bin/nagelfluh-deploy-app(new, shell-into- Python bridge likenagelfluh-bootstrap-provision) vs. folding intonagelfluh-bootstrap-provisionitself — decide once Phase 5 is scoped. - Whether
Secret/ConfigMapfield names change at all from today'snagelfluh-backend-secret/nagelfluh-backend-config(Design decision 3 assumes they stay the same) — confirm no other code depends on the exactkubectl create secret --dry-run=clientinvocation shape rather than just the resulting object.
Phases
Phase 1 — Shared workload-apply helper
backend/services/app_deployment.py:apply_app_workloads(k8s_client, namespace, images, app_config, secrets)per Design decision 3 — Deployments/Services/ConfigMap/Secret/migration Job, all viakubernetes_asyncio.
Phase 2 — ClusterProvider ABC extension + reference implementations
- Add
supports_app_deploymentflag,deploy_app(),expose_app()tobackend/services/cluster_providers/__init__.py. - Implement both for
same-as-backend/minikube(NodePort, parameterized from today's hardcoded30080/hostname -I), calling Phase 1's helper for the workload part.
Phase 3 — Backend/frontend images through the registry axis
- Generalize image build+push (currently Minikube-daemon-only) to route through the existing
RegistryProtocolHandler/nagelfluh-registry-pushmechanism, resolving per Open items whether Minikube keeps a fast-path exception.
Phase 4 — JWT/secret persistence via the K8s API
- Replace
NAGELFLUH_DATA_DIR/host-file JWT persistence with the check-before-generate approach in Phase 1's helper (Design decision 5).
Phase 5 — Orchestration entry point + wiring
- New entry point resolving the default
Cluster's provider and callingdeploy_app()/expose_app()(name/shape per Open items). - Decide and implement
prod/runall-minikube.sh's relationship to it (dogfood vs. parallel path, per Open items).
Phase 6 — Config
config.env.example: documentAPP_DOMAIN(optional, meaningless for core-provided providers).
Manual verification
- Fresh
prod-minikubedeploy (via whichever path Phase 5 settles on) reaches the same observable end state as today: app reachable atSERVER_URL, migrations applied, admin login works, Headlamp/pgAdmin reachable. - JWT key persists across a
minikube delete && minikube startrecreate using the new K8s-API mechanism (parity with today's host-file behavior) — existing tokens stay valid. - Confirm a
Clusterusing the generickubeconfigprovider (nosupports_app_deployment) is completely unaffected — no attempt to calldeploy_app()/expose_app()for it. - Provider-specific
expose_app()behavior for any plugin-provided cloud cluster type is verified entirely by that plugin's own dependent plan, not here — this plan's verification is scoped to the hook and thesame-as-backend/minikubereference implementation.