Storage Admin UI — Plan
Goal
Give admins a way to create, edit, and retire StorageBackend rows from the Admin page instead of
direct DB access — the exact gap cluster-admin-ui.md flagged as "a
parallel admin-UI gap, tracked separately" once it landed. Scope is CRUD on the StorageBackend
table, a pluggable "how do we connect to this storage backend" mechanism mirroring
cluster_provider_forms/ClusterProvider, plus a prerequisite fix: today no StorageBackend
protocol is actually parametrized by its own row's fields (see Background) — an admin UI would be
cosmetic without that fix, since a second MinIO backend an admin creates would silently behave
identically to the first.
It does not touch select_storage's resolution algorithm (still hooks.run_first falling back to
one hardcoded default ID — see Open Questions), job launch, or credential minting itself (mint()
for short-lived stays unimplemented for every protocol, per
short-lived-storage-credentials-00-overview.md's
own Phase 4, not yet built).
Supersedes / modifies
Nothing landed — this is new surface area for the API/UI. It changes
MinioProtocolHandler.provision()/test_connection() and backend/services/minio_service.py to
read from a StorageBackend row instead of global settings (Phase 2), and adds two columns to
StorageBackend (Phase 1). It does not change StaticKeyStrategy/ShortLivedStrategy
(backend/services/storage_credentials.py) or the storage_protocol_handlers registry mechanism
itself — both already exist and already fit this plan's needs unchanged.
Background — current state
(Confirmed by reading the implemented code, not just the plans.)
backend/models/storage_backend.py—StorageBackendhasid, name, protocol, endpoint, bucket_prefix, credential_strategy, config, created_at.protocol(minio/gcs/s3) andconfig(opaque JSON) are already structurally identical in shape toCluster.cluster_type/provider_config— same "discriminator + opaque per-type blob, hidden fromto_dict()" convention. UnlikeCluster,StorageBackendhas nosort_orderoractivecolumn (no retire concept), and has a second, independent discriminator axiscredential_strategy(static-key/short-lived) thatClusterhas no equivalent of.- No route anywhere lists, creates, updates, or retires a
StorageBackendrow. Exhaustive grep ofbackend/routers/*.pyconfirms zero/admin/storage*(or any other) CRUD surface — this is genuinely greenfield, unlike clusters wherebackend/routers/admin.pyandfrontend/src/ClustersAdminPanel.jsxalready exist as a direct template to mirror (see below). The only route referencingStorageBackendat all isbackend/routers/internal.py(reads it to resolve a project's endpoint/protocol for job-facing needs). - The registry/dispatch mechanism this plan needs already exists and needs no changes:
backend/services/storage_protocols/__init__.py—StorageProtocolHandlerbase class (provision(project, backend),mint(project, backend)), discovered via thestorage_protocol_handlersfan-out hook (nagelfluh.hooksentry-point group, registered insetup.py), exactly parallel tocluster_provider_handlers/get_cluster_provider. Core registers its ownminio/gcs/s3handlers through the identical hook a plugin would use — "no core precedence" is already established practice here, same as for clusters and process types.backend/services/storage_credentials.py—CredentialStrategybase class (StaticKeyStrategy/ShortLivedStrategy), keyed byStorageBackend.credential_strategy, delegates to whichever protocol handlerbackend.protocolresolves to. Neither axis's dispatch code branches on the other — already exactly the shape this plan's admin UI needs to expose two independent selectors for. - Only
miniois functional today, and only partially.MinioProtocolHandler.provision()(backend/services/storage_protocols/minio.py) callssetup_project_storage(project.id)(backend/services/minio_service.py:150), which readssettings.storage_endpoint,settings.storage_bucket_prefix,settings.minio_root_user,settings.minio_root_password— global config, notbackend.endpoint/backend.bucket_prefix/backend.config. Themcadmin-CLI calls (_create_minio_user/_create_minio_policy/_attach_policy_to_user,minio_service.py:39-92) target a hardcoded alias"minio", pre-configured once via theMC_HOST_minioenv var at deploy time (prod/runall-minikube.sh:133) ormc alias setin dev (dev/setup-minio.sh:158) — outside the application entirely. This means creating a secondminio-protocolStorageBackendrow today would list/CRUD fine but silently provision against the exact same global endpoint as every other backend — the row's ownendpoint/bucket_prefix/configwould be inert.MinioProtocolHandler.mint()(forshort-lived) is an unimplemented stub.GcsProtocolHandler/S3ProtocolHandlerare pureNotImplementedErrorstubs for bothprovision()andmint()— no real GCS/AWS code exists at all yet._create_minio_user/_create_minio_policy/_attach_policy_to_useralready accept analiasparameter (default"minio") — the per-backend-alias plumbing this plan needs is mostly already there, just never given a non-default value. - This codebase has the exact precedent for everything this plan needs to build, because
cluster-admin-ui.mdbuilt it first for the parallelClusterresource: backend/auth_deps.py—require_admindependency, already extracted and shared.backend/routers/admin.py— already exists (currently cluster-only):_cluster_admin_dict(),_test_and_apply_connection(),_apply_generic_fields(),GET/POST /admin/clusters,PATCH /admin/clusters/{id},POST /admin/clusters/test-connection. This plan adds a parallel set of_storage_backend_admin_dict()/_test_and_apply_storage_connection()/_apply_storage_generic_fields()functions and/admin/storage-backends*routes to the same file — not a new router (parity with how both/admin/usersand/admin/clustersalready shareadmin.py).frontend/src/clusterProviders/{SameAsBackendClusterForm,KubeconfigClusterForm}.jsx+registerHook('cluster_provider_forms', ...)infrontend/src/App.jsx— this plan addsfrontend/src/storageProviders/{MinioStorageForm,GcsStorageForm,S3StorageForm}.jsx+registerHook('storage_protocol_forms', ...).frontend/src/ClustersAdminPanel.jsx(table + modal,configTouchedflag so unrelated edits don't wipe/retest connection config) — this plan addsfrontend/src/StorageBackendsAdminPanel.jsxfollowing the identical structure.frontend/src/AdminPage.jsx'sbuiltinTabsarray — this plan adds one more entry.frontend/src/datamodel/api.js(listAdminClusters/createAdminCluster/updateAdminCluster/testAdminClusterConnection) andfrontend/src/datamodel/useAuthQueries.js(useAdminClusters/useCreateAdminCluster/useUpdateAdminCluster/useTestAdminClusterConnection) — this plan adds the parallel*AdminStorageBackend*functions/hooks to the same two files.select_storage(backend/routers/projects.py) is a different hook shape fromselect_clusters:hooks.run_first(DEFAULT_STORAGE_BACKEND_ID, db, auth.user, proj)returns a single chosen ID (falling back to one hardcoded UUID constant, currently defined ad hoc inprojects.pyrather than colocated in the model the wayCluster.DEFAULT_CLUSTER_IDis), vs. clusters'get_allowed_clusters()returning a filtered set. No plugin registersselect_storagein this repo today, so it always resolves to the hardcoded default. This plan does not change that algorithm (see Open Questions) —active/sort_order(Phase 1) are added for admin-table parity withClusterand future extensibility, not becauseselect_storageconsults them yet.
Design decisions (settled in discussion)
- This plan fixes MinIO to be genuinely per-backend-parametrized, not just CRUD-visible.
StorageBackend.configforprotocol='minio'becomes{"admin_access_key": ..., "admin_secret_key": ...}— the MinIO root/admin credentials for that specific endpoint (parallel to howprovider_config.kubeconfigholds a cluster's connection secret). Provisioning and connection-testing usebackend.endpoint/backend.bucket_prefix/backend.configexclusively; no fallback tosettings.storage_endpoint/minio_root_user/minio_root_passwordremains anywhere in the dispatch path (Phase 2). This includes the bootstrap default row itself — itsconfiggets backfilled from the current global settings values in the same migration that removes the code's reliance on those globals, so all MinIO backends, including the original one, go through the identical per-backend path. No special-cased "the default row still reads settings" carve-out — same "core has no special precedence" principle already used for the protocol-handler registry itself. - All three protocols (
minio,gcs,s3) are selectable in the admin UI now.gcs/s3cleanly failtest_connection/provisioning with a clear "not implemented" error — same precedent as a bad kubeconfig failing cleanly for clusters, and keeps the registered-form mechanism visibly extensible (a future GCS implementation is "swap the stub for real code + write aGcsStorageForm," no registry change).GcsStorageForm/S3StorageFormare placeholder components (mirroringSameAsBackendClusterForm's style) stating support isn't implemented yet and that Test Connection will fail — not empty/hidden, since the field values (whatever an admin types) should still be preserved for whenever real implementations land, matching the no-cross-type-carryover-but-still-a-real-form precedent from clusters. credential_strategy(static-key/short-lived) is exposed as a second, independent selector now, even thoughshort-livedis unimplemented for every protocol (mint()raisesNotImplementedErroreverywhere). This means an admin can create a backend that passes Test Connection (which validates protocol reachability only, not minting) but fails at actual job launch — an accepted, explicit trade-off. To reduce (not eliminate) that confusion, the frontend shows a static warning banner in the form whenevershort-livedis selected: "Short-lived credential minting is not implemented for any protocol yet — jobs launched against a backend using this strategy will fail." This is a client-side UX hint only;test_connectiondeliberately stays scoped to protocol reachability (see below), not credential-strategy correctness — extending it to dry-runmint()would require threading a "dry run" concept through every future protocol's minting code for a strategy that isn't implemented yet, which is premature.sort_order+activeadded toStorageBackend, mirroringClusterexactly. "Retire" =PATCHwith{"active": false}; no DELETE route. Retired backends stay listed (visually distinguished) so historicalProject.storage_backend_idreferences stay resolvable and a backend can be reactivated. Table sorted bysort_order, matching theClusteradmin table.select_storage's default resolution now honorsactive/sort_order. A new project's storage backend is chosen as the first active backend ordered bysort_order, replacing the hardcoded-default-UUID fallback (Phase 1.3). This is the storage analog ofget_allowed_clusters()filtering clusters byactiveand ordering bysort_order— retiring a backend genuinely removes it from new-project selection, andsort_ordercontrols which active backend new projects land on. Theselect_storagehook is unchanged in shape (stillhooks.run_first— a plugin can still override the pick per-user/per-project); only the default the hook falls back to changes from a constant ID to the computed first-active-by-sort_order id. If no active backend exists, project creation fails with a clear error rather than silently assigning a retired one — a project with no storage backend is a real misconfiguration, not a state to paper over.StorageProtocolHandlergains atest_connection(backend) -> Nonemethod, mirroringClusterProvider.test_connection(). Unlike clusters (where one generic implementation — build a kubeconfig, list namespaces — covers bothsame-as-backendandkubeconfig), storage protocols are too different from each other for a shared default: no default implementation in the base class, each handler must implement it explicitly (NotImplementedErrorin the base is a hard error, not a silent no-op, if a future protocol forgets to override it).test_connectionvalidates connectivity/credentials only — no bucket/user/policy creation, since it must be safe to call repeatedly while an admin is still filling out the create form, before any project exists to provision for.MinioProtocolHandler.test_connection()builds a per-backendMinioSDK client frombackend.endpoint/backend.configand callslist_buckets()(same check the existing module-levelminio_service.test_connection()does today, just parametrized instead of global-settings-based — that module-level function andget_minio_client()/is_minio_enabled()become dead code once this lands, since nothing outsideminio_service.pyitself calls them today; confirmed by grep).GcsProtocolHandler.test_connection()/S3ProtocolHandler.test_connection()raise the same clear "not implemented" error theirprovision()stubs already do. Both the "Test Connection" button (client-triggered) and thePOST/PATCHadmin routes themselves (server-side, authoritative) call it — server never trusts the client tested it — same rule as clusters.- Secrets never round-trip to the browser. List/get responses omit
configentirely, replaced withhas_config(boolean). Every per-protocol form component's initial value is empty on edit (write-only), with a "(currently set)" hint driven byhas_config. Switchingprotocolon an edit always requires re-entering connection config from scratch — no cross-protocol config carryover — mechanically via the sameconfigTouchedflag patternClustersAdminPanel.jsxalready uses (only sendprotocol+configin the PATCH body if the admin actually interacted with the protocol form; leaving it untouched skips re-runningtest_connectiontoo, so an admin editing onlysort_orderdoesn't fail because storage is momentarily unreachable). - New routes added to the existing
backend/routers/admin.py, not a new router file — it already exists and already holds the identical cluster-admin pattern; splitting storage into its own file would just duplicate therequire_admin/dict-shape conventions for no benefit. Not added to the MCPinclude_tagsallowlist, matching/admin/clustersand/admin/users— storage backend administration (admin credential entry, connection testing) is deliberately UI-only.
Phase 1 — StorageBackend schema: sort_order + active
1.1 Model changes
backend/models/storage_backend.py:
sort_order = Column(Integer, nullable=False, default=0)
active = Column(Boolean, nullable=False, default=True)
to_dict() adds "sort_order": self.sort_order, "active": self.active — not sensitive, safe in
the base dict (parallel to Cluster.to_dict()).
Also add, colocated with the model (parity with Cluster.DEFAULT_CLUSTER_ID):
DEFAULT_STORAGE_BACKEND_ID = 'default-storage-backend-00000000-0000-0000-0000-000000000000'
backend/routers/projects.py drops its local copy of this constant and imports it from
backend.models.storage_backend instead — same value, single source of truth. The constant is
still used by the seed migration (fixed UUID for the bootstrap row) but is no longer the runtime
resolution fallback (see 1.3).
1.2 Migration
One migration, off the current head (5ebf42871eb0 at time of writing — verify with
alembic -c backend/alembic.ini heads before creating, since other work may land first):
"""add sort_order, active to storage_backends"""
revision = 'bc173d62da23' # generated via python3 -c "import uuid; print(uuid.uuid4().hex[:12])"
down_revision = '5ebf42871eb0' # verify at implementation time
def upgrade() -> None:
with op.batch_alter_table('storage_backends') as batch_op:
batch_op.add_column(sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()))
def downgrade() -> None:
with op.batch_alter_table('storage_backends') as batch_op:
batch_op.drop_column('active')
batch_op.drop_column('sort_order')
Directly mirrors b707a57376f7_add_cluster_selection_columns.py. The existing bootstrap "Default
Storage Backend" row gets sort_order=0, active=True — behaviorally identical to today (still the
only backend, still what every project resolves to).
Verify the generated revision id is still unique before committing: grep -rn "revision =
'bc173d62da23'" --include=*.py . (per CLAUDE.md migration rule) — confirmed unique against the
current tree as of this plan's writing, but re-check at implementation time since other migrations
may have landed in between.
1.3 select_storage default resolution — first active backend by sort_order
New module-level helper in backend/models/storage_backend.py, the storage analog of
get_allowed_clusters():
from sqlalchemy import select
async def get_default_storage_backend_id(db) -> str:
"""The storage backend a new project is assigned by default: the first active backend
ordered by sort_order. Raises if none are active — a project cannot be created without a
storage backend to provision against."""
stmt = select(StorageBackend).where(StorageBackend.active == True).order_by(StorageBackend.sort_order)
result = await db.execute(stmt)
backend = result.scalars().first()
if backend is None:
raise RuntimeError("No active storage backend configured — cannot create a project")
return backend.id
backend/routers/projects.py create_project changes from passing the constant as the
run_first default to computing it:
proj.storage_backend_id = hooks.run_first.select_storage(
await get_default_storage_backend_id(db), db, auth.user, proj
)
run_first's semantics are unchanged: a registered select_storage plugin's non-None result still
wins; only the fallback default changes from the hardcoded UUID to the first-active-by-sort_order
id. The bootstrap row (sort_order=0, active=True, seeded with the well-known UUID) remains the
default in a fresh install exactly as before — but now that's because it's the first active row by
sort_order, not because its id is hardcoded into the resolution path. Retiring it (or giving another
backend a lower sort_order) changes what new projects get, as intended.
Phase 2 — Backend: MinIO becomes genuinely per-backend-parametrized
This is the prerequisite fix without which Phase 3's admin UI would let admins create backends that silently do nothing different from the default one.
2.1 Data migration: backfill the bootstrap row's config
Second migration, immediately after Phase 1's:
"""backfill admin credentials into default storage backend config"""
revision = '090d358a2f80'
down_revision = 'bc173d62da23'
DEFAULT_ID = 'default-storage-backend-00000000-0000-0000-0000-000000000000'
def upgrade() -> None:
from backend.config import settings
conn = op.get_bind()
conn.execute(sa.text("""
UPDATE storage_backends SET config = :config
WHERE id = :id AND (config IS NULL OR config = '{}')
"""), {
"id": DEFAULT_ID,
"config": json.dumps({
"admin_access_key": settings.minio_root_user,
"admin_secret_key": settings.minio_root_password,
}),
})
def downgrade() -> None:
pass # cannot cleanly undo — config may have been legitimately edited since
Follows the same "read live settings inside upgrade()" pattern as
a6b7c8d9e0f1_seed_default_storage_backend.py. The config IS NULL OR config = '{}' guard makes
this idempotent and avoids clobbering a config an admin may have already set via the new UI
between Phase 1 landing and this migration running in a given environment — though in practice
these two migrations land together.
This migration must run before 2.2's code change reaches production — verify order in Implementation Order below.
2.2 minio_service.py — parametrize instead of reading settings
get_minio_client() is replaced by a parametrized version:
def get_minio_client_for_backend(endpoint: str, admin_access_key: str, admin_secret_key: str) -> Minio:
parsed = urlparse(endpoint)
return Minio(
parsed.netloc or parsed.path,
access_key=admin_access_key,
secret_key=admin_secret_key,
secure=parsed.scheme == "https",
)
A new helper ensures the mc CLI alias exists for a given backend before any _create_minio_user/
_create_minio_policy/_attach_policy_to_user call (these already accept an alias param —
just never given a non-default value until now):
def _ensure_mc_alias(alias: str, endpoint: str, admin_access_key: str, admin_secret_key: str) -> None:
"""mc alias set is an idempotent upsert — safe to call before every operation."""
_run_mc(["alias", "set", alias, endpoint, admin_access_key, admin_secret_key])
setup_project_storage() gains explicit params instead of reading settings:
def setup_project_storage(
project_id: str, endpoint: str, bucket_prefix: str,
admin_access_key: str, admin_secret_key: str,
k8s_namespace: str = "nagelfluh-jobs",
) -> dict:
alias = f"backend-{hashlib.sha1(endpoint.encode()).hexdigest()[:12]}"
_ensure_mc_alias(alias, endpoint, admin_access_key, admin_secret_key)
client = get_minio_client_for_backend(endpoint, admin_access_key, admin_secret_key)
bucket_name = f"{bucket_prefix}{project_id}"
... # same bucket/user/policy/k8s-secret steps as today, passing alias= through to
# _create_minio_user/_create_minio_policy/_attach_policy_to_user
alias is derived from endpoint (not backend.id) so two StorageBackend rows that happen to
point at the same physical MinIO endpoint share one mc alias rather than needlessly creating two
— harmless either way, but avoids alias churn if an admin duplicates a backend row.
is_minio_enabled(), the old settings-based get_minio_client(), and the module-level
test_connection() are deleted — confirmed by grep that nothing outside minio_service.py calls
them, and once MinioProtocolHandler stops calling setup_project_storage(project.id) with no
other args, nothing constructs the "is MinIO even configured" question globally anymore (a
StorageBackend row with protocol='minio' existing is the configuration).
create_k8s_secret/ensure_project_k8s_secret are unchanged — the k8s secret's namespace is a
project/job-launch concern, not a storage-backend-connection concern (orthogonal axis, same
reasoning Cluster.registry_url being independent of cluster_type used).
cleanup_project_storage() is deleted. It is dead code — grep confirms zero call sites anywhere
in the repo, and its own docstring says "(for testing)". It reads is_minio_enabled() and
settings.storage_bucket_prefix (both removed by this phase), so leaving it would be dead code
referencing deleted symbols. No replacement is added — nothing invokes bucket cleanup today; if a
real "delete a project's storage" operation is ever needed it belongs in a StorageProtocolHandler
method (per-backend), designed then, not resurrected from this global-settings helper.
2.3 MinioProtocolHandler — call with per-backend params, add test_connection
backend/services/storage_protocols/minio.py:
from backend.services.storage_protocols import StorageProtocolHandler
from backend.services.minio_service import setup_project_storage, get_minio_client_for_backend
class MinioProtocolHandler(StorageProtocolHandler):
def provision(self, project, backend) -> dict:
return setup_project_storage(
project.id, backend.endpoint, backend.bucket_prefix,
backend.config["admin_access_key"], backend.config["admin_secret_key"],
)
def mint(self, project, backend) -> dict:
raise NotImplementedError(
"MinIO short-lived credential minting (expiring service account / native STS) "
"is not implemented yet"
)
async def test_connection(self, backend) -> None:
client = get_minio_client_for_backend(
backend.endpoint, backend.config["admin_access_key"], backend.config["admin_secret_key"]
)
await asyncio.to_thread(lambda: list(client.list_buckets()))
provision()/test_connection() both raise a plain KeyError/TypeError if backend.config is
missing admin_access_key/admin_secret_key — caught by the admin route's existing generic
except Exception and surfaced as a 400 with a clear message (same pattern as a malformed
kubeconfig), no special-casing needed.
2.4 StorageProtocolHandler base class + gcs.py/s3.py stubs
backend/services/storage_protocols/__init__.py: add the abstract method to the base class:
class StorageProtocolHandler:
def provision(self, project, backend) -> dict:
raise NotImplementedError
def mint(self, project, backend) -> dict:
raise NotImplementedError
async def test_connection(self, backend) -> None:
"""Validate connectivity/credentials only — no side effects, safe to call
repeatedly from the admin UI before any project exists to provision for.
No default implementation: protocols are too different from each other for a
shared check (unlike ClusterProvider, where one generic k8s-list-namespaces
check covers every cluster type)."""
raise NotImplementedError
gcs.py/s3.py: add async def test_connection(self, backend) -> None: raise
NotImplementedError("GCS storage support is not implemented yet") (and the AWS-S3 equivalent) —
same message style as their existing provision()/mint() stubs.
Phase 3 — Backend: admin routes
3.1 backend/routers/admin.py — add storage backend routes alongside cluster routes
from backend.models.storage_backend import StorageBackend
from backend.services.storage_protocols import get_protocol_handler
def _storage_backend_admin_dict(backend: StorageBackend) -> Dict:
d = backend.to_dict()
d["has_config"] = bool(backend.config)
return d
async def _test_and_apply_storage_connection(backend: StorageBackend, body: Dict) -> None:
if "protocol" in body or "config" in body:
protocol = body.get("protocol", backend.protocol)
config = body.get("config") or {}
try:
handler = get_protocol_handler(protocol)
await handler.test_connection(_TestBackend(backend.endpoint, config))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Connection test failed: {e}")
backend.protocol = protocol
backend.config = config
def _apply_storage_generic_fields(backend: StorageBackend, body: Dict) -> None:
if "name" in body:
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
backend.name = name
if "endpoint" in body:
backend.endpoint = body.get("endpoint") or None
if "bucket_prefix" in body:
prefix = (body.get("bucket_prefix") or "").strip()
if not prefix:
raise HTTPException(status_code=400, detail="bucket_prefix is required")
backend.bucket_prefix = prefix
if "credential_strategy" in body:
if body["credential_strategy"] not in ("static-key", "short-lived"):
raise HTTPException(status_code=400, detail="invalid credential_strategy")
backend.credential_strategy = body["credential_strategy"]
if "sort_order" in body:
try:
backend.sort_order = int(body["sort_order"])
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="sort_order must be an integer")
if "active" in body:
if not isinstance(body["active"], bool):
raise HTTPException(status_code=400, detail="active must be a boolean")
backend.active = body["active"]
@router.get("/admin/storage-backends")
async def admin_list_storage_backends(auth=Depends(require_admin), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(StorageBackend).order_by(StorageBackend.sort_order))
return [_storage_backend_admin_dict(b) for b in result.scalars().all()]
@router.post("/admin/storage-backends")
async def admin_create_storage_backend(body: Dict, auth=Depends(require_admin), db: AsyncSession = Depends(get_db)):
if not (body.get("name") or "").strip():
raise HTTPException(status_code=400, detail="name is required")
if not (body.get("bucket_prefix") or "").strip():
raise HTTPException(status_code=400, detail="bucket_prefix is required")
backend = StorageBackend(
name=body["name"].strip(), bucket_prefix=body["bucket_prefix"].strip(),
protocol=body.get("protocol", "minio"),
credential_strategy=body.get("credential_strategy", "static-key"),
)
_apply_storage_generic_fields(backend, body)
await _test_and_apply_storage_connection(backend, body)
db.add(backend)
await db.commit()
return _storage_backend_admin_dict(backend)
@router.patch("/admin/storage-backends/{backend_id}")
async def admin_update_storage_backend(backend_id: str, body: Dict, auth=Depends(require_admin), db: AsyncSession = Depends(get_db)):
backend = await db.get(StorageBackend, backend_id)
if backend is None:
raise HTTPException(status_code=404, detail="Storage backend not found")
_apply_storage_generic_fields(backend, body)
await _test_and_apply_storage_connection(backend, body)
await db.commit()
return _storage_backend_admin_dict(backend)
@router.post("/admin/storage-backends/test-connection")
async def admin_test_storage_backend_connection(body: Dict, auth=Depends(require_admin)):
protocol = body.get("protocol")
if not protocol:
raise HTTPException(status_code=400, detail="protocol is required")
try:
handler = get_protocol_handler(protocol)
await handler.test_connection(_TestBackend(body.get("endpoint"), body.get("config") or {}))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Connection test failed: {e}")
return {"ok": True}
endpoint needs to be set on the backend before _test_and_apply_storage_connection runs (order
matters, unlike clusters where connection config is entirely self-contained in provider_config) —
_apply_storage_generic_fields runs first in both create and update. _TestBackend is a tiny
namedtuple/dataclass (endpoint, config) used so test_connection(backend) has a consistent
.endpoint/.config shape whether called against a real ORM row (update path) or a not-yet-created
one (create/standalone-test-button path) — avoids constructing a throwaway unsaved StorageBackend
ORM instance just to pass two fields around.
No DELETE route — retiring only, same as clusters (Design decisions).
3.2 Register — already registered
admin_router is already included in main.py (from the cluster admin work); no change needed
here, just the new routes living in the same file.
Phase 4 — Frontend: pluggable per-protocol connection forms
4.1 Form components
New frontend/src/storageProviders/MinioStorageForm.jsx:
export default function MinioStorageForm({ value, onChange, hasExisting }) {
return (
<>
<Form.Group>
<Form.Label>Admin Access Key</Form.Label>
<Form.Control
placeholder={hasExisting ? '(unchanged — enter to replace)' : ''}
value={value.admin_access_key || ''}
onChange={e => onChange({ ...value, admin_access_key: e.target.value })}
/>
</Form.Group>
<Form.Group>
<Form.Label>Admin Secret Key</Form.Label>
<Form.Control
type="password"
placeholder={hasExisting ? '(unchanged — enter to replace)' : ''}
value={value.admin_secret_key || ''}
onChange={e => onChange({ ...value, admin_secret_key: e.target.value })}
/>
</Form.Group>
</>
);
}
New frontend/src/storageProviders/GcsStorageForm.jsx / S3StorageForm.jsx — placeholder inputs
(not empty/hidden — per Design decisions, values are preserved for whenever real support lands) plus
a text-muted note that this protocol isn't implemented yet and Test Connection will fail.
4.2 Registration
frontend/src/App.jsx, alongside cluster_provider_forms:
registerHook('storage_protocol_forms', () => [
{ type: 'minio', title: 'MinIO', Component: MinioStorageForm },
{ type: 'gcs', title: 'Google Cloud Storage', Component: GcsStorageForm },
{ type: 's3', title: 'AWS S3', Component: S3StorageForm },
]);
Phase 5 — Frontend: data hooks
frontend/src/datamodel/api.js (mirror listAdminClusters/etc. exactly):
listAdminStorageBackends(), createAdminStorageBackend(body),
updateAdminStorageBackend(id, body), testAdminStorageBackendConnection(body).
frontend/src/datamodel/useAuthQueries.js, mirroring the cluster hooks exactly:
useAdminStorageBackends(), useCreateAdminStorageBackend(), useUpdateAdminStorageBackend(),
useTestAdminStorageBackendConnection() — each invalidating queryKey: ['adminStorageBackends'].
Same reasoning as clusters: plain TanStack Query for an admin-only resource, the
ProcessContext/invalidateProject rule doesn't apply here.
Phase 6 — Frontend: Storage admin tab
New frontend/src/StorageBackendsAdminPanel.jsx, mirroring ClustersAdminPanel.jsx's shape:
- Table of all backends (including retired, visually distinguished), columns: Name, Protocol, Endpoint, Bucket Prefix, Credential Strategy, Sort Order, Active, Edit button.
- "Add Storage Backend" / edit form (modal): generic fields (Name, Endpoint, Bucket Prefix, Sort
Order, Active — the last only on edit) plus a Protocol
<select>populated fromhooks.run.storage_protocol_forms()and a Credential Strategy<select>(static-key/short-lived, independent of protocol). Selecting a protocol renders that entry'sComponentwith{ value: configState, onChange, hasExisting: has_config }; switching protocol resetsconfigStateto{}(no cross-protocol carryover). Selectingshort-livedshows the static warning banner from Design decisions. - Test Connection button next to the protocol form, calling
useTestAdminStorageBackendConnectionwith{ protocol, endpoint, config }— spinner then a clear pass/fail message. Not a precondition for Save (Save re-tests authoritatively server-side), just faster feedback. - Submit calls
useCreateAdminStorageBackend/useUpdateAdminStorageBackend. Body only includesprotocol/configif the protocol form was actually touched this session (configTouchedflag, identical mechanism toClustersAdminPanel.jsx).
frontend/src/AdminPage.jsx: add a third builtinTabs entry:
{ key: 'storage', title: 'Storage', render: () => <StorageBackendsAdminPanel /> },
Implementation Order
- Phase 1 — schema migration (
sort_order/active,DEFAULT_STORAGE_BACKEND_IDmoved into the model). No behavior change. - Phase 2.1 — the
configbackfill migration. Must land and run before 2.2-2.4's code deploys, since onceMinioProtocolHandler.provision()stops falling back to globalsettings, the bootstrap row'sconfigmust already hold real admin credentials or the one working production path breaks. Verify by reading the bootstrap row'sconfigafter migrating in a real environment before proceeding. - Phase 2.2–2.4 —
minio_service.pyparametrization,MinioProtocolHandler/gcs.py/s3.pytest_connection. Verify end-to-end: triggersetup_project_storagefor a real project (e.g. viaPOST /project/{id}/setup-storage) and confirm it still works identically to before, using only the bootstrap row's now-populatedconfig— no regression on the one live path. - Phase 3 — backend admin routes. Verify via
/docs/curlwith an admin session: list should show the bootstrap backend withhas_config: true; create a secondminiobackend pointed at the same dev MinIO endpoint with the same admin credentials, confirm Test Connection passes; create agcsbackend, confirm Test Connection fails with a clear "not implemented" message. - Phase 4 — frontend protocol-form registration, no admin UI consuming it yet.
- Phase 5 — frontend query/mutation hooks.
- Phase 6 —
StorageBackendsAdminPanel+AdminPage.jsxwiring. End-to-end test: create a second MinIO backend via the UI pointing at the same dev MinIO instance but a differentbucket_prefix, confirm Test Connection passes, confirm it appears retired/active correctly in the table, confirm retiring it doesn't affect the (unrelated, unchanged)select_storagefallback behavior for new projects.
Open Questions
- GCS/S3 real implementations — explicitly deferred, same status as before this plan (
mint()/provision()stubs). When either lands, it plugs into the exact registry/form mechanism this plan establishes — no dispatch-layer changes needed, per the "core has no special precedence" pattern already proven for clusters and process types. short-livedcredential_strategy — this plan exposes the selector but implements no minting. A future plan (Phase 4 of the original short-lived-storage-credentials plan, still not built) would make it functional for at least one protocol; until then the warning banner is the only guardrail.- Migrating an existing project between
StorageBackendrows stays out of scope, same as the original plan's stance —select_storage/provisioning only ever resolves once, at project creation.