mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #26 from Dvorinka/feat/backup-targets
feat(databases): S3-compatible backup targets — offsite archives + remote restore
This commit is contained in:
25 files changed
+1901
-32
No files matched your search
+11
-2
@@ -37,23 +37,29 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.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/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // 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/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // 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/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // 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/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // 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/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.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 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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/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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
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=
|
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
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/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 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,8 @@ type DatabaseService struct {
|
|||||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||||
Provider string `json:"provider"` // managed | external
|
Provider string `json:"provider"` // managed | external
|
||||||
PublicPort bool `json:"public_port" db:"public_port"`
|
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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
ConnectionURL string `json:"connection_url"`
|
ConnectionURL string `json:"connection_url"`
|
||||||
@@ -106,6 +107,7 @@ type DatabaseBackup struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Status string `json:"status"` // completed, failed, in_progress
|
Status string `json:"status"` // completed, failed, in_progress
|
||||||
|
Remote bool `json:"remote"` // archive also exists on the configured backup target
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseSettings represents database configuration
|
// DatabaseSettings represents database configuration
|
||||||
@@ -131,6 +133,7 @@ type DatabaseUpdateRequest struct {
|
|||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||||
PublicPort *bool `json:"public_port,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
|
// 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.Name = strings.TrimSpace(req.Name)
|
||||||
req.Plan = strings.TrimSpace(req.Plan)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.PublicPort != nil {
|
if req.PublicPort != nil || req.BackupTargetID != nil {
|
||||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row.Provider == "external" {
|
if req.PublicPort != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
|
if row.Provider == "external" {
|
||||||
return
|
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{
|
if req.BackupTargetID != nil {
|
||||||
PublicPort: *req.PublicPort,
|
if row.Provider == "external" {
|
||||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"})
|
||||||
ID: databaseID,
|
return
|
||||||
UserID: userID,
|
}
|
||||||
}); err != nil {
|
targetID := strings.TrimSpace(*req.BackupTargetID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
|
if targetID != "" {
|
||||||
return
|
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"})
|
||||||
// Port bindings are baked into the container — recreate it so the
|
return
|
||||||
// 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.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,
|
CreatedAt: createdAt,
|
||||||
Size: row.Size,
|
Size: row.Size,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
|
Remote: row.RemoteKey.Valid && row.RemoteKey.String != "",
|
||||||
})
|
})
|
||||||
if lastBackup == nil && row.Status == "completed" {
|
if lastBackup == nil && row.Status == "completed" {
|
||||||
t := createdAt
|
t := createdAt
|
||||||
@@ -1144,6 +1172,7 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
|||||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
Provider: row.Provider,
|
Provider: row.Provider,
|
||||||
PublicPort: row.PublicPort,
|
PublicPort: row.PublicPort,
|
||||||
|
BackupTargetID: databaseNullString(row.BackupTargetID),
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
@@ -1815,10 +1844,17 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
|
|||||||
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
||||||
return
|
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)
|
_ = 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -1827,6 +1863,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath
|
|||||||
return
|
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.setDatabaseStatus(databaseID, "building")
|
||||||
_ = h.stopManagedDatabase(databaseID)
|
_ = h.stopManagedDatabase(databaseID)
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// One-off exec console (docker exec, 30s ceiling)
|
// One-off exec console (docker exec, 30s ceiling)
|
||||||
authed.POST("/services/:id/exec", handleExecInService)
|
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.
|
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
||||||
authed.GET("/registries", handleListRegistries)
|
authed.GET("/registries", handleListRegistries)
|
||||||
authed.POST("/registries", handleCreateRegistry)
|
authed.POST("/registries", handleCreateRegistry)
|
||||||
|
|||||||
@@ -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 <name>",
|
||||||
|
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 <id>",
|
||||||
|
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 <id>",
|
||||||
|
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 <id>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -112,8 +112,12 @@ var dbUpdateCmd = &cobra.Command{
|
|||||||
v, _ := cmd.Flags().GetString("backup-schedule")
|
v, _ := cmd.Flags().GetString("backup-schedule")
|
||||||
body["backup_schedule"] = v
|
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 {
|
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)
|
data, err := c.Do("PUT", "/databases/"+args[0], body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -318,6 +322,7 @@ func init() {
|
|||||||
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
|
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
|
||||||
dbUpdateCmd.Flags().String("name", "", "rename the database")
|
dbUpdateCmd.Flags().String("name", "", "rename the database")
|
||||||
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
|
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} {
|
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
|
||||||
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
cmd.Flags().String("host", "", "database host")
|
cmd.Flags().String("host", "", "database host")
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.VariablesCmd)
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
rootCmd.AddCommand(commands.VolumesCmd)
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
rootCmd.AddCommand(commands.RegistriesCmd)
|
rootCmd.AddCommand(commands.RegistriesCmd)
|
||||||
|
rootCmd.AddCommand(commands.BackupTargetsCmd)
|
||||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -170,7 +170,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
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
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
|
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.Size,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.BackupPath,
|
&i.BackupPath,
|
||||||
|
&i.RemoteKey,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.CompletedAt,
|
&i.CompletedAt,
|
||||||
)
|
)
|
||||||
@@ -198,7 +199,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
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
|
FROM database_services
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -226,6 +227,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
&i.ExternalPassword,
|
&i.ExternalPassword,
|
||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -233,7 +235,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
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
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`
|
`
|
||||||
@@ -266,6 +268,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
&i.ExternalPassword,
|
&i.ExternalPassword,
|
||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -286,7 +289,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
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
|
FROM database_services
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -320,6 +323,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
&i.ExternalPassword,
|
&i.ExternalPassword,
|
||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -337,7 +341,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
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
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.database_id = $1 AND s.user_id = $2
|
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.Size,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.BackupPath,
|
&i.BackupPath,
|
||||||
|
&i.RemoteKey,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.CompletedAt,
|
&i.CompletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -383,7 +388,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
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
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -418,6 +423,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
&i.ExternalPassword,
|
&i.ExternalPassword,
|
||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ type AgentHeartbeat struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
type ContainerInstance struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -75,6 +90,7 @@ type DatabaseBackup struct {
|
|||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
BackupPath sql.NullString `json:"backup_path"`
|
BackupPath sql.NullString `json:"backup_path"`
|
||||||
|
RemoteKey sql.NullString `json:"remote_key"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
CompletedAt sql.NullTime `json:"completed_at"`
|
CompletedAt sql.NullTime `json:"completed_at"`
|
||||||
}
|
}
|
||||||
@@ -99,6 +115,7 @@ type DatabaseService struct {
|
|||||||
ExternalPassword string `json:"external_password"`
|
ExternalPassword string `json:"external_password"`
|
||||||
ExternalSsl bool `json:"external_ssl"`
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
PublicPort bool `json:"public_port"`
|
PublicPort bool `json:"public_port"`
|
||||||
|
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package sqlcdb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -13,11 +14,13 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, 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)
|
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||||
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||||
|
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
|
||||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||||
@@ -30,12 +33,15 @@ type Querier interface {
|
|||||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
|
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (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)
|
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
|
||||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (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)
|
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||||
|
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||||
@@ -74,8 +81,10 @@ type Querier interface {
|
|||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
|
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
|
SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
|
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
|
||||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||||
@@ -86,6 +95,7 @@ type Querier interface {
|
|||||||
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
|
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str
|
|||||||
return reader, err
|
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
|
// GetContainerStats returns real-time resource usage statistics for a container
|
||||||
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
||||||
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func init() {
|
|||||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
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_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_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_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", "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})
|
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})
|
||||||
@@ -110,6 +110,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_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})
|
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
|
// Cron
|
||||||
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
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})
|
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -78,7 +78,7 @@ SET status = $1, connection_url = $2, updated_at = $3
|
|||||||
WHERE id = $4;
|
WHERE id = $4;
|
||||||
|
|
||||||
-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
-- 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
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.database_id = $1 AND s.user_id = $2
|
WHERE b.database_id = $1 AND s.user_id = $2
|
||||||
@@ -86,7 +86,7 @@ ORDER BY b.created_at DESC
|
|||||||
LIMIT $3;
|
LIMIT $3;
|
||||||
|
|
||||||
-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
-- 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
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3;
|
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3;
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ CREATE TABLE database_services (
|
|||||||
external_password TEXT NOT NULL DEFAULT '',
|
external_password TEXT NOT NULL DEFAULT '',
|
||||||
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||||
public_port 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(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_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,
|
size VARCHAR(50) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
|
status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
|
||||||
backup_path TEXT,
|
backup_path TEXT,
|
||||||
|
remote_key VARCHAR(512),
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
completed_at TIMESTAMP WITH TIME ZONE
|
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 (
|
CREATE TABLE node_agents (
|
||||||
id VARCHAR(255) PRIMARY KEY,
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import {
|
|||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
registerExternalDatabase,
|
registerExternalDatabase,
|
||||||
updateDatabase,
|
updateDatabase,
|
||||||
|
listBackupTargets,
|
||||||
|
createBackupTarget,
|
||||||
|
deleteBackupTarget,
|
||||||
|
testBackupTarget,
|
||||||
testDatabaseConnection,
|
testDatabaseConnection,
|
||||||
testDatabaseConnectionByID,
|
testDatabaseConnectionByID,
|
||||||
type ExternalDatabaseInput,
|
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<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<HardDrive size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} />
|
||||||
|
<input value={form.endpoint} onChange={(e) => setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} />
|
||||||
|
<input value={form.bucket} onChange={(e) => 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" />
|
||||||
|
<input value={form.accessKey} onChange={(e) => setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} />
|
||||||
|
<input type="password" value={form.secretKey} onChange={(e) => setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} />
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input type="checkbox" checked={form.noTls} onChange={(e) => setForm((f) => ({ ...f, noTls: e.target.checked }))} className="accent-[var(--accent-primary)]" />
|
||||||
|
No TLS
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{targetsQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : targets.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
No backup targets. Database archives stay in the local backups volume until a target is configured.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{t.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||||
|
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
|
||||||
|
</span>
|
||||||
|
{testResult[t.id ?? ''] && (
|
||||||
|
<span className={`ml-2 ${testResult[t.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate(t.id ?? '')}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="text-[var(--accent-primary)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete backup target ${t.name}?`)) {
|
||||||
|
deleteMutation.mutate(t.id ?? '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1742,6 +1879,7 @@ export function SettingsPage() {
|
|||||||
{/* Personal Access Tokens */}
|
{/* Personal Access Tokens */}
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
<RegistriesSection />
|
<RegistriesSection />
|
||||||
|
<BackupTargetsSection />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
@@ -1918,6 +2056,11 @@ export function DatabasesPage() {
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
|
const backupTargetsQuery = useQuery({
|
||||||
|
queryKey: ['backup-targets'],
|
||||||
|
queryFn: listBackupTargets,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
@@ -1972,6 +2115,11 @@ export function DatabasesPage() {
|
|||||||
updateDatabase(id, { public_port: publicPort }),
|
updateDatabase(id, { public_port: publicPort }),
|
||||||
onSuccess: () => window.setTimeout(invalidate, 1500),
|
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({
|
const testByIdMutation = useMutation({
|
||||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||||
@@ -2196,6 +2344,20 @@ export function DatabasesPage() {
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(backupTargetsQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<select
|
||||||
|
value={db.backup_target_id ?? ''}
|
||||||
|
onChange={(e) => backupTargetMutation.mutate({ id: db.id ?? '', targetId: e.target.value })}
|
||||||
|
title="Offsite archive destination"
|
||||||
|
className="h-7 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-xs text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
<option value="">local only</option>
|
||||||
|
{(backupTargetsQuery.data ?? []).map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name} ({t.bucket})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||||
disabled={!running || backupMutation.isPending}
|
disabled={!running || backupMutation.isPending}
|
||||||
@@ -2205,6 +2367,7 @@ export function DatabasesPage() {
|
|||||||
<Archive size={11} />
|
<Archive size={11} />
|
||||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<BackupScheduleRow
|
<BackupScheduleRow
|
||||||
@@ -2221,6 +2384,7 @@ export function DatabasesPage() {
|
|||||||
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
||||||
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
||||||
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||||
|
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
|
||||||
{b.status === 'completed' && (
|
{b.status === 'completed' && (
|
||||||
<span className="ml-auto flex items-center gap-3">
|
<span className="ml-auto flex items-center gap-3">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1241,6 +1241,204 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/backup-targets": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List backup targets
|
||||||
|
* @description S3-compatible archive destinations for the caller. Secret keys are never returned — `has_credentials` signals presence.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Backup target list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
backup_targets?: components["schemas"]["BackupTarget"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Register a backup target
|
||||||
|
* @description The bucket is probed (BucketExists) before storing — typos and bad credentials fail at registration. Keys are stored encrypted at rest.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BackupTargetRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Backup target created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation error or connection check failed */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/backup-targets/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Update a backup target
|
||||||
|
* @description Empty access_key/secret_key keep the stored pair — delete and recreate to remove credentials. Re-probed before saving.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BackupTargetRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Backup target updated */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Backup target not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete a backup target
|
||||||
|
* @description Fails with 409 while databases still point at the target.
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Backup target deleted */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Databases still reference this target */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/backup-targets/{id}/test": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Re-probe a backup target
|
||||||
|
* @description Checks bucket access with the stored credentials. Always returns a probe result — `ok` false means unreachable or unauthorized.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Probe result */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
ok?: boolean;
|
||||||
|
latency_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/services/{id}/domains": {
|
"/services/{id}/domains": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -9187,6 +9385,8 @@ export interface components {
|
|||||||
provider?: "managed" | "external";
|
provider?: "managed" | "external";
|
||||||
/** @description managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container */
|
/** @description managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container */
|
||||||
public_port?: boolean;
|
public_port?: boolean;
|
||||||
|
/** @description S3-compatible backup target receiving this database's archives, empty when local-only */
|
||||||
|
backup_target_id?: string;
|
||||||
external?: components["schemas"]["DatabaseExternal"];
|
external?: components["schemas"]["DatabaseExternal"];
|
||||||
/**
|
/**
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
@@ -9259,6 +9459,8 @@ export interface components {
|
|||||||
backup_schedule?: string | null;
|
backup_schedule?: string | null;
|
||||||
/** @description Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container */
|
/** @description Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container */
|
||||||
public_port?: boolean;
|
public_port?: boolean;
|
||||||
|
/** @description Assign a backup target for offsite archives; empty string clears */
|
||||||
|
backup_target_id?: string | null;
|
||||||
};
|
};
|
||||||
DatabaseActionRequest: {
|
DatabaseActionRequest: {
|
||||||
/**
|
/**
|
||||||
@@ -9309,6 +9511,36 @@ export interface components {
|
|||||||
size?: string;
|
size?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status?: "completed" | "failed" | "in_progress";
|
status?: "completed" | "failed" | "in_progress";
|
||||||
|
/** @description Archive also shipped to the database's configured backup target */
|
||||||
|
remote?: boolean;
|
||||||
|
};
|
||||||
|
BackupTarget: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
/** @description Bare host[:port] — scheme comes from use_tls */
|
||||||
|
endpoint?: string;
|
||||||
|
bucket?: string;
|
||||||
|
region?: string;
|
||||||
|
prefix?: string;
|
||||||
|
has_credentials?: boolean;
|
||||||
|
use_tls?: boolean;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
BackupTargetRequest: {
|
||||||
|
name?: string;
|
||||||
|
endpoint: string;
|
||||||
|
bucket: string;
|
||||||
|
region?: string;
|
||||||
|
prefix?: string;
|
||||||
|
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
|
||||||
|
access_key?: string;
|
||||||
|
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
|
||||||
|
secret_key?: string;
|
||||||
|
/** @default true */
|
||||||
|
use_tls: boolean;
|
||||||
};
|
};
|
||||||
DatabaseBackupConfig: {
|
DatabaseBackupConfig: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -1789,6 +1789,40 @@ export async function testDatabaseConnectionByID(id: string): Promise<{ ok: bool
|
|||||||
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
|
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BackupTarget = components['schemas']['BackupTarget'];
|
||||||
|
export type BackupTargetInput = components['schemas']['BackupTargetRequest'];
|
||||||
|
|
||||||
|
export async function listBackupTargets(): Promise<BackupTarget[]> {
|
||||||
|
const payload = await requestJson<{ backup_targets?: BackupTarget[] }>('/backup-targets');
|
||||||
|
return payload.backup_targets ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBackupTarget(input: BackupTargetInput): Promise<BackupTarget> {
|
||||||
|
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<void> {
|
||||||
|
await requestJson(`/backup-targets/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupTarget(id: string): Promise<void> {
|
||||||
|
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 ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||||
|
|||||||
@@ -828,6 +828,118 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
description: Registry not found
|
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:
|
/services/{id}/domains:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -6394,6 +6506,9 @@ components:
|
|||||||
public_port:
|
public_port:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container
|
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:
|
external:
|
||||||
$ref: '#/components/schemas/DatabaseExternal'
|
$ref: '#/components/schemas/DatabaseExternal'
|
||||||
next_backup_at:
|
next_backup_at:
|
||||||
@@ -6501,6 +6616,10 @@ components:
|
|||||||
public_port:
|
public_port:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container
|
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:
|
DatabaseActionRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -6594,6 +6713,62 @@ components:
|
|||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [completed, failed, in_progress]
|
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:
|
DatabaseBackupConfig:
|
||||||
type: object
|
type: object
|
||||||
@@ -8137,5 +8312,7 @@ tags:
|
|||||||
description: Scheduled jobs
|
description: Scheduled jobs
|
||||||
- name: Registries
|
- name: Registries
|
||||||
description: Private-image pull credentials
|
description: Private-image pull credentials
|
||||||
|
- name: BackupTargets
|
||||||
|
description: S3-compatible backup destinations for managed databases
|
||||||
- name: Gateway
|
- name: Gateway
|
||||||
description: API gateway — upstream services, keys, analytics
|
description: API gateway — upstream services, keys, analytics
|
||||||
@@ -53,6 +53,10 @@ databases list|get|create|update|delete|action|backup|restore|download-backup
|
|||||||
update <id> --public/--public=false toggles it (container recreates, data persists)
|
update <id> --public/--public=false toggles it (container recreates, data persists)
|
||||||
databases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)
|
databases register <name> 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
|
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 <db-id> --backup-target assigns a target ("" clears it)
|
||||||
cron list|get|create|delete|trigger|executions
|
cron list|get|create|delete|trigger|executions
|
||||||
templates list|get|create|delete|deploy
|
templates list|get|create|delete|deploy
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Base URL: `https://<host>/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.
|
- **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.
|
- **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.
|
- **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/<db-id>/<archive>` 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.
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
## Core objects
|
## Core objects
|
||||||
|
|||||||
Reference in new issue
Block a user