Pluggable registry backend + generalized bootstrap hooks — Plan
Goal
Make the container registry a pluggable backend, exactly like storage (StorageBackend/
StorageProtocolHandler) and clusters (Cluster/ClusterProvider) already are. This plan is
host-repo work only: the generic RegistryBackend/RegistryProtocolHandler extension axis, core's
own docker-v2 implementation of it (wrapping the existing self-hosted registry), the generalized
bootstrap() hook shared by all three pluggable axes, and the cleanup that follows from that
(protocol-agnostic docker/build.sh, per-Job pull credentials, generic seed migrations).
It also generalizes a related, previously provider-specific concern: making a newly-connectable
Cluster actually ready to run Nagelfluh Jobs (namespace, Kueue install, quotas/queues, RBAC). That
logic exists today as two independent, duplicated shell implementations (one for minikube, one
inside the GCP plugin's GKE setup script); this plan replaces both with one provider-agnostic Python
routine, since none of it is actually specific to any given cluster_type once you have a working
K8sClient — which ClusterProvider.connect() already supplies uniformly.
No Google/GAR-specific code is written here. Google Artifact Registry support is a separate,
dependent plan — docs/plans/gar-registry-protocol.md in the plugins/ymerflow-gcp plugin's own
repo — which implements a gar protocol against the hooks this plan defines. This plan must be
implemented (and the hooks it adds must be stable) before that one can start.
Background — current state
(Confirmed by reading the implemented code, not just the docs.)
Nagelfluh's only registry today is the self-hosted Docker Registry v2 that dev/setup-registry.sh
deploys inside Minikube (namespace registry, NodePort 30500, self-signed TLS, htpasswd basic
auth). Every consumer of "the registry" bakes in assumptions specific to that setup:
config.env/backend/config.py—REGISTRY_PUBLIC_HOST(bare host/IP, no scheme or port),REGISTRY_USER/REGISTRY_PASSWORD(dev-only, fed intodev/setup-registry.sh's htpasswd), andsettings.registry_url/settings.registry_auth(the values actually consumed at runtime —registry_authis a single base64(username:password) string, i.e. one long-lived static credential, not a token).docker/build.sh— constructsREGISTRY_URL="${REGISTRY_PUBLIC_HOST}:30500"(hardcoded NodePort), then doesdocker tag ... ${REGISTRY_URL}/nagelfluh-base-runner:${ENV_TAG}anddocker login "${REGISTRY_URL}" -u "${REGISTRY_USER}" --password-stdinwith the static credentials fromconfig.env.dev/setup-registry.sh— provisions the registry deployment itself (TLS cert generation, htpasswd secret, NodePort service, in-clusterExternalNameservice innagelfluh-jobs).dev/lib/provision-nagelfluh-jobs.sh— creates a single K8sdocker-registrySecret (nagelfluh-registry-pull, fixed name, one per jobs-namespace) fromREGISTRY_PUBLIC_HOST:30500+REGISTRY_USER/REGISTRY_PASSWORD, created once at provisioning time and never refreshed afterward.backend/services/job_orchestrator.py— every Job pod getsimagePullSecrets: [{name: "nagelfluh-registry-pull"}](the secret above) and, separately,REGISTRY_URL/REGISTRY_AUTHenv vars sourced from the same globalsettings.registry_url/settings.registry_auth(used bybuild_frontend_plugininside the runner to pull/build further images, not for pulling the pod's own image — see that file's docstring on why registry config is global rather than per-Cluster, perdocs/plans/done/cluster-registry-global-not-per-cluster.md).backend/routers/admin.py'sGET /static/assets/setup-minikube-remote.sh— for a remote Minikube cluster to be able to pull the image, this route live-fetches the registry's TLS certificate via a raw TLS handshake againstregistry_host:30500(_fetch_registry_ca_pem) and bakes the PEM into the generated setup script, plus the staticREGISTRY_USER/REGISTRY_PASSWORD, so the remote node's provisioning script can create its ownnagelfluh-registry-pullsecret pointing at the same registry.- The pluggable-backend pattern already exists twice, via the identical mechanism.
StorageBackend.protocoldispatches to aStorageProtocolHandler(backend/services/storage_protocols/__init__.py);Cluster.cluster_typedispatches to aClusterProvider(backend/services/cluster_providers/__init__.py). Both are discovered vianagelfluh.hooksfan-out hooks (storage_protocol_handlers/cluster_provider_handlers), and core registers its own built-ins (minio/s3,same-as-backend/kubeconfig/minikube) through the exact same channel a plugin would use — no "core is special" path. Both models have one active/default row used app-wide (DEFAULT_STORAGE_BACKEND_ID/DEFAULT_CLUSTER_ID,get_default_storage_backend_id()), seeded fromconfig.envvalues by one-time Alembic data migrations (182d880e84c7_backfill_default_storage_backend_config.pyfor MinIO,f6a7b8c9d0e1_seed_default_cluster.pyfor the default cluster — the latter is stale against the currentClusterschema and needs revisiting regardless of this plan). backend/bin/nagelfluh-migrateestablishes the precedent for a shell-invoked script bridging into plugin-provided Python viaimportlib.metadata.entry_points(group=...)directly (not even the fullhooks.pyfan-out machinery) — it discovers everynagelfluh.migration_dirsentry point to build Alembic'sversion_locations. This is the model fordocker/build.shcalling into protocol-specific push logic without itself knowing about any given protocol.prod/runall-minikube.sh'salembic-migrateJob is isolated from cluster config today. It runs in-cluster (kubectl applyabatch/v1 Job, lines ~274–294) with only a literalDATABASE_URLenv var — noenvFromreferencingnagelfluh-backend-secret/nagelfluh-backend-config(created earlier in the same script, lines ~122–201, and consumed by the backend Deployment). This is a real, pre-existing gap: today's182d880e84c7seed migration would silently see the Python-level defaults (minioadmin/minioadmin) inside that Job rather than an operator-customizedMINIO_ROOT_USER/PASSWORD, and it blocks any design that needs the seed migration to see config that's only assembled host-side (see Design decision 6 below).- Existing GCP plugin precedent (
plugins/ymerflow-gcp, its own repo):GkeClusterProvider/GkeK8sClient(GCP service-account key stored inCluster.provider_config, refreshed hourly via arefresh_api_key_hook) andGcsProtocolHandler(admin SA key stored inStorageBackend.config, produced once via a copy-pastegcloudsetup script +/admin/gcp/gcs/register-callback). This plan's hooks are what let that plugin add an equivalentgarregistry protocol — see its own plan. - Cluster job-readiness provisioning (Kueue/RBAC/queues) is duplicated across two shell scripts
today, and no backend Python hook does any of it.
dev/lib/provision-nagelfluh-jobs.sh— sourced bydev/setup-minikube.shand spliced verbatim into the generated remote setup script (admin.py:280, served atGET /static/assets/setup-minikube-remote.sh) — creates the jobs namespace, installs Kueue (CRDs/controller/webhook, with aminikube ssh -- nc-based webhook reachability probe that only works because it has host shell access), sizes aClusterQueuequota fromMINIKUBE_CPUS/MINIKUBE_MEMORYenv vars, appliesResourceFlavor/ClusterQueue/LocalQueue, applies thenagelfluh-backend-jobsRole/RoleBinding +nagelfluh-backend-kueue- readerClusterRole/ClusterRoleBinding (applied unconditionally, "so every provisioned cluster carries the same least-privilege intent" per its own comment, even though it's only load-bearing for thesame-as-backendcluster type), and creates the staticnagelfluh-registry-pullSecret (superseded by Design decision 4 above).plugins/ymerflow-gcp/gcp_plugin/scripts/setup-gke.sh.in(that plugin's own repo) independently reimplements the same Kueue-install-and-queue logic for a freshly-created GKE cluster, diverging in two incidental ways: it waits for webhook readiness via endpoint-population-plus-a-fixed-sleep instead of an active reachability probe, and it sizes the quota from real node allocatable capacity (kubectl get nodes -o json) rather than trusting env vars — the more general of the two approaches, since it works regardless of what any given node actually has available.POST /admin/clusters/register-callback(admin.py:186-246), the one place aClustertransitions from "just got a working config" to claimable, does none of this today — it only callsprovider.test_connection()(a generic list-namespaces check) and storesprovider_config; every bit of Kueue/RBAC/queue provisioning happens shell-side, before the callback ever fires.
Design decisions (settled in discussion)
- Add a third pluggable-backend axis:
RegistryBackend+RegistryProtocolHandler, mirroringStorageBackend/StorageProtocolHandlerexactly. New modelbackend/models/registry_backend.py(id, name, protocol, config JSON, active, sort_order) and newbackend/services/registry_protocols/package (RegistryProtocolHandlerABC +registry_protocol_handlershook +get_registry_protocol_handler(protocol)), following the exact shape ofbackend/services/storage_protocols/__init__.py. One active/default backend is used app-wide (get_default_registry_backend_id(), mirroringget_default_storage_backend_id()) — this preserves the "one global registry" decision fromdocs/plans/done/cluster-registry-global-not-per-cluster.mdas a consequence of the model shape, not a hardcoded assumption. - Core registers its own self-hosted registry as protocol
"docker-v2", throughregistry_protocol_handlers()in the rootsetup.py, alongside("minio", MinioProtocolHandler)— same list, same precedence (none). This is what keeps local/offline dev working with zero GCP dependency: agar(or any other plugin-provided) protocol is additive, never a required replacement. RegistryProtocolHandlerABC methods (implemented here only fordocker-v2; any other protocol is a plugin's job):image_url(repository, tag) -> str— the single place address shape is decided (mirrorsstorage_base_url).docker-v2returnshost:port/image:tag.pull_credentials(config) -> {"username", "password", "expires_at"}— resolves a pod image-pull credential.docker-v2returns the staticuser/passwordfrom its config,expires_at=None.configure_push_auth(config) -> None— performs whatever localdocker login/credential-helper setup push-side tooling needs before adocker push.docker-v2does today'sdocker login host:port -u ... -p ....test_connection(config) -> None.bootstrap(config) -> config— see decision 6.docker-v2's implementation is a passthrough (return config) — there is nothing to provision, the registry server itself is stood up bydev/setup-registry.sh, not by this hook.- No CA-pinning concept anywhere in the ABC:
docker-v2's handler keeps doing_fetch_registry_ca_pem-style CA scraping internally (self-signed TLS is adocker-v2-specific concern, moved in fromadmin.py); core callers never branch on protocol to decide whether CA trust is needed. A protocol that doesn't need it (like a real managed registry) simply never implements anything resembling it. - Pull-side: mint per-Job, not a long-lived synced Secret. Rather than refreshing a single
nagelfluh-registry-pullSecret on some schedule, resolvepull_credentials()at Job-creation time and create/attach an ephemeral pull secret alongside the Job — reusing the shape already established for storage'scredential_strategy="short-lived"(expires_at/refresh_tokenalready threaded throughcreate_job_manifestinbackend/services/job_orchestrator.py). This sidesteps "who refreshes what, on what trigger" entirely: a Job's pull credential is only ever as old as the Job itself. Applies uniformly to every cluster (local or remote), and to every protocol — this plan only has to prove it works fordocker-v2's static credential (expires_at=Nonejust means "never refresh, reuse the pod-launch-time value"), but the mechanism itself must not assume a static credential. - Push-side:
docker/build.shbecomes registry-protocol-agnostic. It no longer hardcodesREGISTRY_USER/PASSWORDdefaults,:30500, or adocker logincall. Instead it shells out to a new entry point,backend/bin/nagelfluh-registry-push <repository> <tag>, which: - loads the active
RegistryBackendrow (same DB connectiondocker/update_bootstrap_environment.pyalready opens), - resolves the handler via
get_registry_protocol_handler(backend.protocol), - calls
handler.configure_push_auth(backend.config), - calls
handler.image_url(repository, tag), - prints the resolved full image reference to stdout.
build.shcaptures that output,docker tags to it,docker pushes. This is the same shell-into-plugin-Python bridge shapenagelfluh-migratealready establishes (entry-point discovery), just applied to a new hook name — core only ever exercises this withdocker-v2; any other protocol'sconfigure_push_auth/image_urlbehavior is that plugin's concern. - Generalize "config.env can seed a default backend that needs live provisioning" to all three
axes (registry, storage, cluster) via one shared
bootstrap()hook and one new host-side entry point — even though core has no protocol that actually needs live provisioning today (docker-v2/minio/kubeconfigare all passthroughs), the hook and its plumbing are added here so that plugin-provided protocols (e.g.gar,gcs,gke) have somewhere to plug into without any host-repo changes: -
config.envgains a uniform shape for all three axes: ```bash REGISTRY_PROTOCOL=docker-v2 REGISTRY_CONFIG_JSON={"user":"nagelfluh","password":"nagelfluh","public_host":"192.168.1.142"}STORAGE_PROTOCOL=s3 STORAGE_CONFIG_JSON={...}
CLUSTER_TYPE=kubeconfig CLUSTER_CONFIG_JSON={...}
`` ExistingREGISTRY_USER/STORAGE_ENDPOINT/etc. keep working unchanged as a fallback when_PROTOCOL/_CONFIG_JSONaren't set explicitly (backward compatible for anyone not opting into a plugin-provided protocol). - One new common ABC method,bootstrap(config: dict) -> dict, added to all three handler bases —RegistryProtocolHandler(new),StorageProtocolHandler,ClusterProvider(new capability there — no such hook exists on it today). Every core-provided handler/provider implements it as a passthrough (return config); this plan does not implement any live-provisioningbootstrap()— that is entirely plugin territory (see the GCP plugin's own plan forgar, and any future plan forgcs/gkebootstrap). - One new host-side entry point,backend/bin/nagelfluh-bootstrap-provision(same shell-into-Python-entry-point shape asnagelfluh-migrate). For each axis present in the environment, it resolves the handler via the existing hook-backed registries (get_protocol_handler,get_cluster_provider, the newget_registry_protocol_handler), calls.bootstrap(config), and emits the enrichedresults (e.g. as JSON). - **Consuming the output differs by deployment mode:** - **dev** (dev/runall.sh): runs on the host with direct SQLite access — the enriched config is written straight into the three default DB rows. - **prod-minikube** (prod/runall-minikube.sh): the enriched JSON is folded intonagelfluh-backend-secret/nagelfluh-backend-configalongsideMINIO_ROOT_PASSWORD/REGISTRY_AUTH(today's--from-literalblock, lines ~122–201), which then needsenvFromadded to thealembic-migrateJob — closing the pre-existing gap noted in Background. The seed migrations read_PROTOCOL /_CONFIG_JSON from their env and upsert the corresponding default row — no live-provisioning calls happen in-cluster; any such call would already have happened host-side, in the operator's own environment, one step earlier (a concern for whichever plugin'sbootstrap()actually does one). 7. **Seed migrations become generic, replacing today's hardcoded ones.**182d880e84c7's MinIO-specific field names andf6a7b8c9d0e1's stale schema assumptions are replaced by a uniform "read_PROTOCOL /_CONFIG_JSON , upsert the default row'sprotocol/config" shape for all three axes — a new migration per axis (registry needs one regardless, since the model is new; storage/cluster get a follow-up migration replacing the old hardcoded ones). 8. **Generalize cluster job-readiness provisioning (Kueue/RBAC/queues) into one provider-agnostic Python routine, replacing both existing shell implementations.** Since installing Kueue, waiting for it, sizing/applying quotas and queues, and applying RBAC are pure Kubernetes API operations — nothing about them actually depends oncluster_typeonce aK8sClientexists — this becomes a singleensure_cluster_job_ready(k8s_client, namespace, quota_config)routine (backend/services/cluster_job_provisioning.py), written againstkubernetes_asyncio(the same libraryK8sClient/GkeK8sClientalready use), not shell/kubectl: - **Quota sizing uses real node allocatable capacity** (list_node()), uniformly — the more general of the two approaches the current shell scripts use (GKE's), and it works identically for minikube.MINIKUBE_CPUS/MINIKUBE_MEMORYremain relevant only forminikube start --cpus/--memoryitself, a separate, earlier, shell-side step — not for quota sizing anymore. - **Webhook readiness useskubectl wait-equivalent (poll the Deployment'savailablecondition) plus polling the webhook Service's Endpoints for a populated address** — both doable purely via the K8s API, replacing theminikube ssh-only reachability probe (which no other provider can perform) with something every provider supports identically. - **RBAC (thenagelfluh-backend-jobsRole/RoleBinding +nagelfluh-backend-kueue-readerClusterRole/ClusterRoleBinding) is applied unconditionally, every time**, preserving today's "same least-privilege intent regardless of whether this cluster's identity model strictly needs it" policy. - **The registry-pull-secret step is dropped entirely** from this routine — superseded by Design decision 4's per-Job ephemeral pull credentials, not ported forward. - **Invoked from one generic call site**, not three divergent ones:register-callback's claim path (admin.py), right aftertest_connection()succeeds and before/as the row becomes usable. Any plugin-providedcluster_type(e.g. a futuregke, whether registered via the existing self-service-registration flow or via abootstrap()-created defaultCluster, seeplugins/ymerflow-gcp/docs/plans/gcs-gke-bootstrap-provisioning.md) gets job-readiness for free from this one path — it never needs to reimplement any of this itself. -dev/lib/provision-nagelfluh-jobs.sh` shrinks down to whatever remains genuinely shell-only for local dev (namespace creation can even move into the generic routine too — see Phase 7); the Kueue/RBAC/queue logic it currently contains is deleted from it, not kept as a second implementation alongside the new Python one.
Open items to confirm at implementation time
- Whether
nagelfluh-bootstrap-provision's dev-mode DB write should go through Alembic (so it's versioned/re-runnable via the same migration chain) or write directly — leaning towards emitting the enriched config for the seed migrations to consume identically in both dev and prod-minikube mode, so there's exactly one code path that writes toregistry_backends/storage_backends/clusters, not two. - Whether the static per-namespace
nagelfluh-registry-pullSecret is dropped entirely once per-Job pull secrets exist, or kept as a fallback for some case — decide once Phase 3 is implemented and it's clear whether anything else still depends on it. - Confirm no other code path reads
settings.registry_url/settings.registry_authdirectly besides the ones enumerated in Background before removing them fromSettings. - Exact insertion point for
ensure_cluster_job_ready()withinregister-callback— right aftertest_connection()succeeds (decision 8) versus only once the admin actually claims the row viaadmin_update_cluster/Save. Leaning towards right aftertest_connection(), since a pending row that never gets claimed is harmless either way and it means the admin sees a fully job-ready cluster the moment they claim it, not a further wait. - Whether
admin_create_cluster(the direct, non-self-service path used bysame-as-backend/kubeconfigtoday) should also callensure_cluster_job_ready(), or whether that path already gets it some other way (e.g.dev/setup-minikube.shcalling it directly for the local default cluster) — confirm there's exactly one place a givenClustergets provisioned, not zero or two. - A config.env-driven default
Clusterseeded viabootstrap()(Design decision 6) never goes throughregister-callbackat all — it's written directly by the generic seed migration (Phase 6).ensure_cluster_job_ready()needs a second call site for this path too: oncenagelfluh-bootstrap-provision's seed migration has upserted the defaultClusterrow, call it there (dev: right after the direct DB write; prod-minikube: from the in-cluster seed migration, since that's where a workingK8sClientfor the newly-configured cluster can actually be constructed). A plugin'sbootstrap()(e.g.gkeinplugins/ymerflow-gcp) must not callensure_cluster_job_ready()itself — it only ever produces the credential; this host-repo call site is what makes job-readiness happen for a bootstrap-seeded cluster, exactly as it does for a self-service-registered one. - Whether
dev/lib/provision-nagelfluh-jobs.sh's namespace-creation step also moves into the new Python routine (decision 8 leans yes) or stays shell-side for the local dev bootstrap specifically.
Phases
Phase 1 — Core registry abstraction
backend/models/registry_backend.py:RegistryBackendmodel,get_default_registry_backend_id().backend/services/registry_protocols/__init__.py:RegistryProtocolHandlerABC,registry_protocol_handlershook,get_registry_protocol_handler().backend/services/registry_protocols/docker_v2.py: wraps today'sdev/setup-registry.shregistry —image_url,pull_credentials(static user/password),configure_push_auth(docker login),test_connection, CA-fetch logic moved in fromadmin.py's_fetch_registry_ca_pem,bootstrap= passthrough.- Register
("docker-v2", DockerV2ProtocolHandler)in rootsetup.py'sregistry_protocol_handlers. - New Alembic migration creating
registry_backends+ seeding the default row fromREGISTRY_PROTOCOL/REGISTRY_CONFIG_JSON(falling back to today'sREGISTRY_USER/PASSWORD/PUBLIC_HOSTif unset).
Phase 2 — Push-side generalization
backend/bin/nagelfluh-registry-push: new entry point per decision 5.docker/build.sh: replace hardcoded registry logic with a call into the new entry point.
Phase 3 — Pull-side per-Job ephemeral credentials
backend/services/job_orchestrator.py: resolve the activeRegistryBackend, callpull_credentials(), create/attach a per-Jobkubernetes.io/dockerconfigjsonSecret instead of the fixednagelfluh-registry-pullSecret. Updatedev/lib/provision-nagelfluh-jobs.sh/setup-minikube-remote.shaccordingly.
Phase 4 — Generic bootstrap() mechanism (hooks only, no live provisioning)
- Add
bootstrap()toRegistryProtocolHandler,StorageProtocolHandler,ClusterProvider, implemented as a passthrough on every core handler/provider (docker-v2,minio/s3,same-as-backend/kubeconfig/minikube). backend/bin/nagelfluh-bootstrap-provision: new entry point per decision 6.config.env.example: documentREGISTRY_PROTOCOL/REGISTRY_CONFIG_JSON,STORAGE_PROTOCOL/STORAGE_CONFIG_JSON,CLUSTER_TYPE/CLUSTER_CONFIG_JSON.
Phase 5 — Wire bootstrap into dev and prod-minikube flows
dev/runall.sh: callnagelfluh-bootstrap-provisionbefore migrations; write enriched config to the dev DB.prod/runall-minikube.sh: call it host-side, fold the enriched JSON intonagelfluh-backend-secret/nagelfluh-backend-config, add the missingenvFromto thealembic-migrateJob.
Phase 6 — Generic seed migrations
- Replace
182d880e84c7/f6a7b8c9d0e1's hardcoded shapes with the generic<AXIS>_PROTOCOL/<AXIS>_CONFIG_JSONupsert pattern for all three ofregistry_backends,storage_backends,clusters.
Phase 7 — Generic cluster job-readiness provisioning
backend/services/cluster_job_provisioning.py:ensure_cluster_job_ready(k8s_client, namespace, quota_config)per Design decision 8 — namespace, Kueue install + readiness waits, node-capacity- based quota sizing,ResourceFlavor/ClusterQueue/LocalQueue, backend RBAC. Purekubernetes_asyncio, no shell/kubectlsubprocess calls.- Wire it into
POST /admin/clusters/register-callback(admin.py) per the Open items' resolved insertion point, and into the seed-migration path for abootstrap()-seeded defaultCluster(Phase 6/Design decision 6) — the two call sites aClustercan become active through. - Strip the now-superseded Kueue/RBAC/queue/registry-pull-secret logic out of
dev/lib/provision-nagelfluh-jobs.sh, leaving only whatever remains genuinely shell-only for local dev (if anything). - Update
dev/setup-minikube.shaccordingly (it currently sources and callsprovision_nagelfluh_jobs()directly for the local default cluster — decide whether it now instead triggers the same Python path, e.g. by callingregister-callback-equivalent logic locally, or keeps a thin shell call into the new Python routine).
Manual verification
- Local (default) cluster/backends: fresh
dev/runall.sh, confirmdocker-v2/s3(minio)/kubeconfigdefaults still work end to end with noconfig.envchanges from today (backward compatibility) — build, push, and run a process. prod-minikubedeployment: confirm thealembic-migrateJob now receives the enrichedREGISTRY_CONFIG_JSON/etc. viaenvFromand seeds the default rows correctly.- Confirm a Job pod's pull secret is created per-Job and reflects
pull_credentials()'s output rather than the old staticnagelfluh-registry-pullSecret. - Confirm
bootstrap()being a no-op passthrough for every core protocol/provider doesn't change observable behavior for any existing deployment. - Protocol-specific behavior (GAR, and any future
gcs/gkebootstrap) is verified by each plugin's own plan, not here — this plan's verification is scoped to the hooks and thedocker-v2reference implementation. - Register a remote minikube cluster end to end via the existing self-service flow: confirm
ensure_cluster_job_ready()(Phase 7) produces the same end state the old shell logic did (namespace, Kueue, quotas, queues, RBAC — no registry pull secret anymore, per decision 8/Phase 3), and that a process actually runs on it afterward. - Confirm the local default cluster (
dev/runall.sh) still ends up job-ready after Phase 7's change todev/setup-minikube.sh/dev/lib/provision-nagelfluh-jobs.sh— same end state as before the shell logic was stripped out.