feat(databases): bind managed DB connection URL to a service

Databases page cards gain a Bind action: pick project → service → variable
key (default DATABASE_URL) and the connection_url is injected as a secret
env var via the existing variables endpoint. Verified e2e: merged PUT lands
the real URL and preserves masked secret values.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:35:05 +02:00
1 parent bcd3a83aa4
commit aeb91b03e9
2 files changed
+123 -4

No files matched your search

+4 -4
View File
@@ -322,10 +322,10 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
`?installation_id=` return by calling `POST /git/github-app/connect`. `?installation_id=` return by calling `POST /git/github-app/connect`.
(Verified at typecheck/lint level; real GitHub App redirect e2e tracked (Verified at typecheck/lint level; real GitHub App redirect e2e tracked
under the OAuth/provider-credentials item in Phase 4.) under the OAuth/provider-credentials item in Phase 4.)
- [ ] **Database → service binding**: creating a managed DB does not wire it - [x] **Database → service binding**: `/databases` cards have a Bind action —
into a service. Add a bind action that injects the resolved pick project → service → variable key (default `DATABASE_URL`) and the
`connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen `connection_url` is injected as a secret env var. Verified e2e: merged
service, so apps can actually consume provisioned databases. PUT lands the URL and preserves masked secrets verbatim.
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams - [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
the archive out of the `containr-db-backups` volume via a stopped utility the archive out of the `containr-db-backups` volume via a stopped utility
container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat` container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat`
@@ -22,6 +22,9 @@ import {
databaseAction, databaseAction,
createDatabaseBackup, createDatabaseBackup,
restoreDatabaseBackup, restoreDatabaseBackup,
listServicesByProject,
listServiceVariables,
updateServiceVariables,
getGitHubAppInstallUrl, getGitHubAppInstallUrl,
connectGitHubApp, connectGitHubApp,
updateCurrentUserProfile, updateCurrentUserProfile,
@@ -1514,6 +1517,7 @@ const dbStatusClass: Record<string, string> = {
export function DatabasesPage() { export function DatabasesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
const databasesQuery = useQuery({ const databasesQuery = useQuery({
queryKey: ['databases'], queryKey: ['databases'],
queryFn: listDatabases, queryFn: listDatabases,
@@ -1625,6 +1629,14 @@ export function DatabasesPage() {
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button> </button>
<button
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url}
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
<Link2 size={11} /> Bind
</button>
{actionMutation.isError && ( {actionMutation.isError && (
<span className="text-xs text-[var(--error)]"> <span className="text-xs text-[var(--error)]">
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'} {actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
@@ -1632,6 +1644,13 @@ export function DatabasesPage() {
)} )}
</div> </div>
{bindDb?.id === db.id && db.connection_url && (
<BindDatabasePanel
connectionUrl={db.connection_url}
onClose={() => setBindDb(null)}
/>
)}
{db.metrics && ( {db.metrics && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{([ {([
@@ -1708,3 +1727,103 @@ export function DatabasesPage() {
</div> </div>
); );
} }
function BindDatabasePanel({
connectionUrl,
onClose,
}: {
connectionUrl: string;
onClose: () => void;
}) {
const [projectId, setProjectId] = useState('');
const [serviceId, setServiceId] = useState('');
const [varKey, setVarKey] = useState('DATABASE_URL');
const [bound, setBound] = useState(false);
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: projectId !== '',
});
const bindMutation = useMutation({
mutationFn: async () => {
const existing = await listServiceVariables(serviceId);
const merged = existing
.filter((v) => v.key !== varKey)
.map((v) => ({ key: v.key, value: v.value, is_secret: v.isSecret }));
merged.push({ key: varKey, value: connectionUrl, is_secret: true });
await updateServiceVariables(serviceId, merged);
},
onSuccess: () => setBound(true),
});
const services = servicesQuery.data ?? [];
return (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-raised)] p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
</div>
<p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Project
<select
value={projectId}
onChange={(e) => { setProjectId(e.target.value); setServiceId(''); }}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
>
<option value="">Select…</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Service
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
disabled={!projectId}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)] disabled:opacity-40"
>
<option value="">Select…</option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Variable key
<input
value={varKey}
onChange={(e) => setVarKey(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40"
/>
</label>
<button
onClick={() => bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium disabled:opacity-40"
>
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button>
</div>
{bound && (
<p className="text-xs text-[var(--success)]">
Bound — {varKey} is set on the service. Redeploy the service for it to take effect.
</p>
)}
{bindMutation.isError && (
<p className="text-xs text-[var(--error)]">
{bindMutation.error instanceof Error ? bindMutation.error.message : 'Bind failed'}
</p>
)}
</div>
);
}