diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9472636a8..27713cb78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,7 +306,7 @@ jobs: # so scanning the root would silently miss pkg/ and providers/*. # Walk every module independently and fail on any HIGH/CRITICAL. set -e - for mod in . pkg providers/aws providers/azure providers/gcp; do + for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do echo "==> govulncheck in $mod" (cd "$mod" && govulncheck ./...) done @@ -372,13 +372,13 @@ jobs: with: args: --severity-threshold=high - # Docker Compose E2E tests - # NOTE: Dockerfile.test and docker-compose.test.yml are not yet implemented. - # continue-on-error prevents this unimplemented job from blocking the ci-success gate. + # Docker Compose E2E tests: build the app + test-runner images, bring up + # postgres + cudly-app, then run the black-box suite in tests/e2e against + # the app over HTTP. The test-runner service is profile-gated, so every + # compose invocation needs --profile test (issue #1180). e2e-tests: name: E2E Tests runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout code @@ -389,20 +389,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build test image + - name: Build images run: | - docker build -t cudly:test -f Dockerfile.test . + docker compose -f docker-compose.test.yml --profile test build - - name: Run E2E tests with docker-compose + - name: Run E2E tests with docker compose run: | - docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from test-runner + docker compose -f docker-compose.test.yml --profile test up --abort-on-container-exit --exit-code-from test-runner env: COMPOSE_INTERACTIVE_NO_CLI: 1 - name: Cleanup if: always() run: | - docker compose -f docker-compose.test.yml down -v + docker compose -f docker-compose.test.yml --profile test down -v # Assert that the Azure custom-role actions list is identical in the TF module # and the ARM onboarding template. Fast (shell + jq only), so it always runs. diff --git a/.gitignore b/.gitignore index a16630aed..13b4ae391 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ cmd.test # Test binary, built with `go test -c` *.test +# ... but not the E2E test-runner image definition +!Dockerfile.test # Output of the go coverage tool *.out diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 000000000..2b319e850 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,20 @@ +# Test-runner image for the docker-compose E2E suite (docker-compose.test.yml, +# profile "test"). It only needs the Go toolchain and the stdlib-only module +# under tests/e2e, so the build is fast and independent of the app's +# dependency tree. +# +# Base image pinned by digest for the same supply-chain reasons as Dockerfile; +# keep the digest in sync with the builder stage there. +FROM golang:1.25.4-alpine3.21@sha256:3289aac2aac769e031d644313d094dbda745f28af81cd7a94137e73eefd58b33 + +WORKDIR /e2e + +COPY tests/e2e/ ./ + +# Pre-compile the test binary so `docker compose up` failures are runtime +# failures, not compile errors discovered after the stack is already up. +RUN go vet -tags=e2e ./... && go test -tags=e2e -run NONE ./... + +# docker-compose.test.yml overrides the command; this default keeps the image +# usable standalone. +CMD ["go", "test", "-v", "-tags=e2e", "./..."] diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3825fb67b..8043691f6 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -48,8 +48,19 @@ services: DB_AUTO_MIGRATE: "true" DB_MIGRATIONS_PATH: /app/migrations - # Secret provider (use env for testing) + # Secret provider (use env for testing). With the env provider, + # *_SECRET variables name the environment variable holding the value. SECRET_PROVIDER: env + ADMIN_EMAIL: admin@test.example + ADMIN_PASSWORD_SECRET: TEST_ADMIN_PASSWORD + TEST_ADMIN_PASSWORD: e2e-local-test-only-password + # Allow the all-zero dev encryption key; this stack never holds real + # cloud credentials. + CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY: "1" + + # Scheduled-task auth: disabled for the local/CI compose stack (no + # cloud OIDC issuer or bearer secret available) + SCHEDULED_TASK_AUTH_MODE: disabled # DynamoDB tables (for backward compatibility testing) CONFIG_TABLE: test-config @@ -59,7 +70,9 @@ services: GROUPS_TABLE: test-groups SESSIONS_TABLE: test-sessions - # Email configuration (mock) + # Email configuration: disabled, the app falls back to a no-op sender + # (SECRET_PROVIDER=env is not a supported email backend) + EMAIL_ENABLED: "false" EMAIL_ADDRESS: test@example.com # Feature flags @@ -79,7 +92,7 @@ services: retries: 3 start_period: 10s - # Test runner (optional - for running integration tests in container) + # Test runner: black-box E2E suite (tests/e2e) against cudly-app over HTTP. test-runner: build: context: . @@ -101,15 +114,9 @@ services: DB_SSL_MODE: disable networks: - cudly-test - command: > - sh -c " - echo 'Waiting for services to be ready...'; - sleep 5; - echo 'Running E2E tests...'; - go test -v -tags=integration ./...; - " + command: ["go", "test", "-v", "-tags=e2e", "./..."] profiles: - - test # Only start with: docker-compose --profile test up + - test # Only start with: docker compose --profile test up networks: cudly-test: diff --git a/go.mod b/go.mod index 660842604..de601d7dc 100644 --- a/go.mod +++ b/go.mod @@ -60,18 +60,18 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect - golang.org/x/crypto v0.49.0 - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.274.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect @@ -111,9 +111,9 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.8.0 github.com/pashagolub/pgxmock/v4 v4.9.0 - github.com/testcontainers/testcontainers-go v0.40.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 - golang.org/x/term v0.41.0 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/term v0.44.0 ) require ( @@ -128,7 +128,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v2 v2.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect @@ -149,36 +149,35 @@ require ( github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.8.4 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.6 // 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 - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // 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/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // 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.0 // indirect - github.com/morikuni/aec v1.0.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shirou/gopsutil/v4 v4.25.6 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect diff --git a/go.sum b/go.sum index 0fdc9d75f..a726f9e1a 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLB github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= @@ -187,10 +187,9 @@ github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26 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/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -205,8 +204,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= @@ -245,8 +244,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.6 h1:1ufTZkFXIQQ9EmgPjcIPIi2krfxG03lQ8OLoY1MJ3UM= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.6/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -259,8 +256,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -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/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -277,20 +274,22 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= 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/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 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.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -308,17 +307,17 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= -github.com/shirou/gopsutil/v4 v4.25.6/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/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -326,20 +325,20 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= -github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= -github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= -github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -352,10 +351,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.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= -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/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= @@ -366,29 +361,24 @@ go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9 go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -414,3 +404,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/analytics/postgres_analytics_db_test.go b/internal/analytics/postgres_analytics_db_test.go index 3ce6aba8d..c76da6ca1 100644 --- a/internal/analytics/postgres_analytics_db_test.go +++ b/internal/analytics/postgres_analytics_db_test.go @@ -335,8 +335,11 @@ func TestPostgresAnalyticsStore_QueryByProvider_DB(t *testing.T) { for _, b := range breakdowns { if b.Provider == "aws" && b.Service == "rds" { found = true - assert.InDelta(t, 300.00, b.TotalSavings, 0.01) // 200 + 100 - assert.InDelta(t, 77.5, b.AvgCoverage, 0.01) // (80 + 75) / 2 + // Snapshots are run-rates: aggregation over time uses AVG, + // not SUM (see migration 000074 / PR #1127). + assert.InDelta(t, 150.00, b.TotalSavings, 0.01) // AVG(200, 100) + require.NotNil(t, b.AvgCoverage) + assert.InDelta(t, 77.5, *b.AvgCoverage, 0.01) // (80 + 75) / 2 } } assert.True(t, found, "aws/rds breakdown not found") diff --git a/internal/analytics/postgres_analytics_integration_test.go b/internal/analytics/postgres_analytics_integration_test.go index 82b5aa0ca..865f6e241 100644 --- a/internal/analytics/postgres_analytics_integration_test.go +++ b/internal/analytics/postgres_analytics_integration_test.go @@ -343,8 +343,11 @@ func TestPostgresAnalyticsStore_QueryByProvider(t *testing.T) { for _, b := range breakdowns { if b.Provider == "aws" && b.Service == "rds" { found = true - assert.InDelta(t, 300.00, b.TotalSavings, 0.01) // 200 + 100 - assert.InDelta(t, 77.5, b.AvgCoverage, 0.01) // (80 + 75) / 2 + // Snapshots are run-rates: aggregation over time uses AVG, + // not SUM (see migration 000074 / PR #1127). + assert.InDelta(t, 150.00, b.TotalSavings, 0.01) // AVG(200, 100) + require.NotNil(t, b.AvgCoverage) + assert.InDelta(t, 77.5, *b.AvgCoverage, 0.01) // (80 + 75) / 2 } } assert.True(t, found, "aws/rds breakdown not found") diff --git a/internal/config/store_postgres_db_test.go b/internal/config/store_postgres_db_test.go index 3561d5ef1..868536d2f 100644 --- a/internal/config/store_postgres_db_test.go +++ b/internal/config/store_postgres_db_test.go @@ -593,9 +593,11 @@ func TestPostgresStoreDB_PurchaseExecutions(t *testing.T) { t.Run("GetExecutionByID not found", func(t *testing.T) { nonexistentID := uuid.New().String() - _, err := store.GetExecutionByID(ctx, nonexistentID) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") + exec, err := store.GetExecutionByID(ctx, nonexistentID) + // Contract: not-found lookups return (nil, nil); every caller maps + // the nil execution to its own not-found error. + require.NoError(t, err) + assert.Nil(t, exec) }) t.Run("GetExecutionByPlanAndDate success", func(t *testing.T) { diff --git a/internal/config/store_postgres_recommendations_test.go b/internal/config/store_postgres_recommendations_test.go index 626f52c61..eb0c57f6e 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -173,8 +173,8 @@ func TestPostgresStore_ListStoredRecommendations_FilterPushdown(t *testing.T) { require.NoError(t, err) assert.Len(t, got, 1) - // Filter by min savings. - got, err = store.ListStoredRecommendations(ctx, config.RecommendationFilter{MinSavings: 25}) + // Filter by min savings (dollar floor, pushed down to SQL). + got, err = store.ListStoredRecommendations(ctx, config.RecommendationFilter{MinSavingsUSD: 25}) require.NoError(t, err) assert.Len(t, got, 1) assert.Equal(t, "rds", got[0].Service) diff --git a/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go b/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go index c51875de4..6a696129c 100644 --- a/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go +++ b/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go @@ -26,12 +26,15 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Pin the schema at 000053 so the assertions exercise this migration's + // direct effects; later migrations change unrelated parts of the same + // tables (e.g. purchase_executions.execution_id becomes a UUID). + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 53)) // Verify the FK is RESTRICT after the migration. var deleteAction string err = pool.QueryRow(ctx, ` - SELECT confdeltype + SELECT confdeltype::text FROM pg_constraint WHERE conname = 'purchase_executions_cloud_account_id_fkey' `).Scan(&deleteAction) @@ -40,18 +43,17 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { "expected confdeltype 'r' (RESTRICT) after 000053, got %q", deleteAction) // Negative-space guard: migration 000053 must only tighten the executions - // FK. The recommendations FK should stay ON DELETE SET NULL — orphaning a - // historical recommendation is fine (it's advisory data), unlike a pending - // execution (which represents money about to be spent). + // FK. The recommendations FK has been ON DELETE CASCADE since its creation + // in 000030 and must be left untouched. var recsDeleteAction string err = pool.QueryRow(ctx, ` - SELECT confdeltype + SELECT confdeltype::text FROM pg_constraint WHERE conname = 'recommendations_cloud_account_id_fkey' `).Scan(&recsDeleteAction) require.NoError(t, err) - assert.Equal(t, "n", recsDeleteAction, - "recommendations FK must stay SET NULL after 000053, got %q", recsDeleteAction) + assert.Equal(t, "c", recsDeleteAction, + "recommendations FK must stay CASCADE after 000053, got %q", recsDeleteAction) // Behavioural test: insert an account + a pending execution that // references it, then attempt to delete the account. Postgres must @@ -66,7 +68,7 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { INSERT INTO purchase_executions (execution_id, status, step_number, scheduled_date, cloud_account_id) VALUES - ('exec-fk-test', 'pending', 1, NOW(), 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + ('eeeeeeee-eeee-eeee-eeee-eeeeeeee0001', 'pending', 1, NOW(), 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') `) require.NoError(t, err) @@ -77,16 +79,17 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { strings.Contains(err.Error(), "23503") || strings.Contains(err.Error(), "foreign key"), "expected FK violation, got %v", err) - // Cancel the execution, then the delete should succeed. + // RESTRICT applies to every referencing row regardless of status, so the + // account delete only succeeds once the execution row itself is removed + // (the API layer's Cancel-All-And-Delete flow does exactly that). _, err = pool.Exec(ctx, ` - UPDATE purchase_executions - SET status = 'cancelled' - WHERE execution_id = 'exec-fk-test' + DELETE FROM purchase_executions + WHERE execution_id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeee0001' `) require.NoError(t, err) _, err = pool.Exec(ctx, `DELETE FROM cloud_accounts WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'`) - require.NoError(t, err, "DELETE should succeed once pending executions are cancelled") + require.NoError(t, err, "DELETE should succeed once referencing executions are removed") } // TestMigration_ExecutionsAccountFKRestrict_Rollback asserts that the @@ -102,12 +105,15 @@ func TestMigration_ExecutionsAccountFKRestrict_Rollback(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Pin the schema at 000053, then roll back exactly that migration. A + // fixed step count from head would exercise whichever migration happens + // to be newest instead of 000053's down. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 53)) require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) var deleteAction string err = pool.QueryRow(ctx, ` - SELECT confdeltype + SELECT confdeltype::text FROM pg_constraint WHERE conname = 'purchase_executions_cloud_account_id_fkey' `).Scan(&deleteAction) @@ -116,14 +122,14 @@ func TestMigration_ExecutionsAccountFKRestrict_Rollback(t *testing.T) { "expected confdeltype 'n' (SET NULL) after rollback, got %q", deleteAction) // Negative-space guard: rolling back 000053 must not touch the - // recommendations FK — it has always been SET NULL and should stay so. + // recommendations FK — it has been CASCADE since 000030 and should stay so. var recsDeleteAction string err = pool.QueryRow(ctx, ` - SELECT confdeltype + SELECT confdeltype::text FROM pg_constraint WHERE conname = 'recommendations_cloud_account_id_fkey' `).Scan(&recsDeleteAction) require.NoError(t, err) - assert.Equal(t, "n", recsDeleteAction, - "recommendations FK must stay SET NULL after rollback, got %q", recsDeleteAction) + assert.Equal(t, "c", recsDeleteAction, + "recommendations FK must stay CASCADE after rollback, got %q", recsDeleteAction) } diff --git a/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go index 44af19ef6..99d31da32 100644 --- a/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go +++ b/internal/database/postgres/migrations/000057_drop_user_role_to_groups_test.go @@ -47,10 +47,10 @@ func TestMigration_DropUserRoleToGroups(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Apply to head, then roll back 000057 so the DB sits at v56 where the - // `role` column still exists and we can seed role-bearing rows. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at v56, where the `role` column still exists and we can + // seed role-bearing rows. (Pinning by version stays correct as newer + // migrations land; the old "head minus one step" approach did not.) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 56)) // Seed one user per legacy role, each with empty group_ids (the pre-#907 // state where authorization came from the role, not groups). Also seed a @@ -74,8 +74,9 @@ func TestMigration_DropUserRoleToGroups(t *testing.T) { require.NoError(t, err) seed("unknown@test.example", "legacy-custom") - // Re-apply 000057. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Apply exactly 000057 so the mapping assertions below are not affected + // by later group migrations (000064 adds admins to Purchaser, etc.). + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 57)) // Role -> group mapping must preserve access. assert.Equal(t, []string{defaultAdminGroupIDTest}, @@ -111,6 +112,9 @@ func TestMigration_DropUserRoleToGroups(t *testing.T) { ARRAY['`+readOnlyUsersGroupIDTest+`']::uuid[], NOW(), NOW()) `) require.NoError(t, err, "a user with at least one group must insert successfully") + + // The rest of the chain must still apply cleanly over the migrated data. + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) } // assertColumnAbsent fails if the named column still exists on the table. @@ -138,7 +142,8 @@ func TestMigration_DropUserRoleToGroups_Down(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Pin at v57 so the rollback below exercises exactly 000057's down. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 57)) // Seed group-only users (post-#907 shape) before rolling back. _, err = pool.Exec(ctx, ` diff --git a/internal/database/postgres/migrations/000060_cleanup_universal_plans_test.go b/internal/database/postgres/migrations/000060_cleanup_universal_plans_test.go index e54f1b9ab..b807d95f9 100644 --- a/internal/database/postgres/migrations/000060_cleanup_universal_plans_test.go +++ b/internal/database/postgres/migrations/000060_cleanup_universal_plans_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestMigration_CleanupUniversalPlans asserts that migration 000057 deletes +// TestMigration_CleanupUniversalPlans asserts that migration 000060 deletes // every purchase_plans row that has no plan_accounts entry (universal plan) // while leaving correctly scoped plans intact. // @@ -41,10 +41,10 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Run all migrations to head (includes 000057), then roll back one so we - // sit at 000056, seed fixtures, and re-run to apply 000057 in isolation. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at v59, seed fixtures, then apply 000060 in isolation. + // (Pinning by version stays correct as newer migrations land; the old + // "head minus one step" approach did not.) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 59)) // Seed: two cloud accounts (needed for plan_accounts FK). _, err = pool.Exec(ctx, ` @@ -95,7 +95,7 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { (id, account_id, purchase_id, timestamp, provider, service, region, resource_type, term, payment, plan_id, plan_name) VALUES ( - 'hhhhhhhh-hhhh-hhhh-hhhh-000000000001', + 'ffffffff-ffff-ffff-ffff-000000000001', '111111111111', 'ri-abc123', NOW(), 'aws', 'rds', 'us-east-1', 'm5.large', 36, 'all-upfront', @@ -105,8 +105,8 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { `) require.NoError(t, err) - // Apply migration 000057. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Apply migration 000060. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 60)) // Universal plans must be gone. var universalCount int @@ -118,7 +118,7 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { ) `).Scan(&universalCount) require.NoError(t, err) - assert.Equal(t, 0, universalCount, "both universal plans must be deleted by migration 000057") + assert.Equal(t, 0, universalCount, "both universal plans must be deleted by migration 000060") // Scoped plan must survive. var scopedCount int @@ -127,7 +127,7 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { WHERE id = '22222222-2222-2222-2222-000000000001' `).Scan(&scopedCount) require.NoError(t, err) - assert.Equal(t, 1, scopedCount, "scoped plan (with plan_accounts) must survive migration 000057") + assert.Equal(t, 1, scopedCount, "scoped plan (with plan_accounts) must survive migration 000060") // Execution linked to deleted universal plan must have plan_id NULLed. var execPlanID *string @@ -142,15 +142,14 @@ func TestMigration_CleanupUniversalPlans(t *testing.T) { var histPlanID *string err = pool.QueryRow(ctx, ` SELECT plan_id::TEXT FROM purchase_history - WHERE id = 'hhhhhhhh-hhhh-hhhh-hhhh-000000000001' + WHERE id = 'ffffffff-ffff-ffff-ffff-000000000001' `).Scan(&histPlanID) require.NoError(t, err) assert.Nil(t, histPlanID, "history.plan_id must be NULLed when universal plan is deleted (ON DELETE SET NULL)") - // Idempotency: re-running the migration path must be a no-op. + // Idempotency: roll back 000060 (its down is a no-op by design), then + // migrate to head, which re-applies 000060 over already-cleaned data. require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) - // Re-seed only the scoped plan to simulate a clean DB at version 56. - // The universal plans are intentionally absent (already cleaned up). require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) var postIdempotentCount int diff --git a/internal/database/postgres/migrations/000064_relocate_purchaser_group_test.go b/internal/database/postgres/migrations/000064_relocate_purchaser_group_test.go index 40901e3f7..1b9c1db92 100644 --- a/internal/database/postgres/migrations/000064_relocate_purchaser_group_test.go +++ b/internal/database/postgres/migrations/000064_relocate_purchaser_group_test.go @@ -75,10 +75,11 @@ func TestMigration_RelocatePurchaserGroup(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Apply all migrations, then roll back to before 000064 so we - // can seed an admin and verify the backfill fires on re-apply. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB just below 000064 so we can seed an admin and + // verify the backfill fires when 000064 applies. (Pinning by + // version stays correct as newer migrations land; the old + // "head minus one step" approach did not.) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 63)) // Seed an admin user in the Administrators group but NOT in Purchaser. const adminEmail = "admin-purchaser-test@test.example" diff --git a/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.down.sql b/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.down.sql new file mode 100644 index 000000000..da7114a88 --- /dev/null +++ b/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.down.sql @@ -0,0 +1,23 @@ +-- 000076 down: restore the COALESCE expression unique indexes from 000074. +-- Note: with these expression indexes in place REFRESH MATERIALIZED VIEW +-- CONCURRENTLY fails (SQLSTATE 55000); this only reverts to the prior state. + +DROP INDEX IF EXISTS idx_monthly_savings_summary_unique; +CREATE UNIQUE INDEX idx_monthly_savings_summary_unique + ON monthly_savings_summary( + month, account_id, + COALESCE(cloud_account_id, '00000000-0000-0000-0000-000000000000'::uuid), + provider, service); + +DROP INDEX IF EXISTS idx_daily_savings_trend_unique; +CREATE UNIQUE INDEX idx_daily_savings_trend_unique + ON daily_savings_trend( + day, account_id, + COALESCE(cloud_account_id, '00000000-0000-0000-0000-000000000000'::uuid), + provider); + +DROP INDEX IF EXISTS idx_provider_savings_summary_unique; +CREATE UNIQUE INDEX idx_provider_savings_summary_unique + ON provider_savings_summary( + provider, account_id, + COALESCE(cloud_account_id, '00000000-0000-0000-0000-000000000000'::uuid)); diff --git a/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.up.sql b/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.up.sql new file mode 100644 index 000000000..63a7e5f6e --- /dev/null +++ b/internal/database/postgres/migrations/000076_matview_unique_indexes_plain_columns.up.sql @@ -0,0 +1,31 @@ +-- 000076: recreate the materialized-view unique indexes on plain columns so +-- REFRESH MATERIALIZED VIEW CONCURRENTLY works again. +-- +-- Migration 000074 recreated idx_monthly_savings_summary_unique, +-- idx_daily_savings_trend_unique and idx_provider_savings_summary_unique as +-- COALESCE(cloud_account_id, ...) expression indexes. PostgreSQL requires the +-- unique index backing REFRESH ... CONCURRENTLY to use only column names (no +-- expressions, no WHERE clause), so refresh_savings_materialized_views() has +-- failed with SQLSTATE 55000 ("cannot refresh materialized view ... +-- concurrently") ever since. +-- +-- The COALESCE existed to collapse NULL cloud_account_id values into a single +-- key; NULLS NOT DISTINCT (PostgreSQL 15+) preserves that dedup intent while +-- satisfying the CONCURRENTLY prerequisite. Row uniqueness at this grain is +-- already guaranteed by each view's GROUP BY, so the index swap does not +-- change view contents. + +DROP INDEX IF EXISTS idx_monthly_savings_summary_unique; +CREATE UNIQUE INDEX idx_monthly_savings_summary_unique + ON monthly_savings_summary (month, account_id, cloud_account_id, provider, service) + NULLS NOT DISTINCT; + +DROP INDEX IF EXISTS idx_daily_savings_trend_unique; +CREATE UNIQUE INDEX idx_daily_savings_trend_unique + ON daily_savings_trend (day, account_id, cloud_account_id, provider) + NULLS NOT DISTINCT; + +DROP INDEX IF EXISTS idx_provider_savings_summary_unique; +CREATE UNIQUE INDEX idx_provider_savings_summary_unique + ON provider_savings_summary (provider, account_id, cloud_account_id) + NULLS NOT DISTINCT; diff --git a/internal/database/postgres/migrations/backfill_admin_group_ids_test.go b/internal/database/postgres/migrations/backfill_admin_group_ids_test.go index 5d39fd620..01de642af 100644 --- a/internal/database/postgres/migrations/backfill_admin_group_ids_test.go +++ b/internal/database/postgres/migrations/backfill_admin_group_ids_test.go @@ -33,12 +33,11 @@ func TestMigration_BackfillAdminGroupIDs(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Up to head, then roll back to version 55 so the next RunMigrations - // re-applies 000056. Two steps because 000057 (drop role -> groups) now - // sits above 000056 at head; rolling back 000057 first also restores the - // `role` column this test seeds below. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 2)) + // Pin the DB at version 55 so 000056 (the SQL backfill under test) is + // not applied yet and the `role` column this test seeds below still + // exists. (Pinning by version stays correct as newer migrations land; + // the old fixed-step rollback from head did not.) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 55)) // Simulate a restored/manually-seeded admin whose group_ids drifted to // empty (the bug pattern from issue #351). @@ -51,21 +50,31 @@ func TestMigration_BackfillAdminGroupIDs(t *testing.T) { drifted := queryAdminGroupIDs(t, ctx, pool, adminEmail) require.Empty(t, drifted, "test setup: admin should start with empty group_ids") - // Re-run migrations with NO admin email. m.Up() re-applies 000056; the - // Go-level assignAdminGroupAndWarn does NOT run (empty email), so any - // repair is attributable solely to the SQL migration. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Apply exactly 000056. MigrateToVersion runs no Go-level repair + // (assignAdminGroupAndWarn), so any fix is attributable solely to the + // SQL migration. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 56)) got := queryAdminGroupIDs(t, ctx, pool, adminEmail) assert.Equal(t, []string{defaultAdminGroupIDTest}, got, "migration 000056 must backfill the Administrators group onto a drifted admin row even without an admin email") - // Idempotent: rolling back the head migration (000057) and re-applying it + // Idempotent: cycling 000057 down and back up, then migrating to head, // must not duplicate or drop the Administrators group on the already // backfilled admin row. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 57)) require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) got = queryAdminGroupIDs(t, ctx, pool, adminEmail) - assert.Equal(t, []string{defaultAdminGroupIDTest}, got, - "re-applying the head migration must not duplicate the Administrators group entry") + // Later migrations legitimately add more groups to admins (000064 adds + // Purchaser), so assert on the Administrators entry rather than the + // exact set: present exactly once, never duplicated or dropped. + adminEntries := 0 + for _, id := range got { + if id == defaultAdminGroupIDTest { + adminEntries++ + } + } + assert.Equal(t, 1, adminEntries, + "re-applying the migration chain must keep exactly one Administrators group entry, got %v", got) } diff --git a/internal/database/postgres/migrations/migrate.go b/internal/database/postgres/migrations/migrate.go index c1276bde6..ea2cf3728 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -455,6 +455,43 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath return nil } +// MigrateToVersion migrates the schema up or down to exactly the given +// version. Unlike RunMigrations it applies no post-migration Go logic +// (admin seeding etc.), and unlike RollbackMigrations it targets a version +// rather than a step count. Migration tests use it to pin the database at +// the version just below the migration under test; fixed step counts from +// head silently drift every time a newer migration lands. +func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, version uint) error { + dsn := buildMigrateDSN(pool.Config(), "") + + m, err := migrate.New( + fmt.Sprintf("file://%s", migrationsPath), + dsn, + ) + if err != nil { + return fmt.Errorf("failed to create migrator: %w", err) + } + defer m.Close() + + if err := m.Migrate(version); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("failed to migrate to version %d: %w", version, err) + } + + current, dirty, err := m.Version() + if err != nil { + return fmt.Errorf("failed to get migration version: %w", err) + } + if dirty { + return fmt.Errorf("database is in dirty state at version %d", current) + } + if current != version { + return fmt.Errorf("expected migration version %d, got %d", version, current) + } + + log.Printf("Migrated to version %d", current) + return nil +} + // GetMigrationVersion returns the current migration version func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string) (uint, bool, error) { dsn := buildMigrateDSN(pool.Config(), "") diff --git a/internal/database/postgres/migrations/savings_snapshots_pk_test.go b/internal/database/postgres/migrations/savings_snapshots_pk_test.go index 128c86332..046b2c97a 100644 --- a/internal/database/postgres/migrations/savings_snapshots_pk_test.go +++ b/internal/database/postgres/migrations/savings_snapshots_pk_test.go @@ -56,28 +56,14 @@ func TestMigration_SavingsSnapshotsPK(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - - // Count how many applied migrations are above version 26. - // Using COUNT from schema_migrations is correct even when migration - // numbering has gaps: Steps(-n) steps back through n applied - // migrations, so we need the actual row count, not an arithmetic - // difference between version numbers. - var stepsToRollback int - err = pool.QueryRow(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version > 26`).Scan(&stepsToRollback) - require.NoError(t, err) - require.Greater(t, stepsToRollback, 0, "there should be migrations above 000026") - - // RollbackMigrations caps a single call at 10 steps; call in a loop. - const maxRollbackPerCall = 10 - for remaining := stepsToRollback; remaining > 0; remaining -= maxRollbackPerCall { - batch := remaining - if batch > maxRollbackPerCall { - batch = maxRollbackPerCall - } - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, batch), - "rollback to 000026 should succeed") - } + // Pin the schema at 000027 (which re-created the PK over 000018's + // version), then roll back exactly 000027. The previous + // count-rows-in-schema_migrations approach was broken: golang-migrate + // keeps a single (version, dirty) row, so the count was always 1 and + // the loop only ever rolled back the newest migration. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 27)) + require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1), + "rollback of 000027 should succeed") // Verify the constraint is gone (000027 was rolled back). var constraintExists bool diff --git a/internal/database/postgres/migrations/split_savingsplans_test.go b/internal/database/postgres/migrations/split_savingsplans_test.go index 81fb905bb..53d0916ca 100644 --- a/internal/database/postgres/migrations/split_savingsplans_test.go +++ b/internal/database/postgres/migrations/split_savingsplans_test.go @@ -59,13 +59,11 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - // Run migrations up to but not including 000040 by running all, - // then forcing the migration version back to 000039 and undoing - // 000040 — equivalent to "stop just before 000040". Simpler: run - // all migrations, then run down once to undo 000040, then - // truncate and seed. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at 000039 (just below the migration under test) and + // seed before applying 000040. (Pinning by version stays correct + // as newer migrations land; the old "head minus one step" + // approach did not.) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 39)) // Seed: a single umbrella row with non-default values. _, err = pool.Exec(ctx, ` @@ -73,8 +71,8 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { VALUES ('aws', 'savings-plans', true, 1, 'no-upfront', 90.50, 'weekly-25pct')`) require.NoError(t, err) - // Up: run 000040. - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + // Up: apply exactly 000040. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 40)) got := queryAWSSPRows(t, pool) expected := []string{ @@ -104,6 +102,14 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { require.True(t, ok) assert.Equal(t, 1, r.term) assert.Equal(t, "no-upfront", r.payment) + + // Forward again to head: the full chain must re-apply cleanly and + // land on the four split rows. + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + got = queryAWSSPRows(t, pool) + require.Len(t, got, 4, "expected 4 per-plan-type rows after migrating to head") + _, hasUmbrella = got["savings-plans"] + assert.False(t, hasUmbrella, "umbrella row should stay deleted at head") }) t.Run("scenario 2: umbrella + PR #71 sagemaker row (different values)", func(t *testing.T) { @@ -112,8 +118,8 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at 000039, just below the migration under test. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 39)) // Seed both rows with intentionally divergent term/payment so // we can verify which value wins per slot. @@ -161,13 +167,13 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at 000039, just below the migration under test. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 39)) // Confirm no SP rows present pre-migration. require.Empty(t, queryAWSSPRows(t, pool)) - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 40)) // Migration is a no-op with no SP rows to seed from. assert.Empty(t, queryAWSSPRows(t, pool), "no-op when no umbrella row exists") @@ -175,6 +181,10 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { // Down should also be a no-op. require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) assert.Empty(t, queryAWSSPRows(t, pool)) + + // Full chain to head stays a no-op for SP rows. + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) + assert.Empty(t, queryAWSSPRows(t, pool)) }) } @@ -193,8 +203,8 @@ func TestMigration_SplitSavingsPlans_PurchasePlansJSONB(t *testing.T) { defer container.Cleanup(ctx) pool := container.DB.Pool() - require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) - require.NoError(t, migrations.RollbackMigrations(ctx, pool, migrationsPath, 1)) + // Pin the DB at 000039, just below the migration under test. + require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 39)) // Seed two plans: one with the umbrella SP key, one with only RDS // (untouched control). Both have a non-SP key (aws:rds) that must @@ -207,6 +217,18 @@ func TestMigration_SplitSavingsPlans_PurchasePlansJSONB(t *testing.T) { '{"aws:rds": {"term": "1yr"}}'::jsonb)`) require.NoError(t, err) + // Scope both plans to an account: migration 000060 deletes purchase_plans + // rows that have no plan_accounts entry, and the chain to head runs it. + _, err = pool.Exec(ctx, ` + INSERT INTO cloud_accounts (id, name, provider, external_id) + VALUES ('cccccccc-cccc-cccc-cccc-000000000001', 'sp-test-acct', 'aws', '333333333333')`) + require.NoError(t, err) + _, err = pool.Exec(ctx, ` + INSERT INTO plan_accounts (plan_id, account_id) VALUES + ('11111111-1111-1111-1111-111111111111', 'cccccccc-cccc-cccc-cccc-000000000001'), + ('22222222-2222-2222-2222-222222222222', 'cccccccc-cccc-cccc-cccc-000000000001')`) + require.NoError(t, err) + require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", "")) // SP plan: services should now have four aws:savings-plans- diff --git a/internal/database/postgres/testhelpers/postgres.go b/internal/database/postgres/testhelpers/postgres.go index 55ba58561..190e3781f 100644 --- a/internal/database/postgres/testhelpers/postgres.go +++ b/internal/database/postgres/testhelpers/postgres.go @@ -53,7 +53,7 @@ func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContain // Build database config config := &database.Config{ Host: host, - Port: port.Int(), + Port: int(port.Num()), Database: "cudly_test", User: "cudly_test", Password: "test_password", diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go index 22afe878e..47e2eabab 100644 --- a/internal/server/integration_test.go +++ b/internal/server/integration_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/LeanerCloud/CUDly/internal/scheduler" "github.com/LeanerCloud/CUDly/internal/testutil" ) @@ -107,8 +108,6 @@ func TestApplicationLifecycle(t *testing.T) { t.Skip("Skipping integration test in short mode") } - ctx := testutil.TestContext(t) - // Set up test environment testutil.SetEnv(t, "VERSION", "integration-test") testutil.SetEnv(t, "CONFIG_TABLE", "test-config") diff --git a/providers/azure/go.mod b/providers/azure/go.mod index 71fb497f5..62bd7b38b 100644 --- a/providers/azure/go.mod +++ b/providers/azure/go.mod @@ -6,6 +6,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/advisor/armadvisor v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v2 v2.7.0 @@ -15,27 +16,26 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 github.com/LeanerCloud/CUDly/pkg v0.0.0 - github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 ) require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/aws/aws-sdk-go-v2 v1.40.1 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/providers/azure/go.sum b/providers/azure/go.sum index 3ad42d7ce..e5d2ff45c 100644 --- a/providers/azure/go.sum +++ b/providers/azure/go.sum @@ -72,17 +72,17 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/providers/gcp/go.mod b/providers/gcp/go.mod index ea76ed0ff..b8fb550fe 100644 --- a/providers/gcp/go.mod +++ b/providers/gcp/go.mod @@ -12,10 +12,11 @@ require ( github.com/googleapis/gax-go/v2 v2.12.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.16.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 google.golang.org/api v0.160.0 google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac google.golang.org/grpc v1.61.0 + google.golang.org/protobuf v1.36.10 ) require ( @@ -41,15 +42,14 @@ require ( go.opentelemetry.io/otel v1.22.0 // indirect go.opentelemetry.io/otel/metric v1.22.0 // indirect go.opentelemetry.io/otel/trace v1.22.0 // indirect - golang.org/x/crypto v0.18.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect - google.golang.org/protobuf v1.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/providers/gcp/go.sum b/providers/gcp/go.sum index 61f0858ca..c76b08648 100644 --- a/providers/gcp/go.sum +++ b/providers/gcp/go.sum @@ -68,8 +68,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= @@ -110,8 +110,8 @@ go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -126,8 +126,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= @@ -135,8 +135,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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= @@ -145,16 +145,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -201,8 +201,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 5061f00a8..568241008 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -46,8 +46,11 @@ if [ "$DB_AUTO_MIGRATE" = "true" ]; then DB_USER=${DB_USER:-cudly} DB_SSL_MODE=${DB_SSL_MODE:-require} - # Get database password from secret or environment - if [ -n "$DB_PASSWORD_SECRET" ] && [ "$SECRET_PROVIDER" != "env" ]; then + # Get database password from secret or environment. Both variables default + # to empty: under `set -u` a bare expansion aborts the container when they + # are unset (e.g. the docker-compose test environment, which passes + # DB_PASSWORD directly). + if [ -n "${DB_PASSWORD_SECRET:-}" ] && [ "${SECRET_PROVIDER:-}" != "env" ]; then echo " Resolving DB password from secret manager..." # Password will be resolved by the application # Migration will use the environment variable if available diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go new file mode 100644 index 000000000..845e0cbc0 --- /dev/null +++ b/tests/e2e/e2e_test.go @@ -0,0 +1,156 @@ +//go:build e2e + +// Package e2e contains black-box end-to-end tests that exercise a running +// CUDly HTTP server over the network. They are driven by docker-compose +// (docker-compose.test.yml, profile "test"): the test-runner container sets +// API_URL to the cudly-app service and runs `go test -tags=e2e ./...`. +// +// The suite intentionally uses only the standard library (see go.mod) so the +// runner image stays small and fast to build. +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" +) + +// apiURL returns the base URL of the app under test. The suite fails loudly +// when API_URL is missing instead of silently falling back to a default that +// would mask a broken compose wiring. +func apiURL(t *testing.T) string { + t.Helper() + u := os.Getenv("API_URL") + if u == "" { + t.Fatal("API_URL environment variable must be set (see docker-compose.test.yml)") + } + return u +} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// healthResponse mirrors the fields of internal/server.HealthStatus that the +// suite asserts on. +type healthResponse struct { + Status string `json:"status"` + Checks map[string]json.RawMessage `json:"checks"` +} + +// waitForHealthy polls /health until the app reports overall status +// "healthy" (DB connected, migrations applied) or the deadline passes. +// The endpoint always returns 200 and reports "degraded" while lazy DB +// initialization is still in flight, so polling on the JSON body is the +// correct readiness signal. The app initializes the database lazily on the +// first API request (never from /health itself), so each iteration nudges an +// API endpoint first; without that the status stays "pending" forever. +func waitForHealthy(t *testing.T, base string, deadline time.Duration) healthResponse { + t.Helper() + var last healthResponse + var lastErr error + stop := time.Now().Add(deadline) + for time.Now().Before(stop) { + nudgeLazyInit(base) + last, lastErr = fetchHealth(base) + if lastErr == nil && last.Status == "healthy" { + return last + } + time.Sleep(2 * time.Second) + } + t.Fatalf("app never became healthy within %s: last status %q, last error %v", deadline, last.Status, lastErr) + return healthResponse{} +} + +// nudgeLazyInit fires a throwaway API request to trigger the app's lazy +// database initialization. Errors are ignored: the subsequent /health poll +// is the actual readiness check. +func nudgeLazyInit(base string) { + resp, err := httpClient.Get(base + "/api/recommendations") + if err != nil { + return + } + _ = resp.Body.Close() +} + +func fetchHealth(base string) (healthResponse, error) { + var h healthResponse + resp, err := httpClient.Get(base + "/health") + if err != nil { + return h, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return h, fmt.Errorf("GET /health: status %d, want %d", resp.StatusCode, http.StatusOK) + } + if err := json.NewDecoder(resp.Body).Decode(&h); err != nil { + return h, fmt.Errorf("GET /health: decode body: %w", err) + } + return h, nil +} + +// TestHealthEndpoint verifies the app boots, connects to Postgres, applies +// migrations and reports fully healthy dependency checks. +func TestHealthEndpoint(t *testing.T) { + base := apiURL(t) + + h := waitForHealthy(t, base, 90*time.Second) + + for _, check := range []string{"config_store", "auth_store", "migrations"} { + if _, ok := h.Checks[check]; !ok { + t.Errorf("health response missing %q check; got checks %v", check, keys(h.Checks)) + } + } +} + +// TestVersionEndpoint verifies the public /version endpoint serves JSON build +// metadata without authentication. +func TestVersionEndpoint(t *testing.T) { + base := apiURL(t) + waitForHealthy(t, base, 90*time.Second) + + resp, err := httpClient.Get(base + "/version") + if err != nil { + t.Fatalf("GET /version: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /version: status %d, want %d", resp.StatusCode, http.StatusOK) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("GET /version: decode body: %v", err) + } + if len(body) == 0 { + t.Error("GET /version: empty JSON body") + } +} + +// TestAPIRequiresAuth verifies the auth middleware is live end-to-end: an +// unauthenticated request to a protected endpoint must be rejected, not +// served. +func TestAPIRequiresAuth(t *testing.T) { + base := apiURL(t) + waitForHealthy(t, base, 90*time.Second) + + resp, err := httpClient.Get(base + "/api/recommendations") + if err != nil { + t.Fatalf("GET /api/recommendations: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("GET /api/recommendations without credentials: status %d, want %d", + resp.StatusCode, http.StatusUnauthorized) + } +} + +func keys(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/tests/e2e/go.mod b/tests/e2e/go.mod new file mode 100644 index 000000000..97f38118a --- /dev/null +++ b/tests/e2e/go.mod @@ -0,0 +1,6 @@ +// Standalone module: the E2E suite uses only the standard library so the +// test-runner image (Dockerfile.test) builds without downloading the main +// module's dependency tree. Excluded from the root module on purpose. +module github.com/LeanerCloud/CUDly/tests/e2e + +go 1.25