mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
FEATURE (healthcheck): Add databases healthcheck
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.svg" alt="Postgresus Logo" width="250"/>
|
||||
|
||||
<h3>PostgreSQL backup and restore tool</h3>
|
||||
<p>Free, open source and self-hosted solution for automated PostgreSQL backups with multiple storage options and notifications</p>
|
||||
<h3>PostgreSQL monitoring and backup</h3>
|
||||
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications</p>
|
||||
|
||||
<p>
|
||||
<a href="#-features">Features</a> •
|
||||
|
||||
@@ -10,4 +10,18 @@ DATABASE_URL=postgres://postgres:Q1234567@dev-db:5437/postgresus?sslmode=disable
|
||||
# migrations
|
||||
GOOSE_DRIVER=postgres
|
||||
GOOSE_DBSTRING=postgres://postgres:Q1234567@dev-db:5437/postgresus?sslmode=disable
|
||||
GOOSE_MIGRATION_DIR=./migrations
|
||||
GOOSE_MIGRATION_DIR=./migrations
|
||||
# testing
|
||||
# to get Google Drive env variables: add storage in UI and copy data from added storage here
|
||||
TEST_GOOGLE_DRIVE_CLIENT_ID=
|
||||
TEST_GOOGLE_DRIVE_CLIENT_SECRET=
|
||||
TEST_GOOGLE_DRIVE_TOKEN_JSON=
|
||||
# testing DBs
|
||||
TEST_POSTGRES_13_PORT=5001
|
||||
TEST_POSTGRES_14_PORT=5002
|
||||
TEST_POSTGRES_15_PORT=5003
|
||||
TEST_POSTGRES_16_PORT=5004
|
||||
TEST_POSTGRES_17_PORT=5005
|
||||
# testing S3
|
||||
TEST_MINIO_PORT=9000
|
||||
TEST_MINIO_CONSOLE_PORT=9001
|
||||
@@ -17,10 +17,12 @@ import (
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/disk"
|
||||
"postgresus-backend/internal/features/healthcheck"
|
||||
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/restores"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
system_healthcheck "postgresus-backend/internal/features/system/healthcheck"
|
||||
"postgresus-backend/internal/features/users"
|
||||
env_utils "postgresus-backend/internal/util/env"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
@@ -129,7 +131,9 @@ func setUpRoutes(r *gin.Engine) {
|
||||
databaseController := databases.GetDatabaseController()
|
||||
backupController := backups.GetBackupController()
|
||||
restoreController := restores.GetRestoreController()
|
||||
healthcheckController := healthcheck.GetHealthcheckController()
|
||||
healthcheckController := system_healthcheck.GetHealthcheckController()
|
||||
healthcheckConfigController := healthcheck_config.GetHealthcheckConfigController()
|
||||
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
|
||||
diskController := disk.GetDiskController()
|
||||
|
||||
downdetectContoller.RegisterRoutes(v1)
|
||||
@@ -141,10 +145,13 @@ func setUpRoutes(r *gin.Engine) {
|
||||
restoreController.RegisterRoutes(v1)
|
||||
healthcheckController.RegisterRoutes(v1)
|
||||
diskController.RegisterRoutes(v1)
|
||||
healthcheckConfigController.RegisterRoutes(v1)
|
||||
healthcheckAttemptController.RegisterRoutes(v1)
|
||||
}
|
||||
|
||||
func setUpDependencies() {
|
||||
backups.SetupDependencies()
|
||||
healthcheck_config.SetupDependencies()
|
||||
}
|
||||
|
||||
func runBackgroundTasks(log *slog.Logger) {
|
||||
@@ -162,6 +169,10 @@ func runBackgroundTasks(log *slog.Logger) {
|
||||
go runWithPanicLogging(log, "restore background service", func() {
|
||||
restores.GetRestoreBackgroundService().Run()
|
||||
})
|
||||
|
||||
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
|
||||
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
|
||||
})
|
||||
}
|
||||
|
||||
func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) {
|
||||
|
||||
@@ -17,4 +17,72 @@ services:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
container_name: dev-db
|
||||
command: -p 5437
|
||||
shm_size: 10gb
|
||||
shm_size: 10gb
|
||||
|
||||
# Test MinIO container
|
||||
test-minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "${TEST_MINIO_PORT:-9000}:9000"
|
||||
- "${TEST_MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=testuser
|
||||
- MINIO_ROOT_PASSWORD=testpassword
|
||||
container_name: test-minio
|
||||
command: server /data --console-address ":9001"
|
||||
|
||||
# Test PostgreSQL containers
|
||||
test-postgres-13:
|
||||
image: postgres:13
|
||||
ports:
|
||||
- "${TEST_POSTGRES_13_PORT}:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=testdb
|
||||
- POSTGRES_USER=testuser
|
||||
- POSTGRES_PASSWORD=testpassword
|
||||
container_name: test-postgres-13
|
||||
shm_size: 1gb
|
||||
|
||||
test-postgres-14:
|
||||
image: postgres:14
|
||||
ports:
|
||||
- "${TEST_POSTGRES_14_PORT}:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=testdb
|
||||
- POSTGRES_USER=testuser
|
||||
- POSTGRES_PASSWORD=testpassword
|
||||
container_name: test-postgres-14
|
||||
shm_size: 1gb
|
||||
|
||||
test-postgres-15:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- "${TEST_POSTGRES_15_PORT}:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=testdb
|
||||
- POSTGRES_USER=testuser
|
||||
- POSTGRES_PASSWORD=testpassword
|
||||
container_name: test-postgres-15
|
||||
shm_size: 1gb
|
||||
|
||||
test-postgres-16:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "${TEST_POSTGRES_16_PORT}:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=testdb
|
||||
- POSTGRES_USER=testuser
|
||||
- POSTGRES_PASSWORD=testpassword
|
||||
container_name: test-postgres-16
|
||||
shm_size: 1gb
|
||||
|
||||
test-postgres-17:
|
||||
image: postgres:17
|
||||
ports:
|
||||
- "${TEST_POSTGRES_17_PORT}:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=testdb
|
||||
- POSTGRES_USER=testuser
|
||||
- POSTGRES_PASSWORD=testpassword
|
||||
container_name: test-postgres-17
|
||||
shm_size: 1gb
|
||||
|
||||
@@ -3,7 +3,6 @@ module postgresus-backend
|
||||
go 1.23.3
|
||||
|
||||
require (
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
@@ -19,7 +18,6 @@ require (
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/testcontainers/testcontainers-go v0.37.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.26.1
|
||||
@@ -32,31 +30,20 @@ require (
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.2.2+incompatible // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -75,7 +62,6 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
@@ -86,47 +72,28 @@ require (
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.0.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
|
||||
116
backend/go.sum
116
backend/go.sum
@@ -2,25 +2,15 @@ cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
||||
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
@@ -30,35 +20,13 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
|
||||
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
@@ -108,10 +76,10 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -123,8 +91,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
|
||||
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -147,8 +113,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
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=
|
||||
@@ -167,10 +131,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
@@ -185,40 +145,16 @@ 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.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
||||
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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -229,8 +165,6 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -250,20 +184,12 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
|
||||
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
|
||||
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/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
@@ -273,10 +199,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
@@ -285,52 +207,35 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -340,8 +245,6 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -349,29 +252,20 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
|
||||
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
|
||||
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -392,8 +286,6 @@ gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
|
||||
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
|
||||
|
||||
@@ -29,9 +29,18 @@ type EnvVariables struct {
|
||||
DataFolder string
|
||||
TempFolder string
|
||||
|
||||
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID" required:"true"`
|
||||
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET" required:"true"`
|
||||
TestGoogleDriveTokenJSON string `env:"TEST_GOOGLE_DRIVE_TOKEN_JSON" required:"true"`
|
||||
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID"`
|
||||
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET"`
|
||||
TestGoogleDriveTokenJSON string `env:"TEST_GOOGLE_DRIVE_TOKEN_JSON"`
|
||||
|
||||
TestPostgres13Port string `env:"TEST_POSTGRES_13_PORT"`
|
||||
TestPostgres14Port string `env:"TEST_POSTGRES_14_PORT"`
|
||||
TestPostgres15Port string `env:"TEST_POSTGRES_15_PORT"`
|
||||
TestPostgres16Port string `env:"TEST_POSTGRES_16_PORT"`
|
||||
TestPostgres17Port string `env:"TEST_POSTGRES_17_PORT"`
|
||||
|
||||
TestMinioPort string `env:"TEST_MINIO_PORT"`
|
||||
TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -122,5 +131,37 @@ func loadEnvVariables() {
|
||||
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "data")
|
||||
env.TempFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "temp")
|
||||
|
||||
if env.IsTesting {
|
||||
if env.TestPostgres13Port == "" {
|
||||
log.Error("TEST_POSTGRES_13_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
if env.TestPostgres14Port == "" {
|
||||
log.Error("TEST_POSTGRES_14_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
if env.TestPostgres15Port == "" {
|
||||
log.Error("TEST_POSTGRES_15_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
if env.TestPostgres16Port == "" {
|
||||
log.Error("TEST_POSTGRES_16_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
if env.TestPostgres17Port == "" {
|
||||
log.Error("TEST_POSTGRES_17_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if env.TestMinioPort == "" {
|
||||
log.Error("TEST_MINIO_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
if env.TestMinioConsolePort == "" {
|
||||
log.Error("TEST_MINIO_CONSOLE_PORT is empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Environment variables loaded successfully!")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(user.UserID)
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
t.Run("BackupFailed_FailNotificationSent", func(t *testing.T) {
|
||||
mockNotificationSender := &MockNotificationSender{}
|
||||
backupService := &BackupService{
|
||||
|
||||
@@ -70,15 +70,15 @@ func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error {
|
||||
func testSingleDatabaseConnection(
|
||||
logger *slog.Logger,
|
||||
ctx context.Context,
|
||||
p *PostgresqlDatabase,
|
||||
postgresDb *PostgresqlDatabase,
|
||||
) error {
|
||||
// For single database backup, we need to connect to the specific database
|
||||
if p.Database == nil || *p.Database == "" {
|
||||
if postgresDb.Database == nil || *postgresDb.Database == "" {
|
||||
return errors.New("database name is required for single database backup (pg_dump)")
|
||||
}
|
||||
|
||||
// Build connection string for the specific database
|
||||
connStr := buildConnectionStringForDB(p, *p.Database)
|
||||
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database)
|
||||
|
||||
// Test connection
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
@@ -87,7 +87,7 @@ func testSingleDatabaseConnection(
|
||||
// - handle wrong creds
|
||||
// - handle wrong database name
|
||||
// - handle wrong protocol
|
||||
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
|
||||
return fmt.Errorf("failed to connect to database '%s': %w", *postgresDb.Database, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
@@ -96,8 +96,12 @@ func testSingleDatabaseConnection(
|
||||
}()
|
||||
|
||||
// Test if we can perform basic operations (like pg_dump would need)
|
||||
if err := testBasicOperations(ctx, conn, *p.Database); err != nil {
|
||||
return fmt.Errorf("basic operations test failed for database '%s': %w", *p.Database, err)
|
||||
if err := testBasicOperations(ctx, conn, *postgresDb.Database); err != nil {
|
||||
return fmt.Errorf(
|
||||
"basic operations test failed for database '%s': %w",
|
||||
*postgresDb.Database,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/users"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
@@ -9,8 +10,10 @@ var databaseRepository = &DatabaseRepository{}
|
||||
|
||||
var databaseService = &DatabaseService{
|
||||
databaseRepository,
|
||||
nil,
|
||||
notifiers.GetNotifierService(),
|
||||
logger.GetLogger(),
|
||||
nil,
|
||||
nil,
|
||||
}
|
||||
|
||||
var databaseController = &DatabaseController{
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package databases
|
||||
@@ -60,3 +60,10 @@ const (
|
||||
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
|
||||
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
|
||||
)
|
||||
|
||||
type HealthStatus string
|
||||
|
||||
const (
|
||||
HealthStatusAvailable HealthStatus = "AVAILABLE"
|
||||
HealthStatusUnavailable HealthStatus = "UNAVAILABLE"
|
||||
)
|
||||
|
||||
@@ -17,3 +17,7 @@ type DatabaseConnector interface {
|
||||
type DatabaseStorageChangeListener interface {
|
||||
OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
|
||||
}
|
||||
|
||||
type DatabaseCreationListener interface {
|
||||
OnDatabaseCreated(databaseID uuid.UUID)
|
||||
}
|
||||
|
||||
@@ -37,15 +37,19 @@ type Database struct {
|
||||
// they are used for pretty UI
|
||||
LastBackupTime *time.Time `json:"lastBackupTime,omitempty" gorm:"column:last_backup_time;type:timestamp with time zone"`
|
||||
LastBackupErrorMessage *string `json:"lastBackupErrorMessage,omitempty" gorm:"column:last_backup_error_message;type:text"`
|
||||
|
||||
HealthStatus *HealthStatus `json:"healthStatus" gorm:"column:health_status;type:text;not null"`
|
||||
}
|
||||
|
||||
func (d *Database) BeforeSave(tx *gorm.DB) error {
|
||||
// Convert SendNotificationsOn array to string
|
||||
if len(d.SendNotificationsOn) > 0 {
|
||||
notificationTypes := make([]string, len(d.SendNotificationsOn))
|
||||
|
||||
for i, notificationType := range d.SendNotificationsOn {
|
||||
notificationTypes[i] = string(notificationType)
|
||||
}
|
||||
|
||||
d.SendNotificationsOnString = strings.Join(notificationTypes, ",")
|
||||
} else {
|
||||
d.SendNotificationsOnString = ""
|
||||
|
||||
@@ -117,7 +117,7 @@ func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error)
|
||||
Preload("Storage").
|
||||
Preload("Notifiers").
|
||||
Where("user_id = ?", userID).
|
||||
Order("name ASC").
|
||||
Order("CASE WHEN health_status = 'UNAVAILABLE' THEN 1 WHEN health_status = 'AVAILABLE' THEN 2 WHEN health_status IS NULL THEN 3 ELSE 4 END, name ASC").
|
||||
Find(&databases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package databases
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"time"
|
||||
|
||||
@@ -10,9 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type DatabaseService struct {
|
||||
dbRepository *DatabaseRepository
|
||||
dbRepository *DatabaseRepository
|
||||
notifierService *notifiers.NotifierService
|
||||
logger *slog.Logger
|
||||
|
||||
dbStorageChangeListener DatabaseStorageChangeListener
|
||||
logger *slog.Logger
|
||||
dbCreationListener []DatabaseCreationListener
|
||||
}
|
||||
|
||||
func (s *DatabaseService) SetDatabaseStorageChangeListener(
|
||||
@@ -21,6 +25,12 @@ func (s *DatabaseService) SetDatabaseStorageChangeListener(
|
||||
s.dbStorageChangeListener = dbStorageChangeListener
|
||||
}
|
||||
|
||||
func (s *DatabaseService) AddDbCreationListener(
|
||||
dbCreationListener DatabaseCreationListener,
|
||||
) {
|
||||
s.dbCreationListener = append(s.dbCreationListener, dbCreationListener)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) CreateDatabase(
|
||||
user *users_models.User,
|
||||
database *Database,
|
||||
@@ -31,11 +41,15 @@ func (s *DatabaseService) CreateDatabase(
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.dbRepository.Save(database)
|
||||
database, err := s.dbRepository.Save(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, listener := range s.dbCreationListener {
|
||||
listener.OnDatabaseCreated(database.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,3 +220,21 @@ func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime tim
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DatabaseService) SetHealthStatus(
|
||||
databaseID uuid.UUID,
|
||||
healthStatus *HealthStatus,
|
||||
) error {
|
||||
database, err := s.dbRepository.FindByID(databaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
database.HealthStatus = healthStatus
|
||||
_, err = s.dbRepository.Save(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -26,6 +28,14 @@ func CreateTestDatabase(
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "postgres",
|
||||
Password: "postgres",
|
||||
},
|
||||
|
||||
StorageID: storage.ID,
|
||||
Storage: *storage,
|
||||
|
||||
@@ -45,3 +55,10 @@ func CreateTestDatabase(
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func RemoveTestDatabase(database *Database) {
|
||||
err := databaseRepository.Delete(database.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthcheckAttemptBackgroundService struct {
|
||||
healthcheckConfigService *healthcheck_config.HealthcheckConfigService
|
||||
checkPgHealthUseCase *CheckPgHealthUseCase
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *HealthcheckAttemptBackgroundService) RunBackgroundTasks() {
|
||||
// first healthcheck immediately
|
||||
s.checkDatabases()
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.checkDatabases()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HealthcheckAttemptBackgroundService) checkDatabases() {
|
||||
now := time.Now().UTC()
|
||||
|
||||
healthcheckConfigs, err := s.healthcheckConfigService.GetDatabasesWithEnabledHealthcheck()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get databases with enabled healthcheck", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, healthcheckConfig := range healthcheckConfigs {
|
||||
go func(healthcheckConfig *healthcheck_config.HealthcheckConfig) {
|
||||
err := s.checkPgHealthUseCase.Execute(now, healthcheckConfig)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to check pg health", "error", err)
|
||||
}
|
||||
}(&healthcheckConfig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CheckPgHealthUseCase struct {
|
||||
healthcheckAttemptRepository *HealthcheckAttemptRepository
|
||||
healthcheckAttemptSender HealthcheckAttemptSender
|
||||
databaseService DatabaseService
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) Execute(
|
||||
now time.Time,
|
||||
healthcheckConfig *healthcheck_config.HealthcheckConfig,
|
||||
) error {
|
||||
database, err := uc.databaseService.GetDatabaseByID(healthcheckConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = uc.validateDatabase(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isExecuteNewAttempt, err := uc.isReadyForNewAttempt(
|
||||
now,
|
||||
database,
|
||||
healthcheckConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !isExecuteNewAttempt {
|
||||
return nil
|
||||
}
|
||||
|
||||
heathcheckAttempt, err := uc.healthcheckDatabase(now, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save the attempt
|
||||
err = uc.healthcheckAttemptRepository.Insert(heathcheckAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = uc.updateDatabaseHealthStatusIfChanged(
|
||||
database,
|
||||
healthcheckConfig,
|
||||
heathcheckAttempt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = uc.healthcheckAttemptRepository.DeleteOlderThan(
|
||||
database.ID,
|
||||
time.Now().Add(-time.Duration(healthcheckConfig.StoreAttemptsDays)*24*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) updateDatabaseHealthStatusIfChanged(
|
||||
database *databases.Database,
|
||||
healthcheckConfig *healthcheck_config.HealthcheckConfig,
|
||||
heathcheckAttempt *HealthcheckAttempt,
|
||||
) error {
|
||||
if &heathcheckAttempt.Status == database.HealthStatus {
|
||||
fmt.Println("Database health status is the same as the attempt status")
|
||||
return nil
|
||||
}
|
||||
|
||||
if (database.HealthStatus == nil ||
|
||||
*database.HealthStatus == databases.HealthStatusUnavailable) &&
|
||||
heathcheckAttempt.Status == databases.HealthStatusAvailable {
|
||||
err := uc.databaseService.SetHealthStatus(
|
||||
database.ID,
|
||||
&heathcheckAttempt.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uc.sendDbStatusNotification(
|
||||
healthcheckConfig,
|
||||
database,
|
||||
heathcheckAttempt.Status,
|
||||
)
|
||||
}
|
||||
|
||||
if (database.HealthStatus == nil ||
|
||||
*database.HealthStatus == databases.HealthStatusAvailable) &&
|
||||
heathcheckAttempt.Status == databases.HealthStatusUnavailable {
|
||||
if healthcheckConfig.AttemptsBeforeConcideredAsDown <= 1 {
|
||||
// proceed, 1 fail is enough to consider db as down
|
||||
} else {
|
||||
lastHealthcheckAttempts, err := uc.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
healthcheckConfig.AttemptsBeforeConcideredAsDown,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(lastHealthcheckAttempts) < healthcheckConfig.AttemptsBeforeConcideredAsDown {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, attempt := range lastHealthcheckAttempts {
|
||||
if attempt.Status == databases.HealthStatusAvailable {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := uc.databaseService.SetHealthStatus(
|
||||
database.ID,
|
||||
&heathcheckAttempt.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uc.sendDbStatusNotification(
|
||||
healthcheckConfig,
|
||||
database,
|
||||
databases.HealthStatusUnavailable,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) healthcheckDatabase(
|
||||
now time.Time,
|
||||
database *databases.Database,
|
||||
) (*HealthcheckAttempt, error) {
|
||||
// Test the connection
|
||||
healthStatus := databases.HealthStatusAvailable
|
||||
err := uc.databaseService.TestDatabaseConnectionDirect(database)
|
||||
if err != nil {
|
||||
healthStatus = databases.HealthStatusUnavailable
|
||||
logger.GetLogger().
|
||||
Error(
|
||||
"Database health check failed",
|
||||
slog.String("database_id", database.ID.String()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Create health check attempt
|
||||
attempt := &HealthcheckAttempt{
|
||||
ID: uuid.New(),
|
||||
DatabaseID: database.ID,
|
||||
Status: healthStatus,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) validateDatabase(
|
||||
database *databases.Database,
|
||||
) error {
|
||||
if database.Type != databases.DatabaseTypePostgres {
|
||||
return errors.New("database type is not postgres")
|
||||
}
|
||||
|
||||
if database.Postgresql == nil {
|
||||
return errors.New("database Postgresql is not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) isReadyForNewAttempt(
|
||||
now time.Time,
|
||||
database *databases.Database,
|
||||
healthcheckConfig *healthcheck_config.HealthcheckConfig,
|
||||
) (bool, error) {
|
||||
lastHealthcheckAttempt, err := uc.healthcheckAttemptRepository.FindLastByDatabaseID(database.ID)
|
||||
if err != nil {
|
||||
// If no attempts found, it's ready for first attempt
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Check if enough time has passed since last attempt
|
||||
intervalDuration := time.Duration(healthcheckConfig.IntervalMinutes) * time.Minute
|
||||
nextAttemptTime := lastHealthcheckAttempt.CreatedAt.Add(intervalDuration)
|
||||
|
||||
return now.After(nextAttemptTime.Add(-1 * time.Second)), nil
|
||||
}
|
||||
|
||||
func (uc *CheckPgHealthUseCase) sendDbStatusNotification(
|
||||
healthcheckConfig *healthcheck_config.HealthcheckConfig,
|
||||
database *databases.Database,
|
||||
newHealthStatus databases.HealthStatus,
|
||||
) {
|
||||
if !healthcheckConfig.IsSentNotificationWhenUnavailable {
|
||||
return
|
||||
}
|
||||
|
||||
messageTitle := ""
|
||||
messageBody := ""
|
||||
|
||||
if newHealthStatus == databases.HealthStatusAvailable {
|
||||
messageTitle = "✅ Database is back online"
|
||||
messageBody = "✅ The database is back online after being unavailable"
|
||||
} else {
|
||||
messageTitle = "❌ Database is unavailable"
|
||||
messageBody = "❌ The database is currently unavailable"
|
||||
}
|
||||
|
||||
for _, notifier := range database.Notifiers {
|
||||
uc.healthcheckAttemptSender.SendNotification(
|
||||
¬ifier,
|
||||
messageTitle,
|
||||
messageBody,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"postgresus-backend/internal/features/databases"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/features/users"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(user.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(user.UserID)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
|
||||
t.Run("Test_DbAttemptFailed_DbMarkedAsUnavailable", func(t *testing.T) {
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Setup mock notifier sender
|
||||
mockSender := &MockHealthcheckAttemptSender{}
|
||||
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
|
||||
|
||||
// Setup mock database service
|
||||
mockDatabaseService := &MockDatabaseService{}
|
||||
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
|
||||
Return(errors.New("test error"))
|
||||
unavailableStatus := databases.HealthStatusUnavailable
|
||||
mockDatabaseService.On("SetHealthStatus", database.ID, &unavailableStatus).
|
||||
Return(nil)
|
||||
mockDatabaseService.On("GetDatabaseByID", database.ID).
|
||||
Return(database, nil)
|
||||
|
||||
// Setup healthcheck config
|
||||
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 1,
|
||||
AttemptsBeforeConcideredAsDown: 1,
|
||||
StoreAttemptsDays: 7,
|
||||
}
|
||||
|
||||
// Create use case with mock sender
|
||||
useCase := &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
|
||||
healthcheckAttemptSender: mockSender,
|
||||
databaseService: mockDatabaseService,
|
||||
}
|
||||
|
||||
// Execute healthcheck
|
||||
err := useCase.Execute(time.Now().UTC(), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify attempt was created and marked as unavailable
|
||||
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
1,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attempts, 1)
|
||||
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
|
||||
|
||||
// Verify database health status was updated (via mock)
|
||||
mockDatabaseService.AssertCalled(
|
||||
t,
|
||||
"SetHealthStatus",
|
||||
database.ID,
|
||||
&unavailableStatus,
|
||||
)
|
||||
|
||||
// Verify notification was sent
|
||||
mockSender.AssertCalled(
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
"❌ Database is unavailable",
|
||||
"❌ The database is currently unavailable",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run(
|
||||
"Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbNotMarkerdAsDownAfterFirstAttempt",
|
||||
func(t *testing.T) {
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Setup mock notifier sender
|
||||
mockSender := &MockHealthcheckAttemptSender{}
|
||||
|
||||
// Setup mock database service - connection fails but SetHealthStatus should not be called
|
||||
mockDatabaseService := &MockDatabaseService{}
|
||||
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
|
||||
Return(errors.New("test error"))
|
||||
mockDatabaseService.On("GetDatabaseByID", database.ID).
|
||||
Return(database, nil)
|
||||
|
||||
// Setup healthcheck config requiring 3 attempts before marking as down
|
||||
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 1,
|
||||
AttemptsBeforeConcideredAsDown: 3,
|
||||
StoreAttemptsDays: 7,
|
||||
}
|
||||
|
||||
// Create use case with mock sender
|
||||
useCase := &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
|
||||
healthcheckAttemptSender: mockSender,
|
||||
databaseService: mockDatabaseService,
|
||||
}
|
||||
|
||||
// Execute first healthcheck
|
||||
err := useCase.Execute(time.Now().UTC(), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify attempt was created and marked as unavailable
|
||||
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
1,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attempts, 1)
|
||||
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
|
||||
|
||||
// Verify database health status was NOT updated (SetHealthStatus should not be called)
|
||||
unavailableStatus := databases.HealthStatusUnavailable
|
||||
mockDatabaseService.AssertNotCalled(
|
||||
t,
|
||||
"SetHealthStatus",
|
||||
database.ID,
|
||||
&unavailableStatus,
|
||||
)
|
||||
|
||||
// Verify no notification was sent (not marked as down yet)
|
||||
mockSender.AssertNotCalled(
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
"❌ Database is unavailable",
|
||||
"❌ The database is currently unavailable",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbMarkerdAsDownAfterThirdFailedAttempt",
|
||||
func(t *testing.T) {
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Make sure DB is available
|
||||
available := databases.HealthStatusAvailable
|
||||
database.HealthStatus = &available
|
||||
err := databases.GetDatabaseService().
|
||||
SetHealthStatus(database.ID, &available)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Setup mock notifier sender
|
||||
mockSender := &MockHealthcheckAttemptSender{}
|
||||
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
|
||||
|
||||
// Setup mock database service
|
||||
mockDatabaseService := &MockDatabaseService{}
|
||||
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
|
||||
Return(errors.New("test error"))
|
||||
unavailableStatus := databases.HealthStatusUnavailable
|
||||
mockDatabaseService.On("SetHealthStatus", database.ID, &unavailableStatus).
|
||||
Return(nil)
|
||||
mockDatabaseService.On("GetDatabaseByID", database.ID).
|
||||
Return(database, nil)
|
||||
|
||||
// Setup healthcheck config requiring 3 attempts before marking as down
|
||||
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 1,
|
||||
AttemptsBeforeConcideredAsDown: 3,
|
||||
StoreAttemptsDays: 7,
|
||||
}
|
||||
|
||||
// Create use case with mock sender
|
||||
useCase := &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
|
||||
healthcheckAttemptSender: mockSender,
|
||||
databaseService: mockDatabaseService,
|
||||
}
|
||||
|
||||
// Execute three failed healthchecks
|
||||
now := time.Now().UTC()
|
||||
for i := range 3 {
|
||||
err := useCase.Execute(now.Add(time.Duration(i)*time.Minute), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify attempt was created
|
||||
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
1,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attempts, 1)
|
||||
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
|
||||
}
|
||||
|
||||
// Verify database health status was updated to unavailable after 3rd attempt
|
||||
mockDatabaseService.AssertCalled(
|
||||
t,
|
||||
"SetHealthStatus",
|
||||
database.ID,
|
||||
&unavailableStatus,
|
||||
)
|
||||
|
||||
// Verify notification was sent
|
||||
mockSender.AssertCalled(
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
"❌ Database is unavailable",
|
||||
"❌ The database is currently unavailable",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run("Test_UnavailableDbAttemptSucceed_DbMarkedAsAvailable", func(t *testing.T) {
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Make sure DB is unavailable
|
||||
unavailable := databases.HealthStatusUnavailable
|
||||
database.HealthStatus = &unavailable
|
||||
err := databases.GetDatabaseService().
|
||||
SetHealthStatus(database.ID, &unavailable)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Setup mock notifier sender
|
||||
mockSender := &MockHealthcheckAttemptSender{}
|
||||
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
|
||||
|
||||
// Setup mock database service - connection succeeds
|
||||
mockDatabaseService := &MockDatabaseService{}
|
||||
mockDatabaseService.On("TestDatabaseConnectionDirect", database).Return(nil)
|
||||
availableStatus := databases.HealthStatusAvailable
|
||||
mockDatabaseService.On("SetHealthStatus", database.ID, &availableStatus).
|
||||
Return(nil)
|
||||
mockDatabaseService.On("GetDatabaseByID", database.ID).
|
||||
Return(database, nil)
|
||||
|
||||
// Setup healthcheck config
|
||||
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 1,
|
||||
AttemptsBeforeConcideredAsDown: 1,
|
||||
StoreAttemptsDays: 7,
|
||||
}
|
||||
|
||||
// Create use case with mock sender
|
||||
useCase := &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
|
||||
healthcheckAttemptSender: mockSender,
|
||||
databaseService: mockDatabaseService,
|
||||
}
|
||||
|
||||
// Execute healthcheck (should succeed)
|
||||
err = useCase.Execute(time.Now().UTC(), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify attempt was created and marked as available
|
||||
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
1,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attempts, 1)
|
||||
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
|
||||
|
||||
// Verify database health status was updated to available
|
||||
mockDatabaseService.AssertCalled(
|
||||
t,
|
||||
"SetHealthStatus",
|
||||
database.ID,
|
||||
&availableStatus,
|
||||
)
|
||||
|
||||
// Verify notification was sent for recovery
|
||||
mockSender.AssertCalled(
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
"✅ Database is back online",
|
||||
"✅ The database is back online after being unavailable",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run(
|
||||
"Test_DbHealthcheckExecutedFast_HealthcheckNotExecutedFasterThanInterval",
|
||||
func(t *testing.T) {
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Setup mock notifier sender
|
||||
mockSender := &MockHealthcheckAttemptSender{}
|
||||
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
|
||||
|
||||
// Setup mock database service - connection succeeds
|
||||
mockDatabaseService := &MockDatabaseService{}
|
||||
mockDatabaseService.On("TestDatabaseConnectionDirect", database).Return(nil)
|
||||
availableStatus := databases.HealthStatusAvailable
|
||||
mockDatabaseService.On("SetHealthStatus", database.ID, &availableStatus).
|
||||
Return(nil)
|
||||
mockDatabaseService.On("GetDatabaseByID", database.ID).
|
||||
Return(database, nil)
|
||||
|
||||
// Setup healthcheck config with 5 minute interval
|
||||
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 5, // 5 minute interval
|
||||
AttemptsBeforeConcideredAsDown: 1,
|
||||
StoreAttemptsDays: 7,
|
||||
}
|
||||
|
||||
// Create use case with mock sender
|
||||
useCase := &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
|
||||
healthcheckAttemptSender: mockSender,
|
||||
databaseService: mockDatabaseService,
|
||||
}
|
||||
|
||||
// Execute first healthcheck
|
||||
now := time.Now().UTC()
|
||||
err := useCase.Execute(now, healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify first attempt was created
|
||||
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
10,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attempts, 1)
|
||||
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
|
||||
|
||||
// Try to execute second healthcheck immediately (should be skipped)
|
||||
err = useCase.Execute(now.Add(1*time.Second), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify no new attempt was created (still only 1 attempt)
|
||||
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
10,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(
|
||||
t,
|
||||
attempts,
|
||||
1,
|
||||
"Second healthcheck should not have been executed due to interval constraint",
|
||||
)
|
||||
|
||||
// Try to execute third healthcheck after 4 minutes (still too early)
|
||||
err = useCase.Execute(now.Add(4*time.Minute), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify still only 1 attempt
|
||||
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
10,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(
|
||||
t,
|
||||
attempts,
|
||||
1,
|
||||
"Third healthcheck should not have been executed due to interval constraint",
|
||||
)
|
||||
|
||||
// Execute fourth healthcheck after 5 minutes (should be executed)
|
||||
err = useCase.Execute(now.Add(5*time.Minute), healthcheckConfig)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify new attempt was created (now should be 2 attempts)
|
||||
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
|
||||
database.ID,
|
||||
10,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(
|
||||
t,
|
||||
attempts,
|
||||
2,
|
||||
"Fourth healthcheck should have been executed after interval passed",
|
||||
)
|
||||
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
|
||||
assert.Equal(t, databases.HealthStatusAvailable, attempts[1].Status)
|
||||
},
|
||||
)
|
||||
}
|
||||
70
backend/internal/features/healthcheck/attempt/controller.go
Normal file
70
backend/internal/features/healthcheck/attempt/controller.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"postgresus-backend/internal/features/users"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckAttemptController struct {
|
||||
healthcheckAttemptService *HealthcheckAttemptService
|
||||
userService *users.UserService
|
||||
}
|
||||
|
||||
func (c *HealthcheckAttemptController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/healthcheck-attempts/:databaseId", c.GetAttemptsByDatabase)
|
||||
}
|
||||
|
||||
// GetAttemptsByDatabase
|
||||
// @Summary Get healthcheck attempts by database
|
||||
// @Description Get healthcheck attempts for a specific database with optional before date filter
|
||||
// @Tags healthcheck-attempts
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "JWT token"
|
||||
// @Param databaseId path string true "Database ID"
|
||||
// @Param afterDate query string false "After date (RFC3339 format)"
|
||||
// @Success 200 {array} HealthcheckAttempt
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Router /healthcheck-attempts/{databaseId} [get]
|
||||
func (c *HealthcheckAttemptController) GetAttemptsByDatabase(ctx *gin.Context) {
|
||||
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
databaseID, err := uuid.Parse(ctx.Param("databaseId"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
|
||||
return
|
||||
}
|
||||
|
||||
afterDate := time.Now().UTC()
|
||||
if afterDateStr := ctx.Query("afterDate"); afterDateStr != "" {
|
||||
parsedDate, err := time.Parse(time.RFC3339, afterDateStr)
|
||||
if err != nil {
|
||||
ctx.JSON(
|
||||
http.StatusBadRequest,
|
||||
gin.H{"error": "invalid afterDate format, use RFC3339"},
|
||||
)
|
||||
return
|
||||
}
|
||||
afterDate = parsedDate
|
||||
}
|
||||
|
||||
attempts, err := c.healthcheckAttemptService.GetAttemptsByDatabase(
|
||||
*user,
|
||||
databaseID,
|
||||
afterDate,
|
||||
)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, attempts)
|
||||
}
|
||||
43
backend/internal/features/healthcheck/attempt/di.go
Normal file
43
backend/internal/features/healthcheck/attempt/di.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/users"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var healthcheckAttemptRepository = &HealthcheckAttemptRepository{}
|
||||
var healthcheckAttemptService = &HealthcheckAttemptService{
|
||||
healthcheckAttemptRepository,
|
||||
databases.GetDatabaseService(),
|
||||
}
|
||||
|
||||
var checkPgHealthUseCase = &CheckPgHealthUseCase{
|
||||
healthcheckAttemptRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
databases.GetDatabaseService(),
|
||||
}
|
||||
|
||||
var healthcheckAttemptBackgroundService = &HealthcheckAttemptBackgroundService{
|
||||
healthcheck_config.GetHealthcheckConfigService(),
|
||||
checkPgHealthUseCase,
|
||||
logger.GetLogger(),
|
||||
}
|
||||
var healthcheckAttemptController = &HealthcheckAttemptController{
|
||||
healthcheckAttemptService,
|
||||
users.GetUserService(),
|
||||
}
|
||||
|
||||
func GetHealthcheckAttemptService() *HealthcheckAttemptService {
|
||||
return healthcheckAttemptService
|
||||
}
|
||||
|
||||
func GetHealthcheckAttemptBackgroundService() *HealthcheckAttemptBackgroundService {
|
||||
return healthcheckAttemptBackgroundService
|
||||
}
|
||||
|
||||
func GetHealthcheckAttemptController() *HealthcheckAttemptController {
|
||||
return healthcheckAttemptController
|
||||
}
|
||||
27
backend/internal/features/healthcheck/attempt/interfaces.go
Normal file
27
backend/internal/features/healthcheck/attempt/interfaces.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckAttemptSender interface {
|
||||
SendNotification(
|
||||
notifier *notifiers.Notifier,
|
||||
title string,
|
||||
message string,
|
||||
)
|
||||
}
|
||||
|
||||
type DatabaseService interface {
|
||||
GetDatabaseByID(id uuid.UUID) (*databases.Database, error)
|
||||
|
||||
TestDatabaseConnectionDirect(database *databases.Database) error
|
||||
|
||||
SetHealthStatus(
|
||||
databaseID uuid.UUID,
|
||||
healthStatus *databases.HealthStatus,
|
||||
) error
|
||||
}
|
||||
55
backend/internal/features/healthcheck/attempt/mocks.go
Normal file
55
backend/internal/features/healthcheck/attempt/mocks.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockHealthcheckAttemptSender struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockHealthcheckAttemptSender) SendNotification(
|
||||
notifier *notifiers.Notifier,
|
||||
title string,
|
||||
message string,
|
||||
) {
|
||||
m.Called(notifier, title, message)
|
||||
}
|
||||
|
||||
type MockDatabaseService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDatabaseService) TestDatabaseConnectionDirect(
|
||||
database *databases.Database,
|
||||
) error {
|
||||
return m.Called(database).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockDatabaseService) SetHealthStatus(
|
||||
databaseID uuid.UUID,
|
||||
healthStatus *databases.HealthStatus,
|
||||
) error {
|
||||
return m.Called(databaseID, healthStatus).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockDatabaseService) GetDatabaseByID(
|
||||
id uuid.UUID,
|
||||
) (*databases.Database, error) {
|
||||
args := m.Called(id)
|
||||
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
database, ok := args.Get(0).(*databases.Database)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
return database, args.Error(1)
|
||||
}
|
||||
19
backend/internal/features/healthcheck/attempt/model.go
Normal file
19
backend/internal/features/healthcheck/attempt/model.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckAttempt struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;not null"`
|
||||
Status databases.HealthStatus `json:"status" gorm:"column:status;type:text;not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;type:timestamp with time zone;not null"`
|
||||
}
|
||||
|
||||
func (h *HealthcheckAttempt) TableName() string {
|
||||
return "healthcheck_attempts"
|
||||
}
|
||||
102
backend/internal/features/healthcheck/attempt/repository.go
Normal file
102
backend/internal/features/healthcheck/attempt/repository.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/storage"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckAttemptRepository struct{}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) FindByDatabaseIdOrderByCreatedAtDesc(
|
||||
databaseID uuid.UUID,
|
||||
afterDate time.Time,
|
||||
) ([]*HealthcheckAttempt, error) {
|
||||
var attempts []*HealthcheckAttempt
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", databaseID).
|
||||
Where("created_at > ?", afterDate).
|
||||
Order("created_at DESC").
|
||||
Find(&attempts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) FindLastByDatabaseID(
|
||||
databaseID uuid.UUID,
|
||||
) (*HealthcheckAttempt, error) {
|
||||
var attempt HealthcheckAttempt
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", databaseID).
|
||||
Order("created_at DESC").
|
||||
First(&attempt).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) DeleteOlderThan(
|
||||
databaseID uuid.UUID,
|
||||
olderThan time.Time,
|
||||
) error {
|
||||
return storage.
|
||||
GetDb().
|
||||
Where("database_id = ? AND created_at < ?", databaseID, olderThan).
|
||||
Delete(&HealthcheckAttempt{}).Error
|
||||
}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) Insert(
|
||||
attempt *HealthcheckAttempt,
|
||||
) error {
|
||||
if attempt.ID == uuid.Nil {
|
||||
attempt.ID = uuid.New()
|
||||
}
|
||||
|
||||
if attempt.CreatedAt.IsZero() {
|
||||
attempt.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
return storage.GetDb().Create(attempt).Error
|
||||
}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) FindByDatabaseIDWithLimit(
|
||||
databaseID uuid.UUID,
|
||||
limit int,
|
||||
) ([]*HealthcheckAttempt, error) {
|
||||
var attempts []*HealthcheckAttempt
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", databaseID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&attempts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
func (r *HealthcheckAttemptRepository) CountByDatabaseID(
|
||||
databaseID uuid.UUID,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Model(&HealthcheckAttempt{}).
|
||||
Where("database_id = ?", databaseID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
35
backend/internal/features/healthcheck/attempt/service.go
Normal file
35
backend/internal/features/healthcheck/attempt/service.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package healthcheck_attempt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckAttemptService struct {
|
||||
healthcheckAttemptRepository *HealthcheckAttemptRepository
|
||||
databaseService *databases.DatabaseService
|
||||
}
|
||||
|
||||
func (s *HealthcheckAttemptService) GetAttemptsByDatabase(
|
||||
user users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
afterDate time.Time,
|
||||
) ([]*HealthcheckAttempt, error) {
|
||||
database, err := s.databaseService.GetDatabaseByID(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if database.UserID != user.ID {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
return s.healthcheckAttemptRepository.FindByDatabaseIdOrderByCreatedAtDesc(
|
||||
databaseID,
|
||||
afterDate,
|
||||
)
|
||||
}
|
||||
87
backend/internal/features/healthcheck/config/controller.go
Normal file
87
backend/internal/features/healthcheck/config/controller.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"postgresus-backend/internal/features/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckConfigController struct {
|
||||
healthcheckConfigService *HealthcheckConfigService
|
||||
userService *users.UserService
|
||||
}
|
||||
|
||||
func (c *HealthcheckConfigController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/healthcheck-config", c.SaveHealthcheckConfig)
|
||||
router.GET("/healthcheck-config/:databaseId", c.GetHealthcheckConfig)
|
||||
}
|
||||
|
||||
// SaveHealthcheckConfig
|
||||
// @Summary Save healthcheck configuration
|
||||
// @Description Create or update healthcheck configuration for a database
|
||||
// @Tags healthcheck-config
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "JWT token"
|
||||
// @Param config body HealthcheckConfigDTO true "Healthcheck configuration data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Router /healthcheck-config [post]
|
||||
func (c *HealthcheckConfigController) SaveHealthcheckConfig(ctx *gin.Context) {
|
||||
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var configDTO HealthcheckConfigDTO
|
||||
if err := ctx.ShouldBindJSON(&configDTO); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.healthcheckConfigService.Save(*user, configDTO); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"message": "Healthcheck configuration saved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetHealthcheckConfig
|
||||
// @Summary Get healthcheck configuration
|
||||
// @Description Get healthcheck configuration for a specific database
|
||||
// @Tags healthcheck-config
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "JWT token"
|
||||
// @Param databaseId path string true "Database ID"
|
||||
// @Success 200 {object} HealthcheckConfig
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Router /healthcheck-config/{databaseId} [get]
|
||||
func (c *HealthcheckConfigController) GetHealthcheckConfig(ctx *gin.Context) {
|
||||
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
databaseID, err := uuid.Parse(ctx.Param("databaseId"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := c.healthcheckConfigService.GetByDatabaseID(*user, databaseID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, config)
|
||||
}
|
||||
32
backend/internal/features/healthcheck/config/di.go
Normal file
32
backend/internal/features/healthcheck/config/di.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/users"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var healthcheckConfigRepository = &HealthcheckConfigRepository{}
|
||||
var healthcheckConfigService = &HealthcheckConfigService{
|
||||
databases.GetDatabaseService(),
|
||||
healthcheckConfigRepository,
|
||||
logger.GetLogger(),
|
||||
}
|
||||
var healthcheckConfigController = &HealthcheckConfigController{
|
||||
healthcheckConfigService,
|
||||
users.GetUserService(),
|
||||
}
|
||||
|
||||
func GetHealthcheckConfigService() *HealthcheckConfigService {
|
||||
return healthcheckConfigService
|
||||
}
|
||||
|
||||
func GetHealthcheckConfigController() *HealthcheckConfigController {
|
||||
return healthcheckConfigController
|
||||
}
|
||||
|
||||
func SetupDependencies() {
|
||||
databases.
|
||||
GetDatabaseService().
|
||||
AddDbCreationListener(healthcheckConfigService)
|
||||
}
|
||||
28
backend/internal/features/healthcheck/config/dto.go
Normal file
28
backend/internal/features/healthcheck/config/dto.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckConfigDTO struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId"`
|
||||
IsHealthcheckEnabled bool `json:"isHealthcheckEnabled"`
|
||||
IsSentNotificationWhenUnavailable bool `json:"isSentNotificationWhenUnavailable"`
|
||||
|
||||
IntervalMinutes int `json:"intervalMinutes"`
|
||||
AttemptsBeforeConcideredAsDown int `json:"attemptsBeforeConcideredAsDown"`
|
||||
StoreAttemptsDays int `json:"storeAttemptsDays"`
|
||||
}
|
||||
|
||||
func (dto *HealthcheckConfigDTO) ToDTO() *HealthcheckConfig {
|
||||
return &HealthcheckConfig{
|
||||
DatabaseID: dto.DatabaseID,
|
||||
|
||||
IsHealthcheckEnabled: dto.IsHealthcheckEnabled,
|
||||
IsSentNotificationWhenUnavailable: dto.IsSentNotificationWhenUnavailable,
|
||||
|
||||
IntervalMinutes: dto.IntervalMinutes,
|
||||
AttemptsBeforeConcideredAsDown: dto.AttemptsBeforeConcideredAsDown,
|
||||
StoreAttemptsDays: dto.StoreAttemptsDays,
|
||||
}
|
||||
}
|
||||
47
backend/internal/features/healthcheck/config/model.go
Normal file
47
backend/internal/features/healthcheck/config/model.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HealthcheckConfig struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;primaryKey"`
|
||||
|
||||
IsHealthcheckEnabled bool `json:"isHealthcheckEnabled" gorm:"column:is_healthcheck_enabled;type:boolean;not null"`
|
||||
IsSentNotificationWhenUnavailable bool `json:"isSentNotificationWhenUnavailable" gorm:"column:is_sent_notification_when_unavailable;type:boolean;not null"`
|
||||
|
||||
IntervalMinutes int `json:"intervalMinutes" gorm:"column:interval_minutes;type:int;not null"`
|
||||
AttemptsBeforeConcideredAsDown int `json:"attemptsBeforeConcideredAsDown" gorm:"column:attempts_before_considered_as_down;type:int;not null"`
|
||||
StoreAttemptsDays int `json:"storeAttemptsDays" gorm:"column:store_attempts_days;type:int;not null"`
|
||||
}
|
||||
|
||||
func (c *HealthcheckConfig) TableName() string {
|
||||
return "healthcheck_configs"
|
||||
}
|
||||
|
||||
func (c *HealthcheckConfig) BeforeSave(tx *gorm.DB) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HealthcheckConfig) Validate() error {
|
||||
if c.IntervalMinutes <= 0 {
|
||||
return errors.New("interval minutes must be greater than 0")
|
||||
}
|
||||
|
||||
if c.AttemptsBeforeConcideredAsDown <= 0 {
|
||||
return errors.New("attempts before considered as down must be greater than 0")
|
||||
}
|
||||
|
||||
if c.StoreAttemptsDays <= 0 {
|
||||
return errors.New("store attempts days must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
53
backend/internal/features/healthcheck/config/repository.go
Normal file
53
backend/internal/features/healthcheck/config/repository.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HealthcheckConfigRepository struct{}
|
||||
|
||||
func (r *HealthcheckConfigRepository) Save(
|
||||
config *HealthcheckConfig,
|
||||
) error {
|
||||
db := storage.GetDb()
|
||||
|
||||
return db.Save(config).Error
|
||||
}
|
||||
|
||||
func (r *HealthcheckConfigRepository) GetDatabasesWithEnabledHealthcheck() (
|
||||
[]HealthcheckConfig, error,
|
||||
) {
|
||||
var configs []HealthcheckConfig
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("healthcheck_configs.is_healthcheck_enabled = ?", true).
|
||||
Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (r *HealthcheckConfigRepository) GetByDatabaseID(
|
||||
databaseID uuid.UUID,
|
||||
) (*HealthcheckConfig, error) {
|
||||
var config HealthcheckConfig
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", databaseID).
|
||||
First(&config).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
116
backend/internal/features/healthcheck/config/service.go
Normal file
116
backend/internal/features/healthcheck/config/service.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package healthcheck_config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type HealthcheckConfigService struct {
|
||||
databaseService *databases.DatabaseService
|
||||
healthcheckConfigRepository *HealthcheckConfigRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *HealthcheckConfigService) OnDatabaseCreated(
|
||||
databaseID uuid.UUID,
|
||||
) {
|
||||
err := s.initializeDefaultConfig(databaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize default healthcheck config", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HealthcheckConfigService) Save(
|
||||
user users_models.User,
|
||||
configDTO HealthcheckConfigDTO,
|
||||
) error {
|
||||
database, err := s.databaseService.GetDatabaseByID(configDTO.DatabaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if database.UserID != user.ID {
|
||||
return errors.New("user does not have access to this database")
|
||||
}
|
||||
|
||||
healthcheckConfig := configDTO.ToDTO()
|
||||
s.logger.Info("healthcheck config", "config", healthcheckConfig)
|
||||
|
||||
healthcheckConfig.DatabaseID = database.ID
|
||||
|
||||
err = s.healthcheckConfigRepository.Save(healthcheckConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// for DBs with disabled healthcheck, we keep
|
||||
// health status as available
|
||||
if !healthcheckConfig.IsHealthcheckEnabled &&
|
||||
database.HealthStatus != nil {
|
||||
err = s.databaseService.SetHealthStatus(
|
||||
database.ID,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HealthcheckConfigService) GetByDatabaseID(
|
||||
user users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
) (*HealthcheckConfig, error) {
|
||||
database, err := s.databaseService.GetDatabaseByID(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if database.UserID != user.ID {
|
||||
return nil, errors.New("user does not have access to this database")
|
||||
}
|
||||
|
||||
config, err := s.healthcheckConfigRepository.GetByDatabaseID(database.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
err = s.initializeDefaultConfig(database.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err = s.healthcheckConfigRepository.GetByDatabaseID(database.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *HealthcheckConfigService) GetDatabasesWithEnabledHealthcheck() (
|
||||
[]HealthcheckConfig, error,
|
||||
) {
|
||||
return s.healthcheckConfigRepository.GetDatabasesWithEnabledHealthcheck()
|
||||
}
|
||||
|
||||
func (s *HealthcheckConfigService) initializeDefaultConfig(
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
return s.healthcheckConfigRepository.Save(&HealthcheckConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsHealthcheckEnabled: true,
|
||||
IsSentNotificationWhenUnavailable: true,
|
||||
IntervalMinutes: 1,
|
||||
AttemptsBeforeConcideredAsDown: 3,
|
||||
StoreAttemptsDays: 7,
|
||||
})
|
||||
}
|
||||
@@ -24,3 +24,10 @@ func CreateTestNotifier(userID uuid.UUID) *Notifier {
|
||||
|
||||
return notifier
|
||||
}
|
||||
|
||||
func RemoveTestNotifier(notifier *Notifier) {
|
||||
err := notifierRepository.Delete(notifier)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
router := createRouter()
|
||||
storage := createTestStorage(user.UserID)
|
||||
storage := createNewStorage(user.UserID)
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -45,12 +45,14 @@ func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, storages, savedStorage)
|
||||
|
||||
RemoveTestStorage(savedStorage.ID)
|
||||
}
|
||||
|
||||
func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
router := createRouter()
|
||||
storage := createTestStorage(user.UserID)
|
||||
storage := createNewStorage(user.UserID)
|
||||
|
||||
// Save initial storage
|
||||
var savedStorage Storage
|
||||
@@ -92,12 +94,14 @@ func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, storages, updatedStorage)
|
||||
|
||||
RemoveTestStorage(updatedStorage.ID)
|
||||
}
|
||||
|
||||
func Test_DeleteStorage_StorageNotReturnedViaGet(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
router := createRouter()
|
||||
storage := createTestStorage(user.UserID)
|
||||
storage := createNewStorage(user.UserID)
|
||||
|
||||
// Save initial storage
|
||||
var savedStorage Storage
|
||||
@@ -129,7 +133,7 @@ func Test_DeleteStorage_StorageNotReturnedViaGet(t *testing.T) {
|
||||
func Test_TestDirectStorageConnection_ConnectionEstablished(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
router := createRouter()
|
||||
storage := createTestStorage(user.UserID)
|
||||
storage := createNewStorage(user.UserID)
|
||||
|
||||
response := test_utils.MakePostRequest(
|
||||
t, router, "/api/v1/storages/direct-test", user.Token, storage, http.StatusOK,
|
||||
@@ -141,7 +145,7 @@ func Test_TestDirectStorageConnection_ConnectionEstablished(t *testing.T) {
|
||||
func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
|
||||
user := users.GetTestUser()
|
||||
router := createRouter()
|
||||
storage := createTestStorage(user.UserID)
|
||||
storage := createNewStorage(user.UserID)
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -159,11 +163,13 @@ func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(response.Body), "successful")
|
||||
|
||||
RemoveTestStorage(savedStorage.ID)
|
||||
}
|
||||
|
||||
func Test_CallAllMethodsWithoutAuth_UnauthorizedErrorReturned(t *testing.T) {
|
||||
router := createRouter()
|
||||
storage := createTestStorage(uuid.New())
|
||||
storage := createNewStorage(uuid.New())
|
||||
|
||||
// Test endpoints without auth
|
||||
endpoints := []struct {
|
||||
@@ -207,7 +213,7 @@ func createRouter() *gin.Engine {
|
||||
return router
|
||||
}
|
||||
|
||||
func createTestStorage(userID uuid.UUID) *Storage {
|
||||
func createNewStorage(userID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
UserID: userID,
|
||||
Type: StorageTypeLocal,
|
||||
|
||||
@@ -20,12 +20,9 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
type S3Container struct {
|
||||
testcontainers.Container
|
||||
endpoint string
|
||||
accessKey string
|
||||
secretKey string
|
||||
@@ -38,14 +35,9 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
|
||||
validateEnvVariables(t)
|
||||
|
||||
// Setup S3 container
|
||||
// Setup S3 connection to docker-compose MinIO
|
||||
s3Container, err := setupS3Container(ctx)
|
||||
require.NoError(t, err, "Failed to setup S3 container")
|
||||
defer func() {
|
||||
if err := s3Container.Terminate(ctx); err != nil {
|
||||
t.Logf("Failed to terminate S3 container: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup test file
|
||||
testFilePath, err := setupTestFile()
|
||||
@@ -69,7 +61,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
S3Region: s3Container.region,
|
||||
S3AccessKey: s3Container.accessKey,
|
||||
S3SecretKey: s3Container.secretKey,
|
||||
S3Endpoint: "http://" + s3Container.endpoint, // Use http:// explicitly for testing
|
||||
S3Endpoint: "http://" + s3Container.endpoint,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -155,50 +147,18 @@ func setupTestFile() (string, error) {
|
||||
return testFilePath, nil
|
||||
}
|
||||
|
||||
// setupS3Container creates and starts a MinIO container for testing
|
||||
// setupS3Container connects to the docker-compose MinIO service
|
||||
func setupS3Container(ctx context.Context) (*S3Container, error) {
|
||||
accessKey := "minioadmin"
|
||||
secretKey := "minioadmin"
|
||||
env := config.GetEnv()
|
||||
|
||||
accessKey := "testuser"
|
||||
secretKey := "testpassword"
|
||||
bucketName := "test-bucket"
|
||||
region := "us-east-1"
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "minio/minio:latest",
|
||||
ExposedPorts: []string{"9000/tcp", "9001/tcp"},
|
||||
Env: map[string]string{
|
||||
"MINIO_ACCESS_KEY": accessKey,
|
||||
"MINIO_SECRET_KEY": secretKey,
|
||||
},
|
||||
Cmd: []string{"server", "/data"},
|
||||
WaitingFor: wait.ForAll(
|
||||
wait.ForLog("MinIO Object Storage Server"),
|
||||
wait.ForListeningPort("9000/tcp"),
|
||||
),
|
||||
AutoRemove: true,
|
||||
}
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start minio container: %w", err)
|
||||
}
|
||||
|
||||
mappedHost, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get host: %w", err)
|
||||
}
|
||||
|
||||
mappedPort, err := container.MappedPort(ctx, "9000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mapped port: %w", err)
|
||||
}
|
||||
|
||||
mappedEndpoint := fmt.Sprintf("%s:%s", mappedHost, mappedPort.Port())
|
||||
endpoint := fmt.Sprintf("localhost:%s", env.TestMinioPort)
|
||||
|
||||
// Create MinIO client and ensure bucket exists
|
||||
minioClient, err := minio.New(mappedEndpoint, &minio.Options{
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: false,
|
||||
Region: region,
|
||||
@@ -227,8 +187,7 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
|
||||
}
|
||||
|
||||
return &S3Container{
|
||||
Container: container,
|
||||
endpoint: mappedEndpoint,
|
||||
endpoint: endpoint,
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
bucketName: bucketName,
|
||||
@@ -241,4 +200,5 @@ func validateEnvVariables(t *testing.T) {
|
||||
assert.NotEmpty(t, env.TestGoogleDriveClientID, "TEST_GOOGLE_DRIVE_CLIENT_ID is empty")
|
||||
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
|
||||
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
|
||||
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -33,56 +34,50 @@ func (s *GoogleDriveStorage) SaveFile(
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
driveService, err := s.getDriveService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
filename := fileID.String()
|
||||
|
||||
ctx := context.Background()
|
||||
filename := fileID.String()
|
||||
// Delete any previous copy so we keep at most one object per logical file.
|
||||
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
|
||||
|
||||
// Delete any previous copy so we keep at most one object per logical file.
|
||||
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
|
||||
fileMeta := &drive.File{Name: filename}
|
||||
|
||||
fileMeta := &drive.File{Name: filename}
|
||||
_, err := driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
|
||||
}
|
||||
|
||||
_, err = driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("file uploaded to Google Drive", "name", filename)
|
||||
|
||||
return nil
|
||||
logger.Info("file uploaded to Google Drive", "name", filename)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
driveService, err := s.getDriveService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result io.ReadCloser
|
||||
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := driveService.Files.Get(fileIDGoogle).Download()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file from Google Drive: %w", err)
|
||||
}
|
||||
|
||||
resp, err := driveService.Files.Get(fileIDGoogle).Download()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download file from Google Drive: %w", err)
|
||||
}
|
||||
result = resp.Body
|
||||
return nil
|
||||
})
|
||||
|
||||
return resp.Body, nil
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
driveService, err := s.getDriveService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return s.deleteByName(ctx, driveService, fileID.String())
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
return s.deleteByName(ctx, driveService, fileID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) Validate() error {
|
||||
@@ -95,66 +90,233 @@ func (s *GoogleDriveStorage) Validate() error {
|
||||
return errors.New("token JSON is required")
|
||||
}
|
||||
|
||||
// Also validate that the token JSON contains a refresh token
|
||||
var token oauth2.Token
|
||||
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
|
||||
return fmt.Errorf("invalid token JSON format: %w", err)
|
||||
}
|
||||
|
||||
if token.RefreshToken == "" {
|
||||
return errors.New("token JSON must contain a refresh token for automatic token refresh")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) TestConnection() error {
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
testFilename := "test-connection-" + uuid.New().String()
|
||||
testData := []byte("test")
|
||||
|
||||
// Test write operation
|
||||
fileMeta := &drive.File{Name: testFilename}
|
||||
file, err := driveService.Files.Create(fileMeta).
|
||||
Media(strings.NewReader(string(testData))).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write test file to Google Drive: %w", err)
|
||||
}
|
||||
|
||||
// Test read operation
|
||||
resp, err := driveService.Files.Get(file.Id).Download()
|
||||
if err != nil {
|
||||
// Clean up test file before returning error
|
||||
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
|
||||
return fmt.Errorf("failed to read test file from Google Drive: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("failed to close response body: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
readData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
// Clean up test file before returning error
|
||||
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
|
||||
return fmt.Errorf("failed to read test file data: %w", err)
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
if err := driveService.Files.Delete(file.Id).Context(ctx).Do(); err != nil {
|
||||
return fmt.Errorf("failed to clean up test file: %w", err)
|
||||
}
|
||||
|
||||
// Verify data matches
|
||||
if string(readData) != string(testData) {
|
||||
return fmt.Errorf(
|
||||
"test file data mismatch: expected %q, got %q",
|
||||
string(testData),
|
||||
string(readData),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// withRetryOnAuth executes the provided function with retry logic for authentication errors
|
||||
func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) error {
|
||||
driveService, err := s.getDriveService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
testFilename := "test-connection-" + uuid.New().String()
|
||||
testData := []byte("test")
|
||||
err = fn(driveService)
|
||||
if err != nil && s.isAuthError(err) {
|
||||
// Try to refresh token and retry once
|
||||
fmt.Printf("Google Drive auth error detected, attempting token refresh: %v\n", err)
|
||||
|
||||
// Test write operation
|
||||
fileMeta := &drive.File{Name: testFilename}
|
||||
file, err := driveService.Files.Create(fileMeta).
|
||||
Media(strings.NewReader(string(testData))).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write test file to Google Drive: %w", err)
|
||||
}
|
||||
if refreshErr := s.refreshToken(); refreshErr != nil {
|
||||
// If refresh fails, return a more helpful error message
|
||||
if strings.Contains(refreshErr.Error(), "invalid_grant") ||
|
||||
strings.Contains(refreshErr.Error(), "refresh token") {
|
||||
return fmt.Errorf(
|
||||
"google drive refresh token has expired. Please re-authenticate and update your token configuration. Original error: %w. Refresh error: %v",
|
||||
err,
|
||||
refreshErr,
|
||||
)
|
||||
}
|
||||
|
||||
// Test read operation
|
||||
resp, err := driveService.Files.Get(file.Id).Download()
|
||||
if err != nil {
|
||||
// Clean up test file before returning error
|
||||
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
|
||||
return fmt.Errorf("failed to read test file from Google Drive: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("failed to close response body: %v\n", err)
|
||||
return fmt.Errorf("failed to refresh token after auth error: %w", refreshErr)
|
||||
}
|
||||
}()
|
||||
|
||||
readData, err := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Token refresh successful, retrying operation\n")
|
||||
|
||||
// Get new service with refreshed token
|
||||
driveService, err = s.getDriveService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create service after token refresh: %w", err)
|
||||
}
|
||||
|
||||
// Retry the operation
|
||||
err = fn(driveService)
|
||||
if err != nil {
|
||||
fmt.Printf("Retry after token refresh also failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Operation succeeded after token refresh\n")
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// isAuthError checks if the error is a 401 authentication error
|
||||
func (s *GoogleDriveStorage) isAuthError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "401") ||
|
||||
strings.Contains(errStr, "Invalid Credentials") ||
|
||||
strings.Contains(errStr, "authError") ||
|
||||
strings.Contains(errStr, "invalid authentication credentials")
|
||||
}
|
||||
|
||||
// refreshToken refreshes the OAuth2 token and updates the TokenJSON field
|
||||
func (s *GoogleDriveStorage) refreshToken() error {
|
||||
if err := s.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
|
||||
return fmt.Errorf("invalid token JSON: %w", err)
|
||||
}
|
||||
|
||||
// Check if we have a refresh token
|
||||
if token.RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available in stored token")
|
||||
}
|
||||
|
||||
fmt.Printf("Original token - Access Token: %s..., Refresh Token: %s..., Expiry: %v\n",
|
||||
truncateString(token.AccessToken, 20),
|
||||
truncateString(token.RefreshToken, 20),
|
||||
token.Expiry)
|
||||
|
||||
// Debug: Print the full token JSON structure (sensitive data masked)
|
||||
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(s.TokenJSON))
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
|
||||
}
|
||||
|
||||
// Force the token to be expired so refresh is guaranteed
|
||||
token.Expiry = time.Now().Add(-time.Hour)
|
||||
fmt.Printf("Forcing token expiry to trigger refresh: %v\n", token.Expiry)
|
||||
|
||||
tokenSource := cfg.TokenSource(ctx, &token)
|
||||
|
||||
// Force token refresh
|
||||
fmt.Printf("Attempting to refresh Google Drive token...\n")
|
||||
newToken, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
// Clean up test file before returning error
|
||||
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
|
||||
return fmt.Errorf("failed to read test file data: %w", err)
|
||||
return fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
if err := driveService.Files.Delete(file.Id).Context(ctx).Do(); err != nil {
|
||||
return fmt.Errorf("failed to clean up test file: %w", err)
|
||||
}
|
||||
fmt.Printf("New token - Access Token: %s..., Refresh Token: %s..., Expiry: %v\n",
|
||||
truncateString(newToken.AccessToken, 20),
|
||||
truncateString(newToken.RefreshToken, 20),
|
||||
newToken.Expiry)
|
||||
|
||||
// Verify data matches
|
||||
if string(readData) != string(testData) {
|
||||
// Check if we actually got a new token
|
||||
if newToken.AccessToken == token.AccessToken {
|
||||
return fmt.Errorf(
|
||||
"test file data mismatch: expected %q, got %q",
|
||||
string(testData),
|
||||
string(readData),
|
||||
"token refresh did not return a new access token - this indicates the refresh token may be invalid",
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure the new token has a refresh token (preserve the original if not returned)
|
||||
if newToken.RefreshToken == "" {
|
||||
fmt.Printf("New token doesn't have refresh token, preserving original\n")
|
||||
newToken.RefreshToken = token.RefreshToken
|
||||
}
|
||||
|
||||
// Update the stored token JSON
|
||||
newTokenJSON, err := json.Marshal(newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refreshed token: %w", err)
|
||||
}
|
||||
|
||||
s.TokenJSON = string(newTokenJSON)
|
||||
fmt.Printf("Token refresh completed successfully with new access token\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskSensitiveData masks sensitive information in token JSON for logging
|
||||
func maskSensitiveData(tokenJSON string) string {
|
||||
// Replace sensitive values with masked versions
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tokenJSON), &data); err != nil {
|
||||
return "invalid JSON"
|
||||
}
|
||||
|
||||
if accessToken, ok := data["access_token"].(string); ok && len(accessToken) > 10 {
|
||||
data["access_token"] = accessToken[:10] + "..."
|
||||
}
|
||||
if refreshToken, ok := data["refresh_token"].(string); ok && len(refreshToken) > 10 {
|
||||
data["refresh_token"] = refreshToken[:10] + "..."
|
||||
}
|
||||
|
||||
masked, _ := json.Marshal(data)
|
||||
return string(masked)
|
||||
}
|
||||
|
||||
// truncateString safely truncates a string for logging purposes
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -176,13 +338,16 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
|
||||
|
||||
tokenSource := cfg.TokenSource(ctx, &token)
|
||||
|
||||
// Try to get a fresh token
|
||||
_, err := tokenSource.Token()
|
||||
// Force token validation to ensure we're using the current token
|
||||
currentToken, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get fresh token: %w", err)
|
||||
return nil, fmt.Errorf("failed to get current token: %w", err)
|
||||
}
|
||||
|
||||
driveService, err := drive.NewService(ctx, option.WithTokenSource(tokenSource))
|
||||
// Create a new token source with the validated token
|
||||
validatedTokenSource := oauth2.StaticTokenSource(currentToken)
|
||||
|
||||
driveService, err := drive.NewService(ctx, option.WithTokenSource(validatedTokenSource))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create Drive client: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,15 @@ func CreateTestStorage(userID uuid.UUID) *Storage {
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
func RemoveTestStorage(id uuid.UUID) {
|
||||
storage, err := storageRepository.FindByID(id)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = storageRepository.Delete(storage)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package healthcheck
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -11,17 +11,17 @@ type HealthcheckController struct {
|
||||
}
|
||||
|
||||
func (c *HealthcheckController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/health", c.CheckHealth)
|
||||
router.GET("/system/health", c.CheckHealth)
|
||||
}
|
||||
|
||||
// CheckHealth
|
||||
// @Summary Check system health
|
||||
// @Description Check if the system is healthy by testing database connection
|
||||
// @Tags healthcheck
|
||||
// @Tags system/health
|
||||
// @Produce json
|
||||
// @Success 200 {object} HealthcheckResponse
|
||||
// @Failure 503 {object} HealthcheckResponse
|
||||
// @Router /health [get]
|
||||
// @Router /system/health [get]
|
||||
func (c *HealthcheckController) CheckHealth(ctx *gin.Context) {
|
||||
err := c.healthcheckService.IsHealthy()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package healthcheck
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups"
|
||||
@@ -1,4 +1,4 @@
|
||||
package healthcheck
|
||||
package system_healthcheck
|
||||
|
||||
type HealthcheckResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -1,4 +1,4 @@
|
||||
package healthcheck
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,7 +1,6 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,16 +18,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const createAndFillTableQuery = `
|
||||
DROP TABLE IF EXISTS test_data;
|
||||
|
||||
CREATE TABLE test_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -43,14 +41,13 @@ INSERT INTO test_data (name, value) VALUES
|
||||
`
|
||||
|
||||
type PostgresContainer struct {
|
||||
Container testcontainers.Container
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
Version string
|
||||
DB *sqlx.DB
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
Version string
|
||||
DB *sqlx.DB
|
||||
}
|
||||
|
||||
type TestDataItem struct {
|
||||
@@ -62,39 +59,37 @@ type TestDataItem struct {
|
||||
|
||||
// Main test functions for each PostgreSQL version
|
||||
func Test_BackupAndRestorePostgresql_RestoreIsSuccesful(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
cases := []struct {
|
||||
name string
|
||||
version string
|
||||
port string
|
||||
}{
|
||||
{"PostgreSQL 13", "13"},
|
||||
{"PostgreSQL 14", "14"},
|
||||
{"PostgreSQL 15", "15"},
|
||||
{"PostgreSQL 16", "16"},
|
||||
{"PostgreSQL 17", "17"},
|
||||
{"PostgreSQL 13", "13", env.TestPostgres13Port},
|
||||
{"PostgreSQL 14", "14", env.TestPostgres14Port},
|
||||
{"PostgreSQL 15", "15", env.TestPostgres15Port},
|
||||
{"PostgreSQL 16", "16", env.TestPostgres16Port},
|
||||
{"PostgreSQL 17", "17", env.TestPostgres17Port},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc // capture loop variable
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testBackupRestoreForVersion(t, tc.version)
|
||||
t.Parallel() // Enable parallel execution
|
||||
testBackupRestoreForVersion(t, tc.version, tc.port)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Run a test for a specific PostgreSQL version
|
||||
func testBackupRestoreForVersion(t *testing.T, pgVersion string) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Start PostgreSQL container
|
||||
container, err := startPostgresContainer(ctx, pgVersion)
|
||||
func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
// Connect to pre-configured PostgreSQL container
|
||||
container, err := connectToPostgresContainer(pgVersion, port)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
if container.DB != nil {
|
||||
container.DB.Close()
|
||||
}
|
||||
|
||||
if container.Container != nil {
|
||||
container.Container.Terminate(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = container.DB.Exec(createAndFillTableQuery)
|
||||
@@ -139,6 +134,9 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string) {
|
||||
|
||||
// Create new database
|
||||
newDBName := "restoreddb"
|
||||
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -219,51 +217,19 @@ func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB)
|
||||
}
|
||||
}
|
||||
|
||||
func startPostgresContainer(ctx context.Context, version string) (*PostgresContainer, error) {
|
||||
func connectToPostgresContainer(version string, port string) (*PostgresContainer, error) {
|
||||
dbName := "testdb"
|
||||
password := "postgres"
|
||||
username := "postgres"
|
||||
port := "5432/tcp"
|
||||
password := "testpassword"
|
||||
username := "testuser"
|
||||
host := "localhost"
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: fmt.Sprintf("postgres:%s", version),
|
||||
ExposedPorts: []string{port},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_PASSWORD": password,
|
||||
"POSTGRES_USER": username,
|
||||
"POSTGRES_DB": dbName,
|
||||
},
|
||||
WaitingFor: wait.ForAll(
|
||||
wait.ForLog("database system is ready to accept connections"),
|
||||
wait.ForListeningPort(nat.Port(port)),
|
||||
),
|
||||
}
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mappedHost, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mappedPort, err := container.MappedPort(ctx, nat.Port(port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portInt, err := strconv.Atoi(mappedPort.Port())
|
||||
portInt, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse port: %w", err)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
mappedHost, portInt, username, password, dbName)
|
||||
host, portInt, username, password, dbName)
|
||||
|
||||
db, err := sqlx.Connect("postgres", dsn)
|
||||
if err != nil {
|
||||
@@ -271,13 +237,12 @@ func startPostgresContainer(ctx context.Context, version string) (*PostgresConta
|
||||
}
|
||||
|
||||
return &PostgresContainer{
|
||||
Container: container,
|
||||
Host: mappedHost,
|
||||
Port: portInt,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: dbName,
|
||||
Version: version,
|
||||
DB: db,
|
||||
Host: host,
|
||||
Port: portInt,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: dbName,
|
||||
Version: version,
|
||||
DB: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
55
backend/migrations/20250704095930_add_healthckeck.sql
Normal file
55
backend/migrations/20250704095930_add_healthckeck.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Add healthcheck columns to databases table
|
||||
ALTER TABLE databases
|
||||
ADD COLUMN health_status TEXT DEFAULT 'AVAILABLE';
|
||||
|
||||
-- Create healthcheck configs table
|
||||
CREATE TABLE healthcheck_configs (
|
||||
database_id UUID PRIMARY KEY,
|
||||
is_healthcheck_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_sent_notification_when_unavailable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_minutes INT NOT NULL,
|
||||
attempts_before_considered_as_down INT NOT NULL,
|
||||
store_attempts_days INT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE healthcheck_configs
|
||||
ADD CONSTRAINT fk_healthcheck_configs_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX idx_healthcheck_configs_database_id ON healthcheck_configs (database_id);
|
||||
|
||||
-- Create healthcheck attempts table
|
||||
CREATE TABLE healthcheck_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
database_id UUID NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE healthcheck_attempts
|
||||
ADD CONSTRAINT fk_healthcheck_attempts_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX idx_healthcheck_attempts_database_id_created_at ON healthcheck_attempts (database_id, created_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_healthcheck_attempts_database_id_created_at;
|
||||
DROP INDEX IF EXISTS idx_healthcheck_configs_database_id;
|
||||
|
||||
DROP TABLE IF EXISTS healthcheck_attempts;
|
||||
DROP TABLE IF EXISTS healthcheck_configs;
|
||||
|
||||
ALTER TABLE databases
|
||||
DROP COLUMN health_status;
|
||||
-- +goose StatementEnd
|
||||
29
frontend/.cursor/rules/react-component-structure.mdc
Normal file
29
frontend/.cursor/rules/react-component-structure.mdc
Normal file
@@ -0,0 +1,29 @@
|
||||
Write ReactComponent with the following structure:
|
||||
|
||||
interface Props {
|
||||
someValue: SomeValue;
|
||||
}
|
||||
|
||||
const someHelperFunction = () => {
|
||||
...
|
||||
}
|
||||
|
||||
export const ReactComponent = ({ someValue }: Props): JSX.Element => {
|
||||
// first put states
|
||||
const [someState, setSomeState] = useState<...>(...)
|
||||
|
||||
// then place functions
|
||||
const loadSomeData = async () => {
|
||||
...
|
||||
}
|
||||
|
||||
// then hooks
|
||||
useEffect(() => {
|
||||
loadSomeData();
|
||||
});
|
||||
|
||||
// then calculated values
|
||||
const calculatedValue = someValue.calculate();
|
||||
|
||||
return <div> ... </div>
|
||||
}
|
||||
@@ -11,4 +11,4 @@ export function getApplicationServer() {
|
||||
}
|
||||
}
|
||||
|
||||
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth';
|
||||
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Interval } from '../../intervals';
|
||||
import type { Notifier } from '../../notifiers';
|
||||
import type { BackupNotificationType } from './BackupNotificationType';
|
||||
import type { DatabaseType } from './DatabaseType';
|
||||
import type { HealthStatus } from './HealthStatus';
|
||||
import type { Period } from './Period';
|
||||
import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase';
|
||||
|
||||
@@ -23,4 +24,6 @@ export interface Database {
|
||||
|
||||
lastBackupTime?: Date;
|
||||
lastBackupErrorMessage?: string;
|
||||
|
||||
healthStatus?: HealthStatus;
|
||||
}
|
||||
|
||||
4
frontend/src/entity/databases/model/HealthStatus.ts
Normal file
4
frontend/src/entity/databases/model/HealthStatus.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum HealthStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
UNAVAILABLE = 'UNAVAILABLE',
|
||||
}
|
||||
15
frontend/src/entity/healthcheck/api/healthcheckAttemptApi.ts
Normal file
15
frontend/src/entity/healthcheck/api/healthcheckAttemptApi.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { HealthcheckAttempt } from '../model/HealthckeckAttempts';
|
||||
|
||||
export const healthcheckAttemptApi = {
|
||||
async getAttemptsByDatabase(databaseId: string, afterDate: Date) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('afterDate', afterDate.toISOString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${getApplicationServer()}/api/v1/healthcheck-attempts/${databaseId}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiHelper.fetchGetJson<HealthcheckAttempt[]>(url, undefined, true);
|
||||
},
|
||||
};
|
||||
24
frontend/src/entity/healthcheck/api/healthcheckConfigApi.ts
Normal file
24
frontend/src/entity/healthcheck/api/healthcheckConfigApi.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import RequestOptions from '../../../shared/api/RequestOptions';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { HealthcheckConfig } from '../model/HealthcheckConfig';
|
||||
|
||||
export const healthcheckConfigApi = {
|
||||
async saveHealthcheckConfig(config: HealthcheckConfig) {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(config));
|
||||
|
||||
return apiHelper.fetchPostJson<{ message: string }>(
|
||||
`${getApplicationServer()}/api/v1/healthcheck-config`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async getHealthcheckConfig(databaseId: string) {
|
||||
return apiHelper.fetchGetJson<HealthcheckConfig>(
|
||||
`${getApplicationServer()}/api/v1/healthcheck-config/${databaseId}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
4
frontend/src/entity/healthcheck/index.ts
Normal file
4
frontend/src/entity/healthcheck/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { healthcheckConfigApi } from './api/healthcheckConfigApi';
|
||||
export { healthcheckAttemptApi } from './api/healthcheckAttemptApi';
|
||||
export type { HealthcheckConfig } from './model/HealthcheckConfig';
|
||||
export type { HealthcheckAttempt } from './model/HealthckeckAttempts';
|
||||
10
frontend/src/entity/healthcheck/model/HealthcheckConfig.ts
Normal file
10
frontend/src/entity/healthcheck/model/HealthcheckConfig.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface HealthcheckConfig {
|
||||
databaseId: string;
|
||||
|
||||
isHealthcheckEnabled: boolean;
|
||||
isSentNotificationWhenUnavailable: boolean;
|
||||
|
||||
intervalMinutes: number;
|
||||
attemptsBeforeConcideredAsDown: number;
|
||||
storeAttemptsDays: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { HealthStatus } from '../../databases/model/HealthStatus';
|
||||
|
||||
export interface HealthcheckAttempt {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
status: HealthStatus;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NotifierType } from './NotifierType';
|
||||
import type { SlackNotifier } from './slack/SlackNotifier';
|
||||
import type { EmailNotifier } from './email/EmailNotifier';
|
||||
import type { SlackNotifier } from './slack/SlackNotifier';
|
||||
import type { TelegramNotifier } from './telegram/TelegramNotifier';
|
||||
import type { WebhookNotifier } from './webhook/WebhookNotifier';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { type Database, DatabaseType } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import { getStorageLogoFromType } from '../../../entity/storages';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
|
||||
|
||||
@@ -29,7 +30,21 @@ export const DatabaseCardComponent = ({
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
onClick={() => setSelectedDatabaseId(database.id)}
|
||||
>
|
||||
<div className="mb-1 font-bold">{database.name}</div>
|
||||
<div className="flex">
|
||||
<div className="mb-1 font-bold">{database.name}</div>
|
||||
|
||||
{database.healthStatus && (
|
||||
<div className="ml-auto pl-1">
|
||||
<div
|
||||
className={`rounded p-1 px-2 text-xs text-white ${
|
||||
database.healthStatus === HealthStatus.AVAILABLE ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{database.healthStatus === HealthStatus.AVAILABLE ? 'Available' : 'Unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb flex items-center">
|
||||
<div className="text-sm text-gray-500">Database type: {databaseType}</div>
|
||||
|
||||
@@ -7,6 +7,11 @@ import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { BackupsComponent } from '../../backups';
|
||||
import {
|
||||
EditHealthcheckConfigComponent,
|
||||
HealthckeckAttemptsComponent,
|
||||
ShowHealthcheckConfigComponent,
|
||||
} from '../../healthcheck';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
@@ -37,6 +42,7 @@ export const DatabaseComponent = ({
|
||||
useState(false);
|
||||
const [isEditStorageSettings, setIsEditStorageSettings] = useState(false);
|
||||
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
|
||||
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
|
||||
|
||||
const [editDatabase, setEditDatabase] = useState<Database | undefined>();
|
||||
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
|
||||
@@ -89,13 +95,16 @@ export const DatabaseComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers') => {
|
||||
const startEdit = (
|
||||
type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers' | 'healthcheck',
|
||||
) => {
|
||||
setEditDatabase(JSON.parse(JSON.stringify(database)));
|
||||
setIsEditName(type === 'name');
|
||||
setIsEditBaseSettings(type === 'settings');
|
||||
setIsEditDatabaseSpecificDataSettings(type === 'database');
|
||||
setIsEditStorageSettings(type === 'storage');
|
||||
setIsEditNotifiersSettings(type === 'notifiers');
|
||||
setIsEditHealthcheckSettings(type === 'healthcheck');
|
||||
setIsNameUnsaved(false);
|
||||
};
|
||||
|
||||
@@ -364,6 +373,39 @@ export const DatabaseComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
{!isEditHealthcheckSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('healthcheck')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditHealthcheckSettings ? (
|
||||
<EditHealthcheckConfigComponent
|
||||
databaseId={database.id}
|
||||
onClose={() => {
|
||||
setIsEditHealthcheckSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowHealthcheckConfigComponent databaseId={database.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings && (
|
||||
<div className="mt-10">
|
||||
<Button
|
||||
@@ -403,6 +445,10 @@ export const DatabaseComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
{database && <HealthckeckAttemptsComponent database={database} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
{database && <BackupsComponent database={database} />}
|
||||
</div>
|
||||
|
||||
3
frontend/src/features/healthcheck/index.ts
Normal file
3
frontend/src/features/healthcheck/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { EditHealthcheckConfigComponent } from './ui/EditHealthcheckConfigComponent';
|
||||
export { ShowHealthcheckConfigComponent } from './ui/ShowHealthcheckConfigComponent';
|
||||
export { HealthckeckAttemptsComponent } from './ui/HealthckeckAttemptsComponent';
|
||||
@@ -0,0 +1,212 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Spin, Switch, Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { healthcheckConfigApi } from '../../../entity/healthcheck';
|
||||
import type { HealthcheckConfig } from '../../../entity/healthcheck';
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditHealthcheckConfigComponent = ({ databaseId, onClose }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUnsaved, setIsUnsaved] = useState(false);
|
||||
const [healthcheckConfig, setHealthcheckConfig] = useState<HealthcheckConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!healthcheckConfig) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await healthcheckConfigApi.saveHealthcheckConfig(healthcheckConfig);
|
||||
setIsUnsaved(false);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
healthcheckConfigApi
|
||||
.getHealthcheckConfig(databaseId)
|
||||
.then((config) => {
|
||||
setHealthcheckConfig(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [databaseId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spin size="small" />;
|
||||
}
|
||||
|
||||
if (!healthcheckConfig) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Enable healthcheck</div>
|
||||
<Switch
|
||||
checked={healthcheckConfig.isHealthcheckEnabled}
|
||||
onChange={(checked) => {
|
||||
setHealthcheckConfig({
|
||||
...healthcheckConfig,
|
||||
isHealthcheckEnabled: checked,
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable or disable healthcheck monitoring for this database"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{healthcheckConfig.isHealthcheckEnabled && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Notify when unavailable</div>
|
||||
|
||||
<Switch
|
||||
checked={healthcheckConfig.isSentNotificationWhenUnavailable}
|
||||
onChange={(checked) => {
|
||||
setHealthcheckConfig({
|
||||
...healthcheckConfig,
|
||||
isSentNotificationWhenUnavailable: checked,
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Send notifications when database becomes unavailable"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Check interval (minutes)</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={healthcheckConfig.intervalMinutes}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (value > 0) {
|
||||
setHealthcheckConfig({
|
||||
...healthcheckConfig,
|
||||
intervalMinutes: value,
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="5"
|
||||
min={1}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How often to check database health (in minutes)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Attempts before down</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={healthcheckConfig.attemptsBeforeConcideredAsDown}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (value > 0) {
|
||||
setHealthcheckConfig({
|
||||
...healthcheckConfig,
|
||||
attemptsBeforeConcideredAsDown: value,
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="3"
|
||||
min={1}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Number of failed attempts before marking database as down"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Store attempts (days)</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={healthcheckConfig.storeAttemptsDays}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (value > 0) {
|
||||
setHealthcheckConfig({
|
||||
...healthcheckConfig,
|
||||
storeAttemptsDays: value,
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="30"
|
||||
min={1}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How many days to store healthcheck attempt history"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-2">
|
||||
<Button onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="primary" onClick={handleSave} loading={isSaving} disabled={!isUnsaved}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Select, Spin, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import { type HealthcheckAttempt, healthcheckAttemptApi } from '../../../entity/healthcheck';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
}
|
||||
|
||||
let lastLoadTime = 0;
|
||||
|
||||
const getAfterDateByPeriod = (period: 'today' | '7d' | '30d' | 'all'): Date => {
|
||||
const afterDate = new Date();
|
||||
|
||||
if (period === 'today') {
|
||||
return new Date(afterDate.setDate(afterDate.getDate() - 1));
|
||||
}
|
||||
|
||||
if (period === '7d') {
|
||||
return new Date(afterDate.setDate(afterDate.getDate() - 7));
|
||||
}
|
||||
|
||||
if (period === '30d') {
|
||||
return new Date(afterDate.setDate(afterDate.getDate() - 30));
|
||||
}
|
||||
|
||||
if (period === 'all') {
|
||||
return new Date(0);
|
||||
}
|
||||
|
||||
return afterDate;
|
||||
};
|
||||
|
||||
export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [healthcheckAttempts, setHealthcheckAttempts] = useState<HealthcheckAttempt[]>([]);
|
||||
const [period, setPeriod] = useState<'today' | '7d' | '30d' | 'all'>('today');
|
||||
|
||||
const loadHealthcheckAttempts = async (isShowLoading = true) => {
|
||||
if (isShowLoading) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentTime = Date.now();
|
||||
lastLoadTime = currentTime;
|
||||
|
||||
const afterDate = getAfterDateByPeriod(period);
|
||||
|
||||
const healthcheckAttempts = await healthcheckAttemptApi.getAttemptsByDatabase(
|
||||
database.id,
|
||||
afterDate,
|
||||
);
|
||||
|
||||
if (currentTime != lastLoadTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHealthcheckAttempts(healthcheckAttempts);
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
if (isShowLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHealthcheckAttempts();
|
||||
}, [database, period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (period === 'today') {
|
||||
const interval = setInterval(() => {
|
||||
loadHealthcheckAttempts(false);
|
||||
}, 60_000); // 1 minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="mr-2 text-sm font-medium">Period</span>
|
||||
<Select
|
||||
size="small"
|
||||
value={period}
|
||||
onChange={(value) => setPeriod(value)}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-w-[750px] flex-wrap gap-1">
|
||||
{healthcheckAttempts.length > 0 ? (
|
||||
healthcheckAttempts.map((healthcheckAttempt) => (
|
||||
<Tooltip
|
||||
key={healthcheckAttempt.createdAt.toString()}
|
||||
title={`${dayjs(healthcheckAttempt.createdAt).format('DD.MM.YYYY HH:mm')} (${dayjs(healthcheckAttempt.createdAt).fromNow()})`}
|
||||
>
|
||||
<div
|
||||
className={`h-[8px] w-[8px] cursor-pointer rounded-[2px] ${
|
||||
healthcheckAttempt.status === HealthStatus.AVAILABLE
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">No data</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { healthcheckConfigApi } from '../../../entity/healthcheck';
|
||||
import type { HealthcheckConfig } from '../../../entity/healthcheck';
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
}
|
||||
|
||||
export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [healthcheckConfig, setHealthcheckConfig] = useState<HealthcheckConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
healthcheckConfigApi
|
||||
.getHealthcheckConfig(databaseId)
|
||||
.then((config) => {
|
||||
setHealthcheckConfig(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [databaseId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spin size="small" />;
|
||||
}
|
||||
|
||||
if (!healthcheckConfig) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Is healthcheck enabled</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
|
||||
{healthcheckConfig.isHealthcheckEnabled && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Notify when unavailable</div>
|
||||
<div className="w-[250px]">
|
||||
{healthcheckConfig.isSentNotificationWhenUnavailable ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Check interval (minutes)</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.intervalMinutes}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Attempts before down</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Store attempts (days)</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.storeAttemptsDays}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export function AuthNavbarComponent() {
|
||||
Community
|
||||
</a>
|
||||
|
||||
<div className='mt-1'>
|
||||
<div className="mt-1">
|
||||
<GitHubButton
|
||||
href="https://github.com/RostislavDugin/postgresus"
|
||||
data-icon="octicon-star"
|
||||
|
||||
@@ -56,7 +56,7 @@ export const MainScreenComponent = () => {
|
||||
<div className="mr-3 ml-auto flex items-center gap-5">
|
||||
<a
|
||||
className="hover:opacity-80"
|
||||
href={`${getApplicationServer()}/api/v1/health`}
|
||||
href={`${getApplicationServer()}/api/v1/system/health`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user