mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: external databases — register, probe, encrypted credentials
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.
- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3664eaed1a
commit
5dc9b5f115
19 files changed
+1274
-48
No files matched your search
@@ -30,6 +30,10 @@ import {
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
testDatabaseConnection,
|
||||
testDatabaseConnectionByID,
|
||||
type ExternalDatabaseInput,
|
||||
listServicesByProject,
|
||||
listServiceVariables,
|
||||
updateServiceVariables,
|
||||
@@ -1901,7 +1905,11 @@ export function DatabasesPage() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
|
||||
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const databasesQuery = useQuery({
|
||||
queryKey: ['databases'],
|
||||
@@ -1929,6 +1937,40 @@ export function DatabasesPage() {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
||||
},
|
||||
});
|
||||
const externalPayload = (): ExternalDatabaseInput => ({
|
||||
name: externalForm.name.trim(),
|
||||
type: externalForm.type,
|
||||
host: externalForm.host.trim(),
|
||||
port: Number(externalForm.port) || undefined,
|
||||
database: externalForm.database.trim() || undefined,
|
||||
username: externalForm.username.trim() || undefined,
|
||||
password: externalForm.password || undefined,
|
||||
ssl: externalForm.ssl,
|
||||
});
|
||||
const registerExternalMutation = useMutation({
|
||||
mutationFn: () => registerExternalDatabase(externalPayload()),
|
||||
onSuccess: () => {
|
||||
setCreateOpen(false);
|
||||
setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||
setExternalProbe(null);
|
||||
setCreateError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to register database');
|
||||
},
|
||||
});
|
||||
const probeMutation = useMutation({
|
||||
mutationFn: () => testDatabaseConnection(externalPayload()),
|
||||
onSuccess: (result) => setExternalProbe(result),
|
||||
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
|
||||
});
|
||||
const testByIdMutation = useMutation({
|
||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
onError: (error, id) =>
|
||||
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||
});
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
||||
databaseAction(id, action),
|
||||
@@ -2014,9 +2056,16 @@ export function DatabasesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||
{db.provider === 'external' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
|
||||
{db.provider === 'external' && db.external
|
||||
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
|
||||
: `${db.plan} · ${db.region}`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -2044,6 +2093,25 @@ export function DatabasesPage() {
|
||||
{signedIn ? (
|
||||
<>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
||||
{db.provider === 'external' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||
disabled={testByIdMutation.isPending}
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={11} /> Test connection
|
||||
</button>
|
||||
{externalTestResult[db.id ?? ''] && (
|
||||
<span className={`text-xs ${externalTestResult[db.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{externalTestResult[db.id ?? '']?.ok
|
||||
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
|
||||
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||
disabled={running || actionMutation.isPending}
|
||||
@@ -2065,6 +2133,8 @@ export function DatabasesPage() {
|
||||
>
|
||||
<RefreshCw size={11} /> Restart
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||
disabled={!db.connection_url}
|
||||
@@ -2107,6 +2177,7 @@ export function DatabasesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{db.provider !== 'external' && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||
@@ -2163,6 +2234,7 @@ export function DatabasesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2177,18 +2249,125 @@ export function DatabasesPage() {
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
|
||||
<div className="mt-3 flex gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||
{(['managed', 'external'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => { setCreateMode(mode); setCreateError(null); }}
|
||||
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--text-tertiary)]">
|
||||
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
|
||||
value={createMode === 'managed' ? createForm.name : externalForm.name}
|
||||
onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))}
|
||||
placeholder="my-postgres"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{createMode === 'external' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||
<select
|
||||
value={externalForm.type}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, type: e.target.value }))}
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{DATABASE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Host</label>
|
||||
<input
|
||||
value={externalForm.host}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, host: e.target.value }))}
|
||||
placeholder="db.example.com"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</label>
|
||||
<input
|
||||
value={externalForm.port}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, port: e.target.value }))}
|
||||
placeholder="default"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Database</label>
|
||||
<input
|
||||
value={externalForm.database}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, database: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Username</label>
|
||||
<input
|
||||
value={externalForm.username}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, username: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={externalForm.password}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={externalForm.ssl}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, ssl: e.target.checked }))}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
Require TLS/SSL
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => probeMutation.mutate()}
|
||||
disabled={probeMutation.isPending || externalForm.host.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||
>
|
||||
{probeMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Test connection
|
||||
</button>
|
||||
{externalProbe && (
|
||||
<span className={`text-xs ${externalProbe.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||
@@ -2224,6 +2403,7 @@ export function DatabasesPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{createError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||
) : null}
|
||||
@@ -2238,12 +2418,15 @@ export function DatabasesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || createForm.name.trim().length === 0}
|
||||
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
|
||||
disabled={
|
||||
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
|
||||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
Create database
|
||||
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3034,6 +3034,154 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/register-external": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Register external database
|
||||
* @description Record a database hosted elsewhere — connection is probed first (postgres/redis get protocol-level checks, others TCP). Password stored encrypted, never returned.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExternalDatabaseRequest"] & Record<string, never>;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description External database registered */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation failed or connection probe failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Name already in use */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/test-connection": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Probe a database connection
|
||||
* @description Ad-hoc connectivity check — postgres via libpq ping, redis/dragonfly via PING, other engines via TCP dial. Always 200; `ok` carries the result.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExternalDatabaseRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Probe result */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
ok?: boolean;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/{id}/test-connection": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Re-probe a registered external database
|
||||
* @description Uses the stored credentials. External databases only.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Probe result (`ok` carries the outcome) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Database not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8940,6 +9088,12 @@ export interface components {
|
||||
region?: string;
|
||||
/** @description Cron expression for automatic backups, empty when unset */
|
||||
backup_schedule?: string;
|
||||
/**
|
||||
* @description managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere
|
||||
* @enum {string}
|
||||
*/
|
||||
provider?: "managed" | "external";
|
||||
external?: components["schemas"]["DatabaseExternal"];
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Next scheduled backup run
|
||||
@@ -8975,6 +9129,28 @@ export interface components {
|
||||
/** @description Database region */
|
||||
region: string;
|
||||
};
|
||||
DatabaseExternal: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
ssl?: boolean;
|
||||
};
|
||||
ExternalDatabaseRequest: {
|
||||
/** @description Display name (required for register, unused for probes) */
|
||||
name?: string;
|
||||
/** @enum {string} */
|
||||
type: "postgresql" | "redis" | "mysql" | "mariadb" | "mongodb" | "clickhouse" | "dragonfly";
|
||||
host: string;
|
||||
/** @description Defaults to the engine's standard port */
|
||||
port?: number;
|
||||
database?: string;
|
||||
username?: string;
|
||||
/** @description Stored encrypted; never returned */
|
||||
password?: string;
|
||||
ssl?: boolean;
|
||||
};
|
||||
UpdateDatabaseRequest: {
|
||||
/** @description Updated database name */
|
||||
name?: string;
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate eLine truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -1733,6 +1733,39 @@ export async function createManagedDatabase(
|
||||
return { id: payload.id, status: payload.status ?? 'building' };
|
||||
}
|
||||
|
||||
export type ExternalDatabaseInput = {
|
||||
name?: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
ssl?: boolean;
|
||||
};
|
||||
|
||||
export async function registerExternalDatabase(input: ExternalDatabaseInput): Promise<{ id: string }> {
|
||||
const payload = await requestJson<{ id?: string }>('/databases/register-external', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!payload.id) {
|
||||
throw new ApiError('Database response is invalid', 500);
|
||||
}
|
||||
return { id: payload.id };
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(input: ExternalDatabaseInput): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||
return requestJson('/databases/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function testDatabaseConnectionByID(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||
|
||||
Reference in new issue
Block a user