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

+232
View File
@@ -1241,6 +1241,204 @@ export interface paths {
patch?: never;
trace?: never;
};
"/backup-targets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List backup targets
* @description S3-compatible archive destinations for the caller. Secret keys are never returned — `has_credentials` signals presence.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Backup target list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
backup_targets?: components["schemas"]["BackupTarget"][];
};
};
};
};
};
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BackupTargetRequest"];
};
};
responses: {
/** @description Backup target created */
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation error or connection check failed */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/backup-targets/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Update a backup target
* @description Empty access_key/secret_key keep the stored pair — delete and recreate to remove credentials. Re-probed before saving.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BackupTargetRequest"];
};
};
responses: {
/** @description Backup target updated */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Backup target not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
post?: never;
/**
* Delete a backup target
* @description Fails with 409 while databases still point at the target.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Backup target deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Databases still reference this target */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/backup-targets/{id}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Re-probe a backup target
* @description Checks bucket access with the stored credentials. Always returns a probe result — `ok` false means unreachable or unauthorized.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
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;
};
"/services/{id}/domains": {
parameters: {
query?: never;
@@ -9095,6 +9293,8 @@ export interface components {
provider?: "managed" | "external";
/** @description managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container */
public_port?: boolean;
/** @description S3-compatible backup target receiving this database's archives, empty when local-only */
backup_target_id?: string;
external?: components["schemas"]["DatabaseExternal"];
/**
* Format: date-time
@@ -9167,6 +9367,8 @@ export interface components {
backup_schedule?: string | null;
/** @description Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container */
public_port?: boolean;
/** @description Assign a backup target for offsite archives; empty string clears */
backup_target_id?: string | null;
};
DatabaseActionRequest: {
/**
@@ -9217,6 +9419,36 @@ export interface components {
size?: string;
/** @enum {string} */
status?: "completed" | "failed" | "in_progress";
/** @description Archive also shipped to the database's configured backup target */
remote?: boolean;
};
BackupTarget: {
id?: string;
name?: string;
/** @description Bare host[:port] — scheme comes from use_tls */
endpoint?: string;
bucket?: string;
region?: string;
prefix?: string;
has_credentials?: boolean;
use_tls?: boolean;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
};
BackupTargetRequest: {
name?: string;
endpoint: string;
bucket: string;
region?: string;
prefix?: string;
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
access_key?: string;
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
secret_key?: string;
/** @default true */
use_tls: boolean;
};
DatabaseBackupConfig: {
enabled?: boolean;