From 6f43bbf8f6a751b5bfee0b2a2819bfe80620167d Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 22:54:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(databases):=20S3-compatible=20backup=20tar?= =?UTF-8?q?gets=20=E2=80=94=20offsite=20archives=20+=20remote=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- app/backend/go.mod | 13 +- app/backend/go.sum | 21 + app/backend/internal/api/backuptargets.go | 506 ++++++++++++++++++ .../internal/api/backuptargets_test.go | 60 +++ app/backend/internal/api/databases.go | 92 +++- app/backend/internal/api/routes.go | 7 + .../internal/cli/commands/backuptargets.go | 204 +++++++ .../internal/cli/commands/databases.go | 7 +- app/backend/internal/cli/root.go | 1 + .../database/sqlcdb/backup_targets.sql.go | 253 +++++++++ .../internal/database/sqlcdb/databases.sql.go | 18 +- .../internal/database/sqlcdb/models.go | 17 + .../internal/database/sqlcdb/querier.go | 10 + app/backend/internal/docker/client.go | 5 + app/backend/internal/mcpserver/server.go | 9 +- .../20261015000000_backup_targets.sql | 34 ++ app/backend/sqlc/queries/backup_targets.sql | 43 ++ app/backend/sqlc/queries/databases.sql | 4 +- app/backend/sqlc/schema.sql | 17 + app/frontend/src/features/secondary/pages.tsx | 164 ++++++ app/frontend/src/generated/api-types.ts | 232 ++++++++ app/frontend/src/lib/api-client.ts | 34 ++ docs/api/openapi.yaml | 177 ++++++ docs/guides/CLI.md | 4 + llms.txt | 1 + 25 files changed, 1901 insertions(+), 32 deletions(-) create mode 100644 app/backend/internal/api/backuptargets.go create mode 100644 app/backend/internal/api/backuptargets_test.go create mode 100644 app/backend/internal/cli/commands/backuptargets.go create mode 100644 app/backend/internal/database/sqlcdb/backup_targets.sql.go create mode 100644 app/backend/migrations_goose/20261015000000_backup_targets.sql create mode 100644 app/backend/sqlc/queries/backup_targets.sql diff --git a/app/backend/go.mod b/app/backend/go.mod index 3fb58ff..f3fae01 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -37,23 +37,29 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/minio/crc64nvme v1.0.2 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.95 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -63,8 +69,10 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -74,6 +82,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/app/backend/go.sum b/app/backend/go.sum index ea3684d..5d253b0 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -46,6 +46,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -65,6 +67,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -82,9 +86,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -99,6 +108,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= +github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= +github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -130,6 +145,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -144,6 +161,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -182,6 +201,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= diff --git a/app/backend/internal/api/backuptargets.go b/app/backend/internal/api/backuptargets.go new file mode 100644 index 0000000..6e05eb3 --- /dev/null +++ b/app/backend/internal/api/backuptargets.go @@ -0,0 +1,506 @@ +package api + +import ( + "archive/tar" + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "containr/internal/database/sqlcdb" + "containr/internal/docker" + "containr/internal/secrets" + + "github.com/docker/docker/api/types/mount" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// BackupTarget is an S3-compatible archive destination. Secret keys are +// stored encrypted and never serialized — has_credentials signals presence. +type BackupTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Region string `json:"region,omitempty"` + Prefix string `json:"prefix,omitempty"` + HasCredentials bool `json:"has_credentials"` + UseTLS bool `json:"use_tls"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + accessKey string `json:"-"` + secretKey string `json:"-"` +} + +type backupTargetRequest struct { + Name string `json:"name"` + Endpoint string `json:"endpoint" binding:"required"` + Bucket string `json:"bucket" binding:"required"` + Region string `json:"region"` + Prefix string `json:"prefix"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + UseTLS *bool `json:"use_tls"` +} + +func mapBackupTargetRow(row sqlcdb.BackupTarget) BackupTarget { + return BackupTarget{ + ID: row.ID, + Name: row.Name, + Endpoint: row.Endpoint, + Bucket: row.Bucket, + Region: row.Region, + Prefix: row.Prefix, + HasCredentials: row.SecretKey != "", + UseTLS: row.UseTls, + CreatedAt: databaseNullTime(row.CreatedAt), + UpdatedAt: databaseNullTime(row.UpdatedAt), + accessKey: secrets.Decrypt(row.AccessKey), + secretKey: secrets.Decrypt(row.SecretKey), + } +} + +// s3Client builds a client for the target. Endpoint is a bare host[:port] — +// scheme comes from use_tls, matching MinIO/Garage/AWS conventions. +func (t BackupTarget) s3Client() (*minio.Client, error) { + opts := &minio.Options{Secure: t.UseTLS, Region: t.Region} + if t.accessKey != "" || t.secretKey != "" { + opts.Creds = credentials.NewStaticV4(t.accessKey, t.secretKey, "") + } else { + opts.Creds = credentials.NewIAM("") + } + return minio.New(t.Endpoint, opts) +} + +func normalizeBackupTargetRequest(req *backupTargetRequest) error { + req.Name = strings.TrimSpace(req.Name) + req.Endpoint = strings.TrimSpace(req.Endpoint) + req.Endpoint = strings.TrimPrefix(strings.TrimPrefix(req.Endpoint, "https://"), "http://") + req.Endpoint = strings.TrimSuffix(req.Endpoint, "/") + req.Bucket = strings.TrimSpace(req.Bucket) + req.Prefix = strings.Trim(strings.TrimSpace(req.Prefix), "/") + if req.Name == "" { + return errors.New("name is required") + } + if req.Endpoint == "" || req.Bucket == "" { + return errors.New("endpoint and bucket are required") + } + return nil +} + +// probeBackupTarget verifies credentials and bucket access. +func probeBackupTarget(ctx context.Context, t BackupTarget) (time.Duration, error) { + client, err := t.s3Client() + if err != nil { + return 0, err + } + start := time.Now() + exists, err := client.BucketExists(ctx, t.Bucket) + if err != nil { + return 0, err + } + if !exists { + return 0, fmt.Errorf("bucket %q not found or not accessible", t.Bucket) + } + return time.Since(start), nil +} + +func (h *DatabaseHandler) ListBackupTargets(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + rows, err := h.queries.ListBackupTargetsByUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backup targets"}) + return + } + targets := make([]BackupTarget, 0, len(rows)) + for _, row := range rows { + t := mapBackupTargetRow(row) + t.accessKey, t.secretKey = "", "" + targets = append(targets, t) + } + c.JSON(http.StatusOK, gin.H{"backup_targets": targets}) +} + +func (h *DatabaseHandler) CreateBackupTarget(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + var req backupTargetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + if err := normalizeBackupTargetRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + + useTLS := true + if req.UseTLS != nil { + useTLS = *req.UseTLS + } + target := BackupTarget{ + ID: "bt_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12], + Name: req.Name, + Endpoint: req.Endpoint, + Bucket: req.Bucket, + Region: req.Region, + Prefix: req.Prefix, + UseTLS: useTLS, + accessKey: req.AccessKey, + secretKey: req.SecretKey, + } + + probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + if _, err := probeBackupTarget(probeCtx, target); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"}) + return + } + + enc := func(v string) string { + if v == "" { + return "" + } + return secrets.Encrypt(v) + } + now := sql.NullTime{Time: time.Now(), Valid: true} + if err := h.queries.CreateBackupTarget(c.Request.Context(), sqlcdb.CreateBackupTargetParams{ + ID: target.ID, + UserID: userID, + Name: target.Name, + Endpoint: target.Endpoint, + Bucket: target.Bucket, + Region: target.Region, + Prefix: target.Prefix, + AccessKey: enc(req.AccessKey), + SecretKey: enc(req.SecretKey), + UseTls: useTLS, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup target"}) + return + } + LogAudit(userID, "backup_target", target.ID, "create", map[string]interface{}{"name": req.Name, "endpoint": req.Endpoint, "bucket": req.Bucket}) + target.accessKey, target.secretKey = "", "" + target.HasCredentials = req.SecretKey != "" + target.CreatedAt, target.UpdatedAt = now.Time, now.Time + c.JSON(http.StatusCreated, gin.H{"backup_target": target}) +} + +func (h *DatabaseHandler) UpdateBackupTarget(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + targetID := c.Param("id") + row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"}) + return + } + var req backupTargetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + if err := normalizeBackupTargetRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + useTLS := row.UseTls + if req.UseTLS != nil { + useTLS = *req.UseTLS + } + // Empty credentials keep the stored pair. + accessKey := row.AccessKey + secretKey := row.SecretKey + if req.AccessKey != "" || req.SecretKey != "" { + accessKey = secrets.Encrypt(req.AccessKey) + secretKey = secrets.Encrypt(req.SecretKey) + } + + target := BackupTarget{ + Endpoint: req.Endpoint, + Bucket: req.Bucket, + Region: req.Region, + Prefix: req.Prefix, + UseTLS: useTLS, + accessKey: secrets.Decrypt(accessKey), + secretKey: secrets.Decrypt(secretKey), + } + probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + if _, err := probeBackupTarget(probeCtx, target); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"}) + return + } + + if err := h.queries.UpdateBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.UpdateBackupTargetByIDAndUserParams{ + Name: req.Name, + Endpoint: req.Endpoint, + Bucket: req.Bucket, + Region: req.Region, + Prefix: req.Prefix, + AccessKey: accessKey, + SecretKey: secretKey, + UseTls: useTLS, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: targetID, + UserID: userID, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Backup target updated"}) +} + +func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + targetID := c.Param("id") + count, err := h.queries.CountDatabasesUsingBackupTarget(c.Request.Context(), sql.NullString{String: targetID, Valid: true}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check target usage"}) + return + } + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%d database(s) still back up to this target", count), "code": "CONFLICT"}) + return + } + if err := h.queries.DeleteBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.DeleteBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup target"}) + return + } + LogAudit(userID, "backup_target", targetID, "delete", nil) + c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"}) +} + +func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + targetID := c.Param("id") + row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"}) + return + } + target := mapBackupTargetRow(row) + probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + latency, probeErr := probeBackupTarget(probeCtx, target) + if probeErr != nil { + c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()}) +} + +// uploadBackupToTarget copies the archive out of the backup volume and puts +// it in the configured S3 target. Returns the object key, or "" when no +// target is configured. +func (h *DatabaseHandler) uploadBackupToTarget(ctx context.Context, db sqlcdb.DatabaseService, archivePath, backupID string) (string, error) { + if !db.BackupTargetID.Valid || db.BackupTargetID.String == "" { + return "", nil + } + targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String) + if err != nil { + return "", fmt.Errorf("backup target lookup failed: %w", err) + } + target := mapBackupTargetRow(targetRow) + + archive, err := h.readBackupArchive(ctx, archivePath) + if err != nil { + return "", fmt.Errorf("read archive: %w", err) + } + defer func() { + _, _ = io.Copy(io.Discard, archive) + _ = archive.Close() + }() + + client, err := target.s3Client() + if err != nil { + return "", err + } + key := target.Prefix + if key != "" { + key += "/" + } + key += "databases/" + db.ID + "/" + sanitizeBackupArchivePath(archivePath) + _, err = client.PutObject(ctx, target.Bucket, key, archive, -1, minio.PutObjectOptions{ContentType: "application/gzip"}) + if err != nil { + return "", fmt.Errorf("s3 upload: %w", err) + } + _ = h.queries.SetDatabaseBackupRemoteKeyByID(ctx, sqlcdb.SetDatabaseBackupRemoteKeyByIDParams{ + RemoteKey: sql.NullString{String: key, Valid: true}, + ID: backupID, + }) + return key, nil +} + +// readBackupArchive streams an archive out of the shared backup volume via a +// stopped holder container — the volume has no direct host path. +func (h *DatabaseHandler) readBackupArchive(ctx context.Context, archivePath string) (io.ReadCloser, error) { + holderID, err := h.createBackupVolumeHolder(ctx) + if err != nil { + return nil, err + } + reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath)) + if err != nil { + _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) + return nil, err + } + // The holder must outlive the stream — deleting the source container while + // dockerd serves the archive stalls the read without an error. + return &archiveReader{tar: reader, cleanup: func() { + _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) + }}, nil +} + +// downloadBackupToVolume fetches a remote archive back into the backup volume +// so the normal restore path can run unchanged. +func (h *DatabaseHandler) downloadBackupToVolume(ctx context.Context, db sqlcdb.DatabaseService, remoteKey, archivePath string) error { + targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String) + if err != nil { + return fmt.Errorf("backup target lookup failed: %w", err) + } + target := mapBackupTargetRow(targetRow) + client, err := target.s3Client() + if err != nil { + return err + } + obj, err := client.GetObject(ctx, target.Bucket, remoteKey, minio.GetObjectOptions{}) + if err != nil { + return err + } + defer obj.Close() + + archive := sanitizeBackupArchivePath(archivePath) + if archive == "" { + return errors.New("backup path is required") + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + data, err := io.ReadAll(obj) + if err != nil { + return fmt.Errorf("s3 download: %w", err) + } + if err := tw.WriteHeader(&tar.Header{Name: archive, Mode: 0o644, Size: int64(len(data))}); err != nil { + return err + } + if _, err := tw.Write(data); err != nil { + return err + } + if err := tw.Close(); err != nil { + return err + } + + holderID, err := h.createBackupVolumeHolder(ctx) + if err != nil { + return err + } + defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }() + return h.dockerClient.CopyToContainer(ctx, holderID, "/backup", &buf) +} + +// backupArchiveExists reports whether the archive is still in the local +// backup volume — a copy attempt on a holder container is the cheapest check. +func (h *DatabaseHandler) backupArchiveExists(ctx context.Context, archivePath string) (bool, error) { + holderID, err := h.createBackupVolumeHolder(ctx) + if err != nil { + return false, err + } + defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }() + reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath)) + if err != nil { + return false, nil + } + _ = reader.Close() + return true, nil +} + +// createBackupVolumeHolder creates a container mounting the backup volume so +// archives can be copied in and out. It must be started — docker cp/archive +// only resolves volume mounts on containers that have run at least once. +func (h *DatabaseHandler) createBackupVolumeHolder(ctx context.Context) (string, error) { + if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil { + return "", err + } + holderID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ + Image: managedDatabaseBackupImage, + Cmd: []string{"true"}, + RestartPolicy: "no", + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}}, + Labels: map[string]string{"containr.managed": "true", "containr.utility": "backup-io"}, + NetworkMode: "none", + }) + if err != nil { + return "", err + } + if err := h.dockerClient.StartContainer(ctx, holderID); err != nil { + _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) + return "", fmt.Errorf("failed to start backup holder: %w", err) + } + return holderID, nil +} + +// archiveReader unwraps the tar stream CopyFromContainer returns. +type archiveReader struct { + tar io.ReadCloser + tr *tar.Reader + err error + cleanup func() +} + +func (r *archiveReader) Read(p []byte) (int, error) { + if r.err != nil { + return 0, r.err + } + if r.tr == nil { + r.tr = tar.NewReader(r.tar) + // Skip to the first regular file entry. + for { + hdr, err := r.tr.Next() + if err != nil { + r.err = err + return 0, err + } + if hdr.Typeflag == tar.TypeReg { + break + } + } + } + n, err := r.tr.Read(p) + if err != nil { + r.err = err + } + return n, err +} + +func (r *archiveReader) Close() error { + err := r.tar.Close() + if r.cleanup != nil { + r.cleanup() + r.cleanup = nil + } + return err +} diff --git a/app/backend/internal/api/backuptargets_test.go b/app/backend/internal/api/backuptargets_test.go new file mode 100644 index 0000000..4470656 --- /dev/null +++ b/app/backend/internal/api/backuptargets_test.go @@ -0,0 +1,60 @@ +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) + } +} diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index b0ef822..aa533a2 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -54,7 +54,8 @@ type DatabaseService struct { NextBackupAt *time.Time `json:"next_backup_at,omitempty"` Provider string `json:"provider"` // managed | external PublicPort bool `json:"public_port" db:"public_port"` - External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs + BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination + External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` ConnectionURL string `json:"connection_url"` @@ -106,6 +107,7 @@ type DatabaseBackup struct { CreatedAt time.Time `json:"created_at"` Size string `json:"size"` Status string `json:"status"` // completed, failed, in_progress + Remote bool `json:"remote"` // archive also exists on the configured backup target } // DatabaseSettings represents database configuration @@ -131,6 +133,7 @@ type DatabaseUpdateRequest struct { Plan string `json:"plan,omitempty"` BackupSchedule *string `json:"backup_schedule,omitempty"` PublicPort *bool `json:"public_port,omitempty"` + BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears } // DatabaseActionRequest represents a request to perform database actions @@ -417,12 +420,12 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.Plan = strings.TrimSpace(req.Plan) - if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil { + if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } - if req.PublicPort != nil { + if req.PublicPort != nil || req.BackupTargetID != nil { row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, @@ -431,23 +434,47 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"}) return } - if row.Provider == "external" { - c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"}) - return + if req.PublicPort != nil { + if row.Provider == "external" { + c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"}) + return + } + if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{ + PublicPort: *req.PublicPort, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: databaseID, + UserID: userID, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"}) + return + } + // Port bindings are baked into the container — recreate it so the + // new bind scope takes effect. The data volume survives. + if *req.PublicPort != row.PublicPort { + go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type) + } } - if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{ - PublicPort: *req.PublicPort, - UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, - ID: databaseID, - UserID: userID, - }); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"}) - return - } - // Port bindings are baked into the container — recreate it so the - // new bind scope takes effect. The data volume survives. - if *req.PublicPort != row.PublicPort { - go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type) + if req.BackupTargetID != nil { + if row.Provider == "external" { + c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"}) + return + } + targetID := strings.TrimSpace(*req.BackupTargetID) + if targetID != "" { + if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"}) + return + } + } + if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{ + BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: databaseID, + UserID: userID, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"}) + return + } } } @@ -991,6 +1018,7 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string CreatedAt: createdAt, Size: row.Size, Status: row.Status, + Remote: row.RemoteKey.Valid && row.RemoteKey.String != "", }) if lastBackup == nil && row.Status == "completed" { t := createdAt @@ -1144,6 +1172,7 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { NextBackupAt: databaseNullTimePtr(row.NextBackupAt), Provider: row.Provider, PublicPort: row.PublicPort, + BackupTargetID: databaseNullString(row.BackupTargetID), ConnectionURL: databaseNullString(row.ConnectionUrl), CreatedAt: databaseNullTime(row.CreatedAt), UpdatedAt: databaseNullTime(row.UpdatedAt), @@ -1815,10 +1844,17 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) return } + if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil { + if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil { + log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err) + } else if key != "" { + log.Printf("containr: backup %s shipped to %s", backupID, key) + } + } _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) } -func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) { +func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() @@ -1827,6 +1863,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath return } + // Restore the archive from the configured S3 target when the local copy + // is gone (fresh node, pruned volume) — local takes precedence. + if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" { + if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok { + if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ + ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID, + }); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" { + if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil { + log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err) + _ = h.setDatabaseStatus(databaseID, "error") + return + } + } + } + } + _ = h.setDatabaseStatus(databaseID, "building") _ = h.stopManagedDatabase(databaseID) diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index f15ac47..6d5ab80 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -354,6 +354,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // One-off exec console (docker exec, 30s ceiling) authed.POST("/services/:id/exec", handleExecInService) + // S3-compatible backup targets — owner-scoped, keys encrypted at rest. + authed.GET("/backup-targets", databaseHandler.ListBackupTargets) + authed.POST("/backup-targets", databaseHandler.CreateBackupTarget) + authed.PUT("/backup-targets/:id", databaseHandler.UpdateBackupTarget) + authed.DELETE("/backup-targets/:id", databaseHandler.DeleteBackupTarget) + authed.POST("/backup-targets/:id/test", databaseHandler.TestBackupTarget) + // Private-image registries — owner-scoped, credentials encrypted at rest. authed.GET("/registries", handleListRegistries) authed.POST("/registries", handleCreateRegistry) diff --git a/app/backend/internal/cli/commands/backuptargets.go b/app/backend/internal/cli/commands/backuptargets.go new file mode 100644 index 0000000..41344ee --- /dev/null +++ b/app/backend/internal/cli/commands/backuptargets.go @@ -0,0 +1,204 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// BackupTargetsCmd manages S3-compatible backup destinations. +var BackupTargetsCmd = &cobra.Command{ + Use: "backup-targets", + Aliases: []string{"backup-target", "targets"}, + Short: "Manage S3-compatible backup targets", +} + +var btListCmd = &cobra.Command{ + Use: "list", + Short: "List backup targets", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/backup-targets", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "backup_targets") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, t := range items { + creds := "no" + if str(t, "has_credentials") == "true" { + creds = "yes" + } + rows = append(rows, []string{str(t, "id"), str(t, "name"), str(t, "endpoint"), str(t, "bucket"), creds}) + } + printRows(data, []string{"ID", "NAME", "ENDPOINT", "BUCKET", "CREDS"}, rows) + return nil + }, +} + +var btAddCmd = &cobra.Command{ + Use: "add ", + Short: "Register an S3-compatible backup target (probed before storing)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{"name": args[0]} + for _, f := range []string{"endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + key := f + switch f { + case "access-key": + key = "access_key" + case "secret-key": + key = "secret_key" + } + body[key] = v + } + } + if cmd.Flags().Changed("no-tls") { + v, _ := cmd.Flags().GetBool("no-tls") + body["use_tls"] = !v + } + data, err := c.Do("POST", "/backup-targets", body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var btUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a backup target (re-probed; empty keys keep stored credentials)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{} + // endpoint/bucket are required by the API — fall back to stored values. + for _, f := range []string{"name", "endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + key := f + switch f { + case "access-key": + key = "access_key" + case "secret-key": + key = "secret_key" + } + body[key] = v + } + } + if _, hasEndpoint := body["endpoint"]; !hasEndpoint { + data, err := c.Do("GET", "/backup-targets", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "backup_targets") + if err != nil { + return err + } + for _, t := range items { + if str(t, "id") == args[0] { + body["endpoint"] = str(t, "endpoint") + if _, ok := body["bucket"]; !ok { + body["bucket"] = str(t, "bucket") + } + if _, ok := body["name"]; !ok { + body["name"] = str(t, "name") + } + break + } + } + } + if cmd.Flags().Changed("no-tls") { + v, _ := cmd.Flags().GetBool("no-tls") + body["use_tls"] = !v + } + data, err := c.Do("PUT", "/backup-targets/"+args[0], body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var btRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Delete a backup target (fails while databases still point at it)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if !Confirm(fmt.Sprintf("Delete backup target %s?", args[0])) { + return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError} + } + if _, err := c.Do("DELETE", "/backup-targets/"+args[0], nil); err != nil { + return err + } + if JSONMode() { + return PrintJSON(map[string]string{"status": "deleted", "id": args[0]}) + } + fmt.Printf("Deleted backup target %s\n", args[0]) + return nil + }, +} + +var btTestCmd = &cobra.Command{ + Use: "test ", + Short: "Re-probe a backup target's bucket access", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/backup-targets/"+args[0]+"/test", nil) + if err != nil { + return err + } + m, _ := unwrapObject(data) + if str(m, "ok") == "false" { + return &APIError{Message: "probe failed: " + str(m, "error"), ExitCode: ExitError} + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Target ok (%sms)\n", str(m, "latency_ms")) + return nil + }, +} + +func init() { + btAddCmd.Flags().String("endpoint", "", "S3 endpoint host[:port] (e.g. s3.amazonaws.com, garage.local:3900)") + btAddCmd.Flags().String("bucket", "", "bucket name") + btAddCmd.Flags().String("region", "", "region (optional for most S3-compatible stores)") + btAddCmd.Flags().String("prefix", "", "key prefix for archives") + btAddCmd.Flags().String("access-key", "", "access key (stored encrypted)") + btAddCmd.Flags().String("secret-key", "", "secret key (stored encrypted)") + btAddCmd.Flags().Bool("no-tls", false, "use plain HTTP") + btUpdateCmd.Flags().String("name", "", "new name") + btUpdateCmd.Flags().String("endpoint", "", "S3 endpoint host[:port]") + btUpdateCmd.Flags().String("bucket", "", "bucket name") + btUpdateCmd.Flags().String("region", "", "region") + btUpdateCmd.Flags().String("prefix", "", "key prefix") + btUpdateCmd.Flags().String("access-key", "", "access key") + btUpdateCmd.Flags().String("secret-key", "", "secret key") + btUpdateCmd.Flags().Bool("no-tls", false, "use plain HTTP") + BackupTargetsCmd.AddCommand(btListCmd, btAddCmd, btUpdateCmd, btRemoveCmd, btTestCmd) +} diff --git a/app/backend/internal/cli/commands/databases.go b/app/backend/internal/cli/commands/databases.go index c5a0e61..c0bdfd9 100644 --- a/app/backend/internal/cli/commands/databases.go +++ b/app/backend/internal/cli/commands/databases.go @@ -112,8 +112,12 @@ var dbUpdateCmd = &cobra.Command{ v, _ := cmd.Flags().GetString("backup-schedule") body["backup_schedule"] = v } + if cmd.Flags().Changed("backup-target") { + v, _ := cmd.Flags().GetString("backup-target") + body["backup_target_id"] = v + } if len(body) == 0 { - return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, or --backup-schedule", ExitCode: ExitError} + return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, --backup-schedule, or --backup-target", ExitCode: ExitError} } data, err := c.Do("PUT", "/databases/"+args[0], body) if err != nil { @@ -318,6 +322,7 @@ func init() { dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces") dbUpdateCmd.Flags().String("name", "", "rename the database") dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)") + dbUpdateCmd.Flags().String("backup-target", "", "backup target id for offsite archives (empty clears)") for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} { cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse") cmd.Flags().String("host", "", "database host") diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 8faa55a..6f8dbb1 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -75,6 +75,7 @@ func init() { rootCmd.AddCommand(commands.VariablesCmd) rootCmd.AddCommand(commands.VolumesCmd) rootCmd.AddCommand(commands.RegistriesCmd) + rootCmd.AddCommand(commands.BackupTargetsCmd) rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.TemplatesCmd) diff --git a/app/backend/internal/database/sqlcdb/backup_targets.sql.go b/app/backend/internal/database/sqlcdb/backup_targets.sql.go new file mode 100644 index 0000000..ee6dd7c --- /dev/null +++ b/app/backend/internal/database/sqlcdb/backup_targets.sql.go @@ -0,0 +1,253 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: backup_targets.sql + +package sqlcdb + +import ( + "context" + "database/sql" +) + +const countDatabasesUsingBackupTarget = `-- name: CountDatabasesUsingBackupTarget :one +SELECT COUNT(*) +FROM database_services +WHERE backup_target_id = $1 +` + +func (q *Queries) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) { + row := q.db.QueryRowContext(ctx, countDatabasesUsingBackupTarget, targetID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createBackupTarget = `-- name: CreateBackupTarget :exec +INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +` + +type CreateBackupTargetParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Prefix string `json:"prefix"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + UseTls bool `json:"use_tls"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error { + _, err := q.db.ExecContext(ctx, createBackupTarget, + arg.ID, + arg.UserID, + arg.Name, + arg.Endpoint, + arg.Bucket, + arg.Region, + arg.Prefix, + arg.AccessKey, + arg.SecretKey, + arg.UseTls, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteBackupTargetByIDAndUser = `-- name: DeleteBackupTargetByIDAndUser :exec +DELETE FROM backup_targets +WHERE id = $1 AND user_id = $2 +` + +type DeleteBackupTargetByIDAndUserParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error { + _, err := q.db.ExecContext(ctx, deleteBackupTargetByIDAndUser, arg.ID, arg.UserID) + return err +} + +const getBackupTargetByID = `-- name: GetBackupTargetByID :one +SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at +FROM backup_targets +WHERE id = $1 +` + +func (q *Queries) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) { + row := q.db.QueryRowContext(ctx, getBackupTargetByID, id) + var i BackupTarget + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Endpoint, + &i.Bucket, + &i.Region, + &i.Prefix, + &i.AccessKey, + &i.SecretKey, + &i.UseTls, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getBackupTargetByIDAndUser = `-- name: GetBackupTargetByIDAndUser :one +SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at +FROM backup_targets +WHERE id = $1 AND user_id = $2 +` + +type GetBackupTargetByIDAndUserParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) { + row := q.db.QueryRowContext(ctx, getBackupTargetByIDAndUser, arg.ID, arg.UserID) + var i BackupTarget + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Endpoint, + &i.Bucket, + &i.Region, + &i.Prefix, + &i.AccessKey, + &i.SecretKey, + &i.UseTls, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listBackupTargetsByUser = `-- name: ListBackupTargetsByUser :many +SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at +FROM backup_targets +WHERE user_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) { + rows, err := q.db.QueryContext(ctx, listBackupTargetsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BackupTarget{} + for rows.Next() { + var i BackupTarget + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Endpoint, + &i.Bucket, + &i.Region, + &i.Prefix, + &i.AccessKey, + &i.SecretKey, + &i.UseTls, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDatabaseBackupRemoteKeyByID = `-- name: SetDatabaseBackupRemoteKeyByID :exec +UPDATE database_backups +SET remote_key = $1 +WHERE id = $2 +` + +type SetDatabaseBackupRemoteKeyByIDParams struct { + RemoteKey sql.NullString `json:"remote_key"` + ID string `json:"id"` +} + +func (q *Queries) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error { + _, err := q.db.ExecContext(ctx, setDatabaseBackupRemoteKeyByID, arg.RemoteKey, arg.ID) + return err +} + +const setDatabaseBackupTargetByIDAndUser = `-- name: SetDatabaseBackupTargetByIDAndUser :exec +UPDATE database_services +SET backup_target_id = $1, updated_at = $2 +WHERE id = $3 AND user_id = $4 +` + +type SetDatabaseBackupTargetByIDAndUserParams struct { + BackupTargetID sql.NullString `json:"backup_target_id"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error { + _, err := q.db.ExecContext(ctx, setDatabaseBackupTargetByIDAndUser, + arg.BackupTargetID, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + return err +} + +const updateBackupTargetByIDAndUser = `-- name: UpdateBackupTargetByIDAndUser :exec +UPDATE backup_targets +SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9 +WHERE id = $10 AND user_id = $11 +` + +type UpdateBackupTargetByIDAndUserParams struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Prefix string `json:"prefix"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + UseTls bool `json:"use_tls"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error { + _, err := q.db.ExecContext(ctx, updateBackupTargetByIDAndUser, + arg.Name, + arg.Endpoint, + arg.Bucket, + arg.Region, + arg.Prefix, + arg.AccessKey, + arg.SecretKey, + arg.UseTls, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + return err +} diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index b7dba52..c715fa3 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -170,7 +170,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele } const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one -SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at +SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at FROM database_backups b JOIN database_services s ON s.id = b.database_id WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3 @@ -191,6 +191,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a &i.Size, &i.Status, &i.BackupPath, + &i.RemoteKey, &i.CreatedAt, &i.CompletedAt, ) @@ -198,7 +199,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a } const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 ` @@ -226,6 +227,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa &i.ExternalPassword, &i.ExternalSsl, &i.PublicPort, + &i.BackupTargetID, &i.CreatedAt, &i.UpdatedAt, ) @@ -233,7 +235,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa } const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 AND user_id = $2 ` @@ -266,6 +268,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData &i.ExternalPassword, &i.ExternalSsl, &i.PublicPort, + &i.BackupTargetID, &i.CreatedAt, &i.UpdatedAt, ) @@ -286,7 +289,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str } const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at FROM database_services ORDER BY created_at DESC ` @@ -320,6 +323,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic &i.ExternalPassword, &i.ExternalSsl, &i.PublicPort, + &i.BackupTargetID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -337,7 +341,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic } const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many -SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at +SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at FROM database_backups b JOIN database_services s ON s.id = b.database_id WHERE b.database_id = $1 AND s.user_id = $2 @@ -366,6 +370,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg &i.Size, &i.Status, &i.BackupPath, + &i.RemoteKey, &i.CreatedAt, &i.CompletedAt, ); err != nil { @@ -383,7 +388,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg } const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC @@ -418,6 +423,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) &i.ExternalPassword, &i.ExternalSsl, &i.PublicPort, + &i.BackupTargetID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index b72de53..44a3498 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -49,6 +49,21 @@ type AgentHeartbeat struct { CreatedAt time.Time `json:"created_at"` } +type BackupTarget struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Prefix string `json:"prefix"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + UseTls bool `json:"use_tls"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + type ContainerInstance struct { ID string `json:"id"` Name string `json:"name"` @@ -75,6 +90,7 @@ type DatabaseBackup struct { Size string `json:"size"` Status string `json:"status"` BackupPath sql.NullString `json:"backup_path"` + RemoteKey sql.NullString `json:"remote_key"` CreatedAt sql.NullTime `json:"created_at"` CompletedAt sql.NullTime `json:"completed_at"` } @@ -99,6 +115,7 @@ type DatabaseService struct { ExternalPassword string `json:"external_password"` ExternalSsl bool `json:"external_ssl"` PublicPort bool `json:"public_port"` + BackupTargetID sql.NullString `json:"backup_target_id"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 9ac43e4..e3a7ad8 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -6,6 +6,7 @@ package sqlcdb import ( "context" + "database/sql" "github.com/google/uuid" ) @@ -13,11 +14,13 @@ import ( type Querier interface { CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) + CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) + CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error @@ -30,12 +33,15 @@ type Querier interface { CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error + DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) + GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) + GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) @@ -55,6 +61,7 @@ type Querier interface { ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgents(ctx context.Context) ([]NodeAgent, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) + ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) @@ -74,8 +81,10 @@ type Querier interface { MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) + SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error + SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error @@ -86,6 +95,7 @@ type Querier interface { TouchUserToken(ctx context.Context, id uuid.UUID) error UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error + UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index c076a4c..64359ca 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -238,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str return reader, err } +// CopyToContainer copies a tar archive stream into a container path. +func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error { + return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{}) +} + // GetContainerStats returns real-time resource usage statistics for a container func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) { resp, err := c.cli.ContainerStats(ctx, containerID, stream) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 7f10cc7..2dabe45 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -99,7 +99,7 @@ func init() { register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false}) register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false}) register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false}) - register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false}) + register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), backup_target_id (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false}) register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false}) register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false}) register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false}) @@ -108,6 +108,13 @@ func init() { register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true}) register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true}) + // S3-compatible backup targets + register(spec{"containr_backup_targets_list", "List S3-compatible backup targets (credentials never returned)", "GET", "/backup-targets", nil, nil, false}) + register(spec{"containr_backup_targets_create", "Register a backup target — probed first, keys stored encrypted. Args: name, endpoint (required), bucket (required), region, prefix, access_key, secret_key, use_tls.", "POST", "/backup-targets", nil, []string{"endpoint", "bucket"}, false}) + register(spec{"containr_backup_targets_update", "Update a backup target; empty credentials keep stored keys. Args: id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls.", "PUT", "/backup-targets/{id}", []string{"id"}, nil, false}) + register(spec{"containr_backup_targets_delete", "Delete a backup target — fails while databases still point at it. Requires confirm:true.", "DELETE", "/backup-targets/{id}", []string{"id"}, nil, true}) + register(spec{"containr_backup_targets_test", "Re-probe a backup target's bucket access. Args: id.", "POST", "/backup-targets/{id}/test", []string{"id"}, nil, false}) + // Cron register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false}) register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false}) diff --git a/app/backend/migrations_goose/20261015000000_backup_targets.sql b/app/backend/migrations_goose/20261015000000_backup_targets.sql new file mode 100644 index 0000000..a35e73a --- /dev/null +++ b/app/backend/migrations_goose/20261015000000_backup_targets.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS backup_targets ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + endpoint VARCHAR(512) NOT NULL, + bucket VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL DEFAULT '', + prefix VARCHAR(255) NOT NULL DEFAULT '', + access_key TEXT NOT NULL DEFAULT '', + secret_key TEXT NOT NULL DEFAULT '', + use_tls BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +ALTER TABLE database_services + ADD COLUMN IF NOT EXISTS backup_target_id VARCHAR(255); + +ALTER TABLE database_backups + ADD COLUMN IF NOT EXISTS remote_key VARCHAR(512); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE database_backups + DROP COLUMN IF EXISTS remote_key; + +ALTER TABLE database_services + DROP COLUMN IF EXISTS backup_target_id; + +DROP TABLE IF EXISTS backup_targets; +-- +goose StatementEnd diff --git a/app/backend/sqlc/queries/backup_targets.sql b/app/backend/sqlc/queries/backup_targets.sql new file mode 100644 index 0000000..9fdc3d9 --- /dev/null +++ b/app/backend/sqlc/queries/backup_targets.sql @@ -0,0 +1,43 @@ +-- name: ListBackupTargetsByUser :many +SELECT backup_targets.* +FROM backup_targets +WHERE user_id = sqlc.arg(user_id) +ORDER BY created_at DESC; + +-- name: GetBackupTargetByIDAndUser :one +SELECT backup_targets.* +FROM backup_targets +WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id); + +-- name: GetBackupTargetByID :one +SELECT backup_targets.* +FROM backup_targets +WHERE id = sqlc.arg(id); + +-- name: CreateBackupTarget :exec +INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); + +-- name: UpdateBackupTargetByIDAndUser :exec +UPDATE backup_targets +SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9 +WHERE id = $10 AND user_id = $11; + +-- name: DeleteBackupTargetByIDAndUser :exec +DELETE FROM backup_targets +WHERE id = $1 AND user_id = $2; + +-- name: SetDatabaseBackupTargetByIDAndUser :exec +UPDATE database_services +SET backup_target_id = $1, updated_at = $2 +WHERE id = $3 AND user_id = $4; + +-- name: SetDatabaseBackupRemoteKeyByID :exec +UPDATE database_backups +SET remote_key = $1 +WHERE id = $2; + +-- name: CountDatabasesUsingBackupTarget :one +SELECT COUNT(*) +FROM database_services +WHERE backup_target_id = sqlc.arg(target_id); diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index 9aef17d..5a1eff9 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -78,7 +78,7 @@ SET status = $1, connection_url = $2, updated_at = $3 WHERE id = $4; -- name: ListDatabaseBackupsByDatabaseAndUser :many -SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at +SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at FROM database_backups b JOIN database_services s ON s.id = b.database_id WHERE b.database_id = $1 AND s.user_id = $2 @@ -86,7 +86,7 @@ ORDER BY b.created_at DESC LIMIT $3; -- name: GetDatabaseBackupByIDAndDatabaseAndUser :one -SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at +SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at FROM database_backups b JOIN database_services s ON s.id = b.database_id WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 0d22ee9..ec59f4d 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -147,6 +147,7 @@ CREATE TABLE database_services ( external_password TEXT NOT NULL DEFAULT '', external_ssl BOOLEAN NOT NULL DEFAULT false, public_port BOOLEAN NOT NULL DEFAULT false, + backup_target_id VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -157,10 +158,26 @@ CREATE TABLE database_backups ( size VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'in_progress', backup_path TEXT, + remote_key VARCHAR(512), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), completed_at TIMESTAMP WITH TIME ZONE ); +CREATE TABLE backup_targets ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + endpoint VARCHAR(512) NOT NULL, + bucket VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL DEFAULT '', + prefix VARCHAR(255) NOT NULL DEFAULT '', + access_key TEXT NOT NULL DEFAULT '', + secret_key TEXT NOT NULL DEFAULT '', + use_tls BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + CREATE TABLE node_agents ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index f75e92a..59078dd 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -32,6 +32,10 @@ import { createManagedDatabase, registerExternalDatabase, updateDatabase, + listBackupTargets, + createBackupTarget, + deleteBackupTarget, + testBackupTarget, testDatabaseConnection, testDatabaseConnectionByID, type ExternalDatabaseInput, @@ -1568,6 +1572,139 @@ function RegistriesSection() { ); } +function BackupTargetsSection() { + const queryClient = useQueryClient(); + const targetsQuery = useQuery({ + queryKey: ['backup-targets'], + queryFn: listBackupTargets, + }); + const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false }); + const [testResult, setTestResult] = useState>({}); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] }); + const createMutation = useMutation({ + mutationFn: () => + createBackupTarget({ + name: form.name.trim(), + endpoint: form.endpoint.trim(), + bucket: form.bucket.trim(), + region: form.region.trim() || undefined, + prefix: form.prefix.trim() || undefined, + access_key: form.accessKey || undefined, + secret_key: form.secretKey || undefined, + use_tls: !form.noTls, + }), + onSuccess: () => { + setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false }); + invalidate(); + }, + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteBackupTarget(id), + onSuccess: invalidate, + }); + const testMutation = useMutation({ + mutationFn: (id: string) => testBackupTarget(id), + onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })), + onError: (error, id) => + setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })), + }); + + const targets = targetsQuery.data ?? []; + const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all'; + + return ( +
+
+
+ +
+
+

Backup targets

+

S3-compatible destinations for database archives — keys stored encrypted

+
+
+ +
+
+ setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} /> + setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} /> + setForm((f) => ({ ...f, bucket: e.target.value }))} placeholder="Bucket" className="h-9 w-36 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" /> + setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} /> + setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} /> + + +
+ + {createMutation.isError ? ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'} +

+ ) : null} + + {targetsQuery.isLoading ? ( +
+ +
+ ) : targets.length === 0 ? ( +

+ No backup targets. Database archives stay in the local backups volume until a target is configured. +

+ ) : ( +
+ {targets.map((t) => ( +
+
+ {t.name} + {t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''} + + {t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'} + + {testResult[t.id ?? ''] && ( + + {testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`} + + )} +
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1742,6 +1879,7 @@ export function SettingsPage() { {/* Personal Access Tokens */} + {/* Platform Settings - owner only */} {profileQuery.data?.isAdmin ? : null} @@ -1918,6 +2056,11 @@ export function DatabasesPage() { refetchInterval: 15_000, enabled: !isDemoMode, }); + const backupTargetsQuery = useQuery({ + queryKey: ['backup-targets'], + queryFn: listBackupTargets, + enabled: !isDemoMode, + }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] }); const createMutation = useMutation({ @@ -1972,6 +2115,11 @@ export function DatabasesPage() { updateDatabase(id, { public_port: publicPort }), onSuccess: () => window.setTimeout(invalidate, 1500), }); + const backupTargetMutation = useMutation({ + mutationFn: ({ id, targetId }: { id: string; targetId: string }) => + updateDatabase(id, { backup_target_id: targetId }), + onSuccess: invalidate, + }); const testByIdMutation = useMutation({ mutationFn: (id: string) => testDatabaseConnectionByID(id), onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })), @@ -2196,6 +2344,20 @@ export function DatabasesPage() {

Backups

{signedIn ? ( +
+ {(backupTargetsQuery.data?.length ?? 0) > 0 && ( + + )} +
) : null}
{formatRelative(b.created_at)} {b.size} {b.status} + {b.remote && offsite} {b.status === 'completed' && ( { + const payload = await requestJson<{ backup_targets?: BackupTarget[] }>('/backup-targets'); + return payload.backup_targets ?? []; +} + +export async function createBackupTarget(input: BackupTargetInput): Promise { + const payload = await requestJson<{ backup_target?: BackupTarget }>('/backup-targets', { + method: 'POST', + body: JSON.stringify(input), + }); + if (!payload.backup_target) { + throw new ApiError('Backup target response is invalid', 500); + } + return payload.backup_target; +} + +export async function updateBackupTarget(id: string, input: BackupTargetInput): Promise { + await requestJson(`/backup-targets/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(input), + }); +} + +export async function deleteBackupTarget(id: string): Promise { + await requestJson(`/backup-targets/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function testBackupTarget(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> { + return requestJson(`/backup-targets/${encodeURIComponent(id)}/test`, { method: 'POST' }); +} + export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest']; export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest']; export type GitWebhookEntity = components['schemas']['GitWebhook']; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7fc495b..2e6cf44 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 970d378..6e98433 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -52,6 +52,10 @@ databases list|get|create|update|delete|action|backup|restore|download-backup update --public/--public=false toggles it (container recreates, data persists) databases register register an external database (--type --host --port --database --username --password --ssl; probed before storing) databases test-connection probe a connection — by id, or ad-hoc flags +backup-targets list|add|update|remove|test + S3-compatible archive destinations; add/update probe the bucket + first, empty --access-key/--secret-key on update keep stored keys; + update --backup-target assigns a target ("" clears it) cron list|get|create|delete|trigger|executions templates list|get|create|delete|deploy nodes list|get|delete|tokens node agents + onboarding tokens (admin) diff --git a/llms.txt b/llms.txt index 9bfe2d5..bf7ac46 100644 --- a/llms.txt +++ b/llms.txt @@ -23,6 +23,7 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`. - **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential. - **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists. - **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists. +- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases//` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. ## Core objects