Route storage I/O through the project's StorageBackend, not global settings.storage_* — Plan

Goal

Make every runtime storage read/write path resolve the project's own StorageBackend (protocol, endpoint, bucket, credentials) instead of the single global settings.storage_protocol / storage_endpoint / storage_bucket_prefix / minio_root_user|password. Today the credential axis is already per-project (StorageBackend + credential strategies + protocol handlers, landed by short-lived-storage-credentials-00-overview.md), but the addressing axis — the storage URL, endpoint, and fsspec kwargs actually used at runtime — still reads the globals. The two only agree because the bootstrap migrations copied the globals into the default backend row; any second backend, or any GCS/S3 backend, silently reads/writes the wrong place. This is the concrete bug behind "a job on a GKE cluster with a GCS project writes its output to the global MinIO URL, which the pod can't even reach" (observed while implementing ../../plugins/ymerflow-gcp/docs/plans/done/gcp-credential-provisioning-revision.md).

The unifying principle: the StorageProtocolHandler for a project's backend is the single authority that produces (storage_base_url, fsspec_kwargs). Every runtime path — job pod env, the /files/ proxy, backend-side dataset/upload reads, post-job output scanning, plugin-asset serving — asks the handler, and the runner passes the handler's fsspec_kwargs straight to fsspec.open(url, **kwargs). fsspec already dispatches on the URL scheme (s3://, gs://, az://, file://), so no protocol-specific code lives in the runner or in any process type — only in the handlers.

Scope

In: backend/services/storage_service.py, backend/services/job_orchestrator.py, backend/services/storage_protocols/*, backend/services/storage_credentials.py, docker/base-runner/runner.py + storage_credentials_client.py, and the backend-side read call sites (routers/uploads.py, routers/datasets.py incl. the /files/ proxy, models/process.py:_create_outputs, services/plugin_registration.py, routers/plugins.py).

Also in: the GCS plugin's provisioning must move to one-bucket-per-project + a per-project bucket-scoped credential (see decision 2) — required for the access-control boundary, so it is part of this plan's contract, not deferred.

Out: no new schema (the model already carries protocol/endpoint/bucket_prefix/config and Project.storage_backend_id/creds); no admin-UI change; no change to select_storage / credential_strategy semantics; moving a project between backends stays out of scope (resolved once at creation, as today).

Design decisions (settled in discussion)

Background — current state (confirmed by reading the code)

Root of the coupling: backend/services/storage_service.py is stateless module-level functions that read settings.* and take only project_id/URL strings — no DB or StorageBackend access.

Phases

Phase 1 — Handler becomes the addressing authority

Add storage_base_url(project, backend) (→ <scheme>://<bucket_prefix><project_id>) and fsspec_kwargs(backend, credentials) to StorageProtocolHandler (storage_protocols/__init__.py) and implement them in MinioProtocolHandler, GcsProtocolHandler (plugins/ymerflow-gcp), and S3ProtocolHandler. Move the MinIO URL/kwargs logic out of storage_service.py/minio_service.py into the handler — MinIO's per-project-bucket layout and per-project bucket-scoped user are unchanged. Change the GCS plugin to one bucket per project, with a per-project bucket-scoped credential: its provision(project, backend) creates <bucket_prefix><project_id> and a per-project credential granting access to only that bucket (per-project SA + objectAdmin bound on that bucket, or minted equivalent) — moving both bucket and credential creation from StorageBackend-setup time to per-project provisioning (parallel to MinIO's setup_project_storage), replacing today's shared-bucket + shared-admin-SA model. No read-path call site changes yet — pure handler + provisioning. Verify a new project on each backend provisions its own bucket, that Project.storage_access_key/secret_key is scoped to that bucket only (a cross-project read is denied), and the handler addresses it.

Phase 2 — storage_service.py becomes backend-aware (backend-side, admin creds)

Turn the module-level functions into thin resolvers that, given a project (or project_id + db), load the project's StorageBackend and call the handler with admin creds. Update the 8 get_fsspec_storage_options sites and the 4 get_storage_base_url sites (see Background) to pass the resolved backend. Because these functions gain a db/backend dependency, some currently-sync call sites become async or take a pre-resolved backend — thread it through (uploads.py, datasets.py, process.py:_create_outputs, plugin_registration.py, plugins.py). Verify uploads, dataset reads, and post-job output scan still work on the default MinIO backend (no behavior change), then against a second backend row.

Phase 3 — /files/ proxy resolves project → backend

The /files/<bucket>/<rest> path's first segment is the project's bucket, whose name embeds project_id (<bucket_prefix><project_id>) — extract it (parse the trailing uuid / strip the prefix) to resolve the project. In get_file/get_dataset_part/get_dataset_part_geography (datasets.py) and the upload download (uploads.py:159), resolve bucket → project → StorageBackend and read with that backend's admin fsspec_kwargs. Verify /files/ serves a file from a non-default backend.

Phase 4 — Job env carries handler-built, project-scoped kwargs

In create_job_manifest (job_orchestrator.py): set STORAGE_BASE from handler.storage_base_url(project, backend), and add STORAGE_KWARGS_JSON = handler.fsspec_kwargs(backend, project_scoped_creds). Delete the settings.storage_protocol=="s3" branches, the localhost:9000 string-replace, and the AWS_* secretKeyRef block. Keep CREDENTIAL_STRATEGY (short-lived still refreshes). Drop the per-project k8s-secret creation (minio_service.create_k8s_secret/ensure_project_k8s_secret) from the launch path — it was the source of the wrong-cluster bug. Verify a static-key MinIO job still runs on the local cluster.

Phase 5 — Runner passes kwargs through

In runner.py:get_storage_kwargs: parse STORAGE_KWARGS_JSON and return it verbatim (no s3-specific construction). Update RefreshableStorageKwargs so the short-lived refresher rewrites the same protocol-general kwargs file (the refresh endpoint returns handler-built kwargs). Rebuild the runner image. Verify: (a) a MinIO static-key job reads/writes correctly; (b) a MinIO short-lived job still refreshes; (c) — end-to-end target — a GCS-backed project's job on a remote/GKE cluster reads/writes the GCS bucket via gs:// + {"token": …} with no runner code change.

Cleanup

Confirm nothing reads settings.storage_protocol|endpoint|bucket_prefix|minio_root_* at runtime (grep); leave them as seed-only. Remove/settle the stale file_service.py shims if unused.

Implementation Order

1–5 above, in order; each phase verified before the next. Phases 1–2 are refactors with no behavior change on the default backend; Phase 4–5 are the only ones that change pod/runtime behavior. The GCS-on-GKE end-to-end is the final acceptance test.

Open / deferred questions