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}