Short-Lived, Per-Project Storage Credentials — Phase 2: hooks.run_first + select_storage
Part of short-lived-storage-credentials-00-overview.md — read that first for goal, background, and architecture summary. This is Phase 2 of 4.
Depends on: Phase 1 (short-lived-storage-credentials-01-storage-backend-model.md)
— needs the StorageBackend table and Project.storage_backend_id to exist.
Still no behavior change with zero plugins installed — default always wins.
2.1 backend/hooks.py additions
def _load_entry_points(name):
eps = importlib.metadata.entry_points(group='nagelfluh.hooks')
eps = sorted((ep for ep in eps if ep.name == name), key=lambda ep: ep.dist.name)
return [ep.load() for ep in eps]
def _run_first(name, default, *args, **kwargs):
for fn in _load_entry_points(name):
result = fn(*args, **kwargs)
if result is not None:
return result
return default
class _Hooks:
run = _Namespace(_run_sync)
run_async = _AsyncNamespace()
run_first = _Namespace(_run_first) # NOTE: run_first's signature is (name, default, *args)
# — the _Namespace.caller forwards default through args
run_first deliberately does not raise on disagreement between plugins — first registered
(now: first by plugin-name sort order) wins, the rest are silently ignored. Document this in the
docstring so a plugin author doesn't assume any other precedence.
2.2 select_storage hook — call site
backend/routers/projects.py, in create_project (currently line ~74). The request body
param is named project (a plain Dict) and the ORM row is proj; the acting user is
auth.user, not a bare user. Insert right after await db.flush() for proj (so proj.id
exists) and before member = ProjectMember(...) is built — this folds into the single
db.commit() a few lines down, no extra round-trip:
from backend.hooks import hooks
DEFAULT_STORAGE_BACKEND_ID = 'default-storage-backend-00000000-0000-0000-0000-000000000000'
proj.storage_backend_id = hooks.run_first.select_storage(
DEFAULT_STORAGE_BACKEND_ID, db, auth.user, proj
)
A plugin implementing this hook receives the same shape of context job_pre_run already does
(db, the acting auth.user, and the object being decided about) — e.g. a billing-tier plugin
could route free-tier users to a shared MinIO backend and paying users to a dedicated GCS bucket.
proj.storage_backend_id must be set before asyncio.create_task(_setup_storage_background(project_id))
is fired a few lines later (routers/projects.py:106), since that background task is what
actually dispatches to a credential strategy per 2.3.
2.3 Provisioning becomes backend-parametrized
setup_project_storage() currently assumes MinIO unconditionally. It becomes dispatch on
project.storage_backend.credential_strategy only — StorageBackend.credential_strategy selects
a CredentialStrategy (Phase 3.2, short-lived-storage-credentials-03-credential-strategies.md),
which internally resolves project.storage_backend.protocol to a StorageProtocolHandler
(Phase 3.1) and delegates to it. is_minio_enabled() becomes the built-in minio protocol
handler's concern, not a global gate checked ad hoc across the codebase.
Two call sites need this dispatch, not one. is_minio_enabled() / setup_project_storage()
are called from two independent places today:
- routers/projects.py's _setup_storage_background() (project creation / manual re-setup via
POST /project/{id}/setup-storage).
- ProcessVersion.run_task() (backend/models/process.py:758-779) — re-provisions/refreshes the
k8s secret lazily before every job launch (e.g. after a cluster restart wipes the secret), with
its own independent copy of the is_minio_enabled() → ensure_project_k8s_secret() /
setup_project_storage() branching.
Both must go through the same per-backend dispatch or they will silently diverge the moment a
non-default StorageBackend exists. Extract a single shared helper — e.g.
storage_credentials.ensure_ready(db, project) -> credentials — that both call sites use instead
of each keeping its own if is_minio_enabled(): ... else: ... branch. This helper is also the
natural home for Phase 3's strategy dispatch (StaticKeyStrategy vs ShortLivedStrategy) and for
Phase 4's mint-per-launch call, so run_task()'s job-launch path and ShortLivedStrategy.mint()
don't need separate wiring later.
Next
Once this phase lands, continue with short-lived-storage-credentials-03-credential-strategies.md (depends on backend-installable-package.md as a prerequisite).