feat(databases): S3-compatible backup targets — offsite archives + remote restore

Phase D3. Managed databases can ship backup archives to a configured
S3-compatible destination (AWS S3, MinIO, Garage, …) while the local
copy remains intact.

- backup_targets table: owner-scoped endpoint/bucket/region/prefix +
  encrypted access/secret keys (enc:v1:, never returned, has_credentials)
- create/update probe bucket access before storing; empty keys on update
  keep stored credentials; delete refused (409) while referenced
- databases.backup_target_id assigns a target; external DBs rejected
- backup pipeline uploads via a holder container over docker cp streams;
  remote failure keeps the local archive (logged), row gets remote:true
- restore prefers the local archive; on a missing local copy it downloads
  the remote object into the backup volume first
- holder containers must be started before archive ops — docker cp does
  not resolve volume mounts on never-started (Created) containers, and
  removing the source mid-stream stalls the reader without EOF
- CLI: backup-targets list|add|update|remove|test + databases update
  --backup-target; MCP: 5 new tools; Settings UI card + per-database
  target picker + offsite badge on backups; OpenAPI + llms.txt + CLI.md

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 22:54:36 +02:00
1 parent b7131965b1
commit 6f43bbf8f6
25 files changed
+1901 -32

No files matched your search

+177
View File
@@ -828,6 +828,118 @@ paths:
'404':
description: Registry not found
/backup-targets:
get:
tags:
- BackupTargets
summary: List backup targets
description: S3-compatible archive destinations for the caller. Secret keys are never returned — `has_credentials` signals presence.
security:
- bearerAuth: []
responses:
'200':
description: Backup target list
content:
application/json:
schema:
type: object
properties:
backup_targets:
type: array
items:
$ref: '#/components/schemas/BackupTarget'
post:
tags:
- BackupTargets
summary: Register a backup target
description: The bucket is probed (BucketExists) before storing — typos and bad credentials fail at registration. Keys are stored encrypted at rest.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupTargetRequest'
responses:
'201':
description: Backup target created
'400':
description: Validation error or connection check failed
/backup-targets/{id}:
put:
tags:
- BackupTargets
summary: Update a backup target
description: Empty access_key/secret_key keep the stored pair — delete and recreate to remove credentials. Re-probed before saving.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupTargetRequest'
responses:
'200':
description: Backup target updated
'404':
description: Backup target not found
delete:
tags:
- BackupTargets
summary: Delete a backup target
description: Fails with 409 while databases still point at the target.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Backup target deleted
'409':
description: Databases still reference this target
/backup-targets/{id}/test:
post:
tags:
- BackupTargets
summary: Re-probe a backup target
description: Checks bucket access with the stored credentials. Always returns a probe result — `ok` false means unreachable or unauthorized.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Probe result
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
latency_ms:
type: integer
error:
type: string
/services/{id}/domains:
get:
tags:
@@ -6322,6 +6434,9 @@ components:
public_port:
type: boolean
description: managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container
backup_target_id:
type: string
description: S3-compatible backup target receiving this database's archives, empty when local-only
external:
$ref: '#/components/schemas/DatabaseExternal'
next_backup_at:
@@ -6429,6 +6544,10 @@ components:
public_port:
type: boolean
description: Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container
backup_target_id:
type: string
nullable: true
description: Assign a backup target for offsite archives; empty string clears
DatabaseActionRequest:
type: object
@@ -6522,6 +6641,62 @@ components:
status:
type: string
enum: [completed, failed, in_progress]
remote:
type: boolean
description: Archive also shipped to the database's configured backup target
BackupTarget:
type: object
properties:
id:
type: string
name:
type: string
endpoint:
type: string
description: Bare host[:port] — scheme comes from use_tls
bucket:
type: string
region:
type: string
prefix:
type: string
has_credentials:
type: boolean
use_tls:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
BackupTargetRequest:
type: object
required:
- endpoint
- bucket
properties:
name:
type: string
endpoint:
type: string
bucket:
type: string
region:
type: string
prefix:
type: string
access_key:
type: string
description: Stored encrypted; never returned. Empty on update keeps stored credentials.
secret_key:
type: string
description: Stored encrypted; never returned. Empty on update keeps stored credentials.
use_tls:
type: boolean
default: true
DatabaseBackupConfig:
type: object
@@ -8065,5 +8240,7 @@ tags:
description: Scheduled jobs
- name: Registries
description: Private-image pull credentials
- name: BackupTargets
description: S3-compatible backup destinations for managed databases
- name: Gateway
description: API gateway — upstream services, keys, analytics