diff --git a/README.md b/README.md index 0f0b30e..5a7ee49 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
Postgresus Logo -

PostgreSQL backup and restore tool

-

Free, open source and self-hosted solution for automated PostgreSQL backups with multiple storage options and notifications

+

PostgreSQL monitoring and backup

+

Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications

Features • diff --git a/backend/.env.development.example b/backend/.env.development.example index 65a6547..3734a19 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index eea65df..38aede9 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -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()) { diff --git a/backend/docker-compose.yml.example b/backend/docker-compose.yml.example index cc0f9c3..db92572 100644 --- a/backend/docker-compose.yml.example +++ b/backend/docker-compose.yml.example @@ -17,4 +17,72 @@ services: - ./pgdata:/var/lib/postgresql/data container_name: dev-db command: -p 5437 - shm_size: 10gb \ No newline at end of file + 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 diff --git a/backend/go.mod b/backend/go.mod index 3494816..ee6bb3e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index a6fb4c6..ca231f9 100644 --- a/backend/go.sum +++ b/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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6d69971..75676d2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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!") } diff --git a/backend/internal/features/backups/service_test.go b/backend/internal/features/backups/service_test.go index 4b8f13c..897e7a9 100644 --- a/backend/internal/features/backups/service_test.go +++ b/backend/internal/features/backups/service_test.go @@ -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{ diff --git a/backend/internal/features/databases/databases/postgresql/model.go b/backend/internal/features/databases/databases/postgresql/model.go index 77510f0..3be61cb 100644 --- a/backend/internal/features/databases/databases/postgresql/model.go +++ b/backend/internal/features/databases/databases/postgresql/model.go @@ -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 diff --git a/backend/internal/features/databases/di.go b/backend/internal/features/databases/di.go index 7b31e07..38ba4ec 100644 --- a/backend/internal/features/databases/di.go +++ b/backend/internal/features/databases/di.go @@ -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{ diff --git a/backend/internal/features/databases/dto.go b/backend/internal/features/databases/dto.go deleted file mode 100644 index 18cbec7..0000000 --- a/backend/internal/features/databases/dto.go +++ /dev/null @@ -1 +0,0 @@ -package databases diff --git a/backend/internal/features/databases/enums.go b/backend/internal/features/databases/enums.go index 60d5c3b..dafe700 100644 --- a/backend/internal/features/databases/enums.go +++ b/backend/internal/features/databases/enums.go @@ -60,3 +60,10 @@ const ( NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED" NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS" ) + +type HealthStatus string + +const ( + HealthStatusAvailable HealthStatus = "AVAILABLE" + HealthStatusUnavailable HealthStatus = "UNAVAILABLE" +) diff --git a/backend/internal/features/databases/interfaces.go b/backend/internal/features/databases/interfaces.go index fcb9e3e..d8e92c0 100644 --- a/backend/internal/features/databases/interfaces.go +++ b/backend/internal/features/databases/interfaces.go @@ -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) +} diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go index d22b63d..2c3297d 100644 --- a/backend/internal/features/databases/model.go +++ b/backend/internal/features/databases/model.go @@ -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 = "" diff --git a/backend/internal/features/databases/repository.go b/backend/internal/features/databases/repository.go index a934898..2cef290 100644 --- a/backend/internal/features/databases/repository.go +++ b/backend/internal/features/databases/repository.go @@ -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 } diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index b0a98cb..8d42226 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -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 +} diff --git a/backend/internal/features/databases/testing.go b/backend/internal/features/databases/testing.go index 65204e3..4401694 100644 --- a/backend/internal/features/databases/testing.go +++ b/backend/internal/features/databases/testing.go @@ -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) + } +} diff --git a/backend/internal/features/healthcheck/attempt/background_service.go b/backend/internal/features/healthcheck/attempt/background_service.go new file mode 100644 index 0000000..4f53a6f --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/background_service.go @@ -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) + } +} diff --git a/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go b/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go new file mode 100644 index 0000000..95194ee --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go @@ -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, + ) + } + +} diff --git a/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go new file mode 100644 index 0000000..4e23d5a --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go @@ -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) + }, + ) +} diff --git a/backend/internal/features/healthcheck/attempt/controller.go b/backend/internal/features/healthcheck/attempt/controller.go new file mode 100644 index 0000000..3037881 --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/controller.go @@ -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) +} diff --git a/backend/internal/features/healthcheck/attempt/di.go b/backend/internal/features/healthcheck/attempt/di.go new file mode 100644 index 0000000..e434d7c --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/di.go @@ -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 +} diff --git a/backend/internal/features/healthcheck/attempt/interfaces.go b/backend/internal/features/healthcheck/attempt/interfaces.go new file mode 100644 index 0000000..43b60cd --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/interfaces.go @@ -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 +} diff --git a/backend/internal/features/healthcheck/attempt/mocks.go b/backend/internal/features/healthcheck/attempt/mocks.go new file mode 100644 index 0000000..48dd12d --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/mocks.go @@ -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) +} diff --git a/backend/internal/features/healthcheck/attempt/model.go b/backend/internal/features/healthcheck/attempt/model.go new file mode 100644 index 0000000..7afa879 --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/model.go @@ -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" +} diff --git a/backend/internal/features/healthcheck/attempt/repository.go b/backend/internal/features/healthcheck/attempt/repository.go new file mode 100644 index 0000000..105c0fe --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/repository.go @@ -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 +} diff --git a/backend/internal/features/healthcheck/attempt/service.go b/backend/internal/features/healthcheck/attempt/service.go new file mode 100644 index 0000000..6dde033 --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/service.go @@ -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, + ) +} diff --git a/backend/internal/features/healthcheck/config/controller.go b/backend/internal/features/healthcheck/config/controller.go new file mode 100644 index 0000000..3caa571 --- /dev/null +++ b/backend/internal/features/healthcheck/config/controller.go @@ -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) +} diff --git a/backend/internal/features/healthcheck/config/di.go b/backend/internal/features/healthcheck/config/di.go new file mode 100644 index 0000000..d328d4f --- /dev/null +++ b/backend/internal/features/healthcheck/config/di.go @@ -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) +} diff --git a/backend/internal/features/healthcheck/config/dto.go b/backend/internal/features/healthcheck/config/dto.go new file mode 100644 index 0000000..777e82a --- /dev/null +++ b/backend/internal/features/healthcheck/config/dto.go @@ -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, + } +} diff --git a/backend/internal/features/healthcheck/config/model.go b/backend/internal/features/healthcheck/config/model.go new file mode 100644 index 0000000..cbf0af6 --- /dev/null +++ b/backend/internal/features/healthcheck/config/model.go @@ -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 +} diff --git a/backend/internal/features/healthcheck/config/repository.go b/backend/internal/features/healthcheck/config/repository.go new file mode 100644 index 0000000..027af0f --- /dev/null +++ b/backend/internal/features/healthcheck/config/repository.go @@ -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 +} diff --git a/backend/internal/features/healthcheck/config/service.go b/backend/internal/features/healthcheck/config/service.go new file mode 100644 index 0000000..02d920f --- /dev/null +++ b/backend/internal/features/healthcheck/config/service.go @@ -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, + }) +} diff --git a/backend/internal/features/notifiers/testing.go b/backend/internal/features/notifiers/testing.go index fd9f8b7..4b68680 100644 --- a/backend/internal/features/notifiers/testing.go +++ b/backend/internal/features/notifiers/testing.go @@ -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) + } +} diff --git a/backend/internal/features/storages/controller_test.go b/backend/internal/features/storages/controller_test.go index c238b6e..40bb538 100644 --- a/backend/internal/features/storages/controller_test.go +++ b/backend/internal/features/storages/controller_test.go @@ -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, diff --git a/backend/internal/features/storages/model_test.go b/backend/internal/features/storages/model_test.go index 372eeb5..28ea95e 100644 --- a/backend/internal/features/storages/model_test.go +++ b/backend/internal/features/storages/model_test.go @@ -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") } diff --git a/backend/internal/features/storages/models/google_drive/model.go b/backend/internal/features/storages/models/google_drive/model.go index df9c171..553ae8e 100644 --- a/backend/internal/features/storages/models/google_drive/model.go +++ b/backend/internal/features/storages/models/google_drive/model.go @@ -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) } diff --git a/backend/internal/features/storages/testing.go b/backend/internal/features/storages/testing.go index 5c7747a..c40872d 100644 --- a/backend/internal/features/storages/testing.go +++ b/backend/internal/features/storages/testing.go @@ -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) + } +} diff --git a/backend/internal/features/healthcheck/controller.go b/backend/internal/features/system/healthcheck/controller.go similarity index 87% rename from backend/internal/features/healthcheck/controller.go rename to backend/internal/features/system/healthcheck/controller.go index ac58e33..43b9edf 100644 --- a/backend/internal/features/healthcheck/controller.go +++ b/backend/internal/features/system/healthcheck/controller.go @@ -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() diff --git a/backend/internal/features/healthcheck/di.go b/backend/internal/features/system/healthcheck/di.go similarity index 93% rename from backend/internal/features/healthcheck/di.go rename to backend/internal/features/system/healthcheck/di.go index bcf46b7..df93993 100644 --- a/backend/internal/features/healthcheck/di.go +++ b/backend/internal/features/system/healthcheck/di.go @@ -1,4 +1,4 @@ -package healthcheck +package system_healthcheck import ( "postgresus-backend/internal/features/backups" diff --git a/backend/internal/features/healthcheck/dto.go b/backend/internal/features/system/healthcheck/dto.go similarity index 71% rename from backend/internal/features/healthcheck/dto.go rename to backend/internal/features/system/healthcheck/dto.go index 3180f2b..e941b59 100644 --- a/backend/internal/features/healthcheck/dto.go +++ b/backend/internal/features/system/healthcheck/dto.go @@ -1,4 +1,4 @@ -package healthcheck +package system_healthcheck type HealthcheckResponse struct { Status string `json:"status"` diff --git a/backend/internal/features/healthcheck/service.go b/backend/internal/features/system/healthcheck/service.go similarity index 96% rename from backend/internal/features/healthcheck/service.go rename to backend/internal/features/system/healthcheck/service.go index 0ea43a3..87a29fd 100644 --- a/backend/internal/features/healthcheck/service.go +++ b/backend/internal/features/system/healthcheck/service.go @@ -1,4 +1,4 @@ -package healthcheck +package system_healthcheck import ( "errors" diff --git a/backend/internal/features/tests/postgresql_backup_restore_test.go b/backend/internal/features/tests/postgresql_backup_restore_test.go index f078f41..922fcaf 100644 --- a/backend/internal/features/tests/postgresql_backup_restore_test.go +++ b/backend/internal/features/tests/postgresql_backup_restore_test.go @@ -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 } diff --git a/backend/migrations/20250704095930_add_healthckeck.sql b/backend/migrations/20250704095930_add_healthckeck.sql new file mode 100644 index 0000000..502e93f --- /dev/null +++ b/backend/migrations/20250704095930_add_healthckeck.sql @@ -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 diff --git a/frontend/.cursor/rules/react-component-structure.mdc b/frontend/.cursor/rules/react-component-structure.mdc new file mode 100644 index 0000000..344152e --- /dev/null +++ b/frontend/.cursor/rules/react-component-structure.mdc @@ -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

...
+} \ No newline at end of file diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 7c5b911..f2ba306 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -11,4 +11,4 @@ export function getApplicationServer() { } } -export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth'; \ No newline at end of file +export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth'; diff --git a/frontend/src/entity/databases/model/Database.ts b/frontend/src/entity/databases/model/Database.ts index dab5b73..baa81ca 100644 --- a/frontend/src/entity/databases/model/Database.ts +++ b/frontend/src/entity/databases/model/Database.ts @@ -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; } diff --git a/frontend/src/entity/databases/model/HealthStatus.ts b/frontend/src/entity/databases/model/HealthStatus.ts new file mode 100644 index 0000000..c393b56 --- /dev/null +++ b/frontend/src/entity/databases/model/HealthStatus.ts @@ -0,0 +1,4 @@ +export enum HealthStatus { + AVAILABLE = 'AVAILABLE', + UNAVAILABLE = 'UNAVAILABLE', +} diff --git a/frontend/src/entity/healthcheck/api/healthcheckAttemptApi.ts b/frontend/src/entity/healthcheck/api/healthcheckAttemptApi.ts new file mode 100644 index 0000000..18b4dac --- /dev/null +++ b/frontend/src/entity/healthcheck/api/healthcheckAttemptApi.ts @@ -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(url, undefined, true); + }, +}; diff --git a/frontend/src/entity/healthcheck/api/healthcheckConfigApi.ts b/frontend/src/entity/healthcheck/api/healthcheckConfigApi.ts new file mode 100644 index 0000000..485fd2d --- /dev/null +++ b/frontend/src/entity/healthcheck/api/healthcheckConfigApi.ts @@ -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( + `${getApplicationServer()}/api/v1/healthcheck-config/${databaseId}`, + undefined, + true, + ); + }, +}; diff --git a/frontend/src/entity/healthcheck/index.ts b/frontend/src/entity/healthcheck/index.ts new file mode 100644 index 0000000..62d176d --- /dev/null +++ b/frontend/src/entity/healthcheck/index.ts @@ -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'; diff --git a/frontend/src/entity/healthcheck/model/HealthcheckConfig.ts b/frontend/src/entity/healthcheck/model/HealthcheckConfig.ts new file mode 100644 index 0000000..e347fa7 --- /dev/null +++ b/frontend/src/entity/healthcheck/model/HealthcheckConfig.ts @@ -0,0 +1,10 @@ +export interface HealthcheckConfig { + databaseId: string; + + isHealthcheckEnabled: boolean; + isSentNotificationWhenUnavailable: boolean; + + intervalMinutes: number; + attemptsBeforeConcideredAsDown: number; + storeAttemptsDays: number; +} diff --git a/frontend/src/entity/healthcheck/model/HealthckeckAttempts.ts b/frontend/src/entity/healthcheck/model/HealthckeckAttempts.ts new file mode 100644 index 0000000..113ae1d --- /dev/null +++ b/frontend/src/entity/healthcheck/model/HealthckeckAttempts.ts @@ -0,0 +1,8 @@ +import type { HealthStatus } from '../../databases/model/HealthStatus'; + +export interface HealthcheckAttempt { + id: string; + databaseId: string; + status: HealthStatus; + createdAt: Date; +} diff --git a/frontend/src/entity/notifiers/models/Notifier.ts b/frontend/src/entity/notifiers/models/Notifier.ts index e99f80c..ff31c1c 100644 --- a/frontend/src/entity/notifiers/models/Notifier.ts +++ b/frontend/src/entity/notifiers/models/Notifier.ts @@ -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'; diff --git a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx index 6771609..12f27f8 100644 --- a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx @@ -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)} > -
{database.name}
+
+
{database.name}
+ + {database.healthStatus && ( +
+
+ {database.healthStatus === HealthStatus.AVAILABLE ? 'Available' : 'Unavailable'} +
+
+ )} +
Database type: {databaseType}
diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx index 32cfc28..4018200 100644 --- a/frontend/src/features/databases/ui/DatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -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(); 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 = ({
+
+
+
+
Healthcheck settings
+ + {!isEditHealthcheckSettings ? ( +
startEdit('healthcheck')} + > + +
+ ) : ( +
+ )} +
+ +
+ {isEditHealthcheckSettings ? ( + { + setIsEditHealthcheckSettings(false); + loadSettings(); + }} + /> + ) : ( + + )} +
+
+
+ {!isEditDatabaseSpecificDataSettings && (
+ + +
+
+ ); +}; diff --git a/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx b/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx new file mode 100644 index 0000000..2bd238d --- /dev/null +++ b/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx @@ -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([]); + 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 ( +
+

Healthcheck attempts

+ +
+ Period +