feat(databases): backup import — upload .tar.gz as a restore point

D5. POST /databases/:id/backups/import accepts a gzipped tar archive
(multipart `file` field or raw application/gzip body, 2 GiB ceiling),
stages it to a temp file, streams it into the backup volume via the
started holder container, and registers a completed backup row — a
first-class restore point that also ships offsite when a target is
assigned. External databases rejected.

Also fixes `containr databases restore` — it never sent database_id,
which the API requires, so every CLI restore failed validation.

CLI: databases import-backup <id> <file|-> (stdin support via new
client.DoRaw). UI: Import button in the backups row. OpenAPI + docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 23:07:27 +02:00
1 parent 109f79268f
commit 15045b8dd3
11 files changed
+409 -10

No files matched your search

@@ -31,6 +31,7 @@ import {
restoreDatabaseBackup,
createManagedDatabase,
registerExternalDatabase,
importDatabaseBackup,
updateDatabase,
listBackupTargets,
createBackupTarget,
@@ -130,6 +131,7 @@ import {
HeartPulse,
Cloud,
ExternalLink,
Upload,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -2120,6 +2122,10 @@ export function DatabasesPage() {
updateDatabase(id, { backup_target_id: targetId }),
onSuccess: invalidate,
});
const importBackupMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
onSuccess: invalidate,
});
const testByIdMutation = useMutation({
mutationFn: (id: string) => testDatabaseConnectionByID(id),
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
@@ -2358,6 +2364,24 @@ export function DatabasesPage() {
))}
</select>
)}
<label
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-all ${importBackupMutation.isPending ? 'opacity-50' : 'cursor-pointer hover:text-[var(--text-primary)]'}`}
title="Import a .tar.gz archive as a restore point"
>
<Upload size={11} />
{importBackupMutation.isPending ? 'Importing…' : 'Import'}
<input
type="file"
accept=".tar.gz,application/gzip"
className="hidden"
disabled={importBackupMutation.isPending}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importBackupMutation.mutate({ id: db.id ?? '', file: f });
e.target.value = '';
}}
/>
</label>
<button
onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending}
+72
View File
@@ -3612,6 +3612,78 @@ export interface paths {
patch?: never;
trace?: never;
};
"/databases/{id}/backups/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import a backup archive
* @description Upload a `.tar.gz` archive (multipart field `file` or a raw application/gzip body) to create a new restore point. The archive lands in the backup volume and, when a backup target is assigned, is also shipped offsite. Managed databases only; 2 GiB ceiling.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Database ID */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file?: string;
};
"application/gzip": string;
};
};
responses: {
/** @description Backup imported */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
backup_id?: string;
size?: string;
status?: string;
};
};
};
/** @description Invalid archive */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Archive too large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/databases/{id}/backups/{bid}/download": {
parameters: {
query?: never;
+15
View File
@@ -1727,6 +1727,21 @@ export async function restoreDatabaseBackup(id: string, backupId: string): Promi
});
}
export async function importDatabaseBackup(id: string, file: File): Promise<{ backup_id: string; size: string }> {
const form = new FormData();
form.append('file', file);
const resp = await fetch(`${getApiBaseUrl()}/databases/${encodeURIComponent(id)}/backups/import`, {
method: 'POST',
headers: authHeaders(),
body: form,
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new ApiError(payload.error || 'Import failed', resp.status);
}
return payload;
}
export async function createManagedDatabase(
input: CreateDatabaseInput,
): Promise<{ id: string; status: string }> {