mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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>
61 lines
1.5 KiB
Go
61 lines
1.5 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeBackupTargetRequest(t *testing.T) {
|
|
req := backupTargetRequest{
|
|
Name: " garage ",
|
|
Endpoint: "https://garage.local:3900/",
|
|
Bucket: " backups ",
|
|
Prefix: " /prod/dbs/ ",
|
|
}
|
|
if err := normalizeBackupTargetRequest(&req); err != nil {
|
|
t.Fatalf("normalize failed: %v", err)
|
|
}
|
|
if req.Endpoint != "garage.local:3900" {
|
|
t.Fatalf("endpoint not normalized: %q", req.Endpoint)
|
|
}
|
|
if req.Bucket != "backups" || req.Prefix != "prod/dbs" {
|
|
t.Fatalf("bucket/prefix not trimmed: %q %q", req.Bucket, req.Prefix)
|
|
}
|
|
|
|
for i, bad := range []backupTargetRequest{
|
|
{Endpoint: "e", Bucket: "b"}, // no name
|
|
{Name: "n", Bucket: "b"}, // no endpoint
|
|
{Name: "n", Endpoint: "e"}, // no bucket
|
|
} {
|
|
if err := normalizeBackupTargetRequest(&bad); err == nil {
|
|
t.Fatalf("case %d: expected error", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArchiveReaderUnwrapsTar(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
payload := []byte("backup-bytes")
|
|
if err := tw.WriteHeader(&tar.Header{Name: "b.tar.gz", Mode: 0o644, Size: int64(len(payload))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write(payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := &archiveReader{tar: io.NopCloser(&buf)}
|
|
got, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
t.Fatalf("payload mismatch: %q", got)
|
|
}
|
|
}
|