From 90d7dce74a4b9296823f226c529559b2f8784523 Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Wed, 8 Jul 2026 11:16:24 -0700 Subject: [PATCH 1/4] test: add Ginkgo/synctest suites, modernize CI, add branding site Adds test coverage, hardens CI, and introduces a branding site. Tests (Ginkgo/Gomega + testing/synctest): - Suites for config, rules, cloudsql, and daemon (67 specs). - daemon.autoscalingLoop covered by a testing/synctest fake-clock test that simulates multiple intervals in zero real time. - Bump go.mod to 1.26 (required for the stable synctest clock). CI / linting: - Port .golangci.yml to golangci-lint v2 format. - Add govulncheck and go-mod-tidy jobs to ci.yaml; bump Go to 1.26. - Add `make vuln`; wire into `make check`. Branding: - Single-page site/ (GCP blue/green terminal theme) + GitHub Pages deploy workflow. Link the site from the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 42 +++- .github/workflows/pages.yml | 43 ++++ .github/workflows/release.yaml | 2 +- .golangci.yml | 107 ++------- Makefile | 10 +- README.md | 2 + go.mod | 25 ++- go.sum | 74 +++++-- pkg/cloudsql/cloudsql_suite_test.go | 13 ++ pkg/cloudsql/cost_test.go | 69 ++++++ pkg/cloudsql/metrics_test.go | 59 +++++ pkg/config/config_edition_test.go | 53 +++++ pkg/config/config_suite_test.go | 13 ++ pkg/config/machine_types_test.go | 151 +++++++++++++ pkg/daemon/config_test.go | 60 +++++ pkg/daemon/daemon_suite_test.go | 13 ++ pkg/daemon/loop_synctest_test.go | 83 +++++++ pkg/rules/constraints_test.go | 88 ++++++++ pkg/rules/engine_test.go | 191 ++++++++++++++++ pkg/rules/rules_suite_test.go | 13 ++ site/favicon.svg | 6 + site/index.html | 202 +++++++++++++++++ site/styles.css | 331 ++++++++++++++++++++++++++++ 23 files changed, 1536 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 pkg/cloudsql/cloudsql_suite_test.go create mode 100644 pkg/cloudsql/cost_test.go create mode 100644 pkg/cloudsql/metrics_test.go create mode 100644 pkg/config/config_edition_test.go create mode 100644 pkg/config/config_suite_test.go create mode 100644 pkg/config/machine_types_test.go create mode 100644 pkg/daemon/config_test.go create mode 100644 pkg/daemon/daemon_suite_test.go create mode 100644 pkg/daemon/loop_synctest_test.go create mode 100644 pkg/rules/constraints_test.go create mode 100644 pkg/rules/engine_test.go create mode 100644 pkg/rules/rules_suite_test.go create mode 100644 site/favicon.svg create mode 100644 site/index.html create mode 100644 site/styles.css diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5ac385..c02df0f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ on: branches: [ main ] env: - GO_VERSION: '1.24.4' + GO_VERSION: '1.26' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -58,10 +58,44 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: latest - args: --timeout=5m + version: v2.11.4 # pin explicit version + + vuln: + name: Vulnerability Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + + - name: Run govulncheck + run: govulncheck ./... + + tidy: + name: Module Tidy + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Check go mod tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum docker-build: name: Docker Build Test diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e9d8959 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,43 @@ +name: Pages + +on: + push: + branches: [main] + paths: + - "site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress run. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + + - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 845bd55..bcec04e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,7 +6,7 @@ on: workflow_dispatch: env: - GO_VERSION: '1.24.4' + GO_VERSION: '1.26' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/.golangci.yml b/.golangci.yml index 47dc45c..0dfa5f0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,96 +1,35 @@ -run: - timeout: 5m - go: "1.24.4" - -linters-settings: - gofmt: - simplify: true - goimports: - local-prefixes: github.com/fraser-isbester/cloudsql-autoscaler - govet: - check-shadowing: true - errcheck: - check-type-assertions: true - check-blank: true - golint: - min-confidence: 0.8 - misspell: - locale: US - gocyclo: - min-complexity: 15 - goconst: - min-len: 3 - min-occurrences: 3 - funlen: - lines: 100 - statements: 50 - godox: - keywords: - - NOTE - - OPTIMIZE - - HACK - gocritic: - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - disabled-checks: - - dupImport - - ifElseChain - - octalLiteral - - whyNoLint +version: "2" linters: - disable-all: true enable: - - bodyclose - - deadcode - - depguard - - dogsled - errcheck - - exportloopref - - exhaustive - - funlen - - gochecknoinits - - goconst - - gocritic - - gocyclo - - gofmt - - goimports - - golint - - gomnd - - goprintffuncname - - gosec - - gosimple - govet + - staticcheck + - unused - ineffassign - - interfacer - - lll - misspell - - nakedret - - noctx - - nolintlint - - rowserrcheck - - staticcheck - - structcheck - - stylecheck - - typecheck + - gosec - unconvert - unparam - - unused - - varcheck - - whitespace + - prealloc + settings: + misspell: + locale: US + exclusions: + rules: + - path: _test\.go + linters: + - gosec + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/fraser-isbester/cloudsql-autoscaler issues: - exclude-rules: - - path: _test\.go - linters: - - gomnd - - funlen - - path: cmd/ - linters: - - gochecknoinits max-issues-per-linter: 0 - max-same-issues: 0 \ No newline at end of file + max-same-issues: 0 diff --git a/Makefile b/Makefile index bbe845b..8145e15 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean test fmt vet install help docker-build docker-push docker-run deploy-k8s undeploy-k8s +.PHONY: build clean test fmt vet vuln install help docker-build docker-push docker-run deploy-k8s undeploy-k8s .DEFAULT_GOAL := help # Build variables @@ -44,8 +44,12 @@ fmt: vet: go vet ./... -## Run all checks (fmt, vet, build, test) -check: fmt vet build test +## Run govulncheck +vuln: + go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + +## Run all checks (fmt, vet, vuln, build, test) +check: fmt vet vuln build test ## Clean build artifacts clean: diff --git a/README.md b/README.md index 891136b..5ef90bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CloudSQL Autoscaler +**Website:** https://fraser-isbester.github.io/cloudsql-autoscaler/ + Automatically scales Google Cloud SQL instances based on CPU and memory utilization patterns. Supports both one-shot CLI usage and continuous daemon mode for production deployments. ## Features diff --git a/go.mod b/go.mod index 0e1be83..e6e7449 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,30 @@ module github.com/fraser-isbester/cloudsql-autoscaler -go 1.24.4 +go 1.26.0 require ( cloud.google.com/go/compute/metadata v0.7.0 cloud.google.com/go/monitoring v1.24.2 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.9.1 google.golang.org/api v0.241.0 - google.golang.org/protobuf v1.36.6 + google.golang.org/protobuf v1.36.7 ) require ( cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -36,13 +42,16 @@ require ( go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect diff --git a/go.sum b/go.sum index f46ba45..445c5b5 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeO cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -15,15 +17,27 @@ 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -34,12 +48,26 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= 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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -50,6 +78,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= @@ -57,6 +87,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= @@ -73,20 +111,26 @@ 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= -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/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -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/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.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= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/api v0.241.0 h1:QKwqWQlkc6O895LchPEDUSYr22Xp3NCxpQRiWTB6avE= google.golang.org/api v0.241.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= @@ -97,8 +141,10 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 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= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cloudsql/cloudsql_suite_test.go b/pkg/cloudsql/cloudsql_suite_test.go new file mode 100644 index 0000000..b54cfe1 --- /dev/null +++ b/pkg/cloudsql/cloudsql_suite_test.go @@ -0,0 +1,13 @@ +package cloudsql + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCloudSQL(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CloudSQL Suite") +} diff --git a/pkg/cloudsql/cost_test.go b/pkg/cloudsql/cost_test.go new file mode 100644 index 0000000..0c46fc4 --- /dev/null +++ b/pkg/cloudsql/cost_test.go @@ -0,0 +1,69 @@ +package cloudsql + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/config" +) + +var _ = Describe("EstimateCostSavings", func() { + It("returns positive savings when scaling down to a smaller machine type", func() { + savings := EstimateCostSavings("db-n2-standard-4", "db-n2-standard-2", "us-central1") + Expect(savings).To(BeNumerically(">", 0)) + }) + + It("returns negative savings when scaling up to a larger machine type", func() { + savings := EstimateCostSavings("db-n2-standard-2", "db-n2-standard-4", "us-central1") + Expect(savings).To(BeNumerically("<", 0)) + }) + + It("is symmetric: up-scaling cost equals the negation of down-scaling savings", func() { + down := EstimateCostSavings("db-n2-standard-4", "db-n2-standard-2", "us-central1") + up := EstimateCostSavings("db-n2-standard-2", "db-n2-standard-4", "us-central1") + Expect(up).To(BeNumerically("~", -down, 1e-9)) + }) + + It("returns zero savings when the machine type is unchanged", func() { + savings := EstimateCostSavings("db-n2-standard-4", "db-n2-standard-4", "us-central1") + Expect(savings).To(BeNumerically("~", 0, 1e-9)) + }) +}) + +var _ = Describe("ValidateScaling", func() { + newRunnableInstance := func(machineType string) *config.InstanceInfo { + return &config.InstanceInfo{ + Name: "test-instance", + MachineType: machineType, + State: "RUNNABLE", + } + } + + It("errors when the target machine type equals the current type", func() { + instance := newRunnableInstance("db-n2-standard-4") + err := ValidateScaling(instance, "db-n2-standard-4") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("same as current")) + }) + + It("errors when the machine series would change", func() { + instance := newRunnableInstance("db-n2-standard-4") + err := ValidateScaling(instance, "db-e2-standard-4") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot change machine series")) + }) + + It("errors when the instance is not in RUNNABLE state", func() { + instance := newRunnableInstance("db-n2-standard-4") + instance.State = "STOPPED" + err := ValidateScaling(instance, "db-n2-standard-2") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("RUNNABLE")) + }) + + It("succeeds for a valid same-series change on a RUNNABLE instance", func() { + instance := newRunnableInstance("db-n2-standard-4") + err := ValidateScaling(instance, "db-n2-standard-2") + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/pkg/cloudsql/metrics_test.go b/pkg/cloudsql/metrics_test.go new file mode 100644 index 0000000..b6cdbcd --- /dev/null +++ b/pkg/cloudsql/metrics_test.go @@ -0,0 +1,59 @@ +package cloudsql + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/config" +) + +var _ = Describe("CalculateMetricsSummary", func() { + Context("with empty metrics data", func() { + It("reports zero data points and zero period", func() { + summary := CalculateMetricsSummary(&config.MetricsData{}) + Expect(summary.DataPoints).To(Equal(0)) + Expect(summary.Period).To(Equal(time.Duration(0))) + }) + }) + + Context("with a small known series", func() { + var summary *config.MetricsSummary + var start time.Time + + BeforeEach(func() { + start = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + timestamps := make([]time.Time, 5) + for i := range 5 { + timestamps[i] = start.Add(time.Duration(i) * 5 * time.Minute) + } + data := &config.MetricsData{ + Timestamps: timestamps, + CPUUtilization: []float64{10, 20, 30, 40, 50}, + } + summary = CalculateMetricsSummary(data) + }) + + It("counts all data points", func() { + Expect(summary.DataPoints).To(Equal(5)) + }) + + It("computes the CPU average", func() { + Expect(summary.CPUAvg).To(BeNumerically("~", 30, 1e-9)) + }) + + It("computes the CPU maximum", func() { + Expect(summary.CPUMax).To(BeNumerically("~", 50, 1e-9)) + }) + + It("computes the CPU 95th percentile within (40, 50]", func() { + Expect(summary.CPUP95).To(BeNumerically(">", 40)) + Expect(summary.CPUP95).To(BeNumerically("<=", 50)) + }) + + It("computes the period as last minus first timestamp", func() { + Expect(summary.Period).To(Equal(20 * time.Minute)) + }) + }) +}) diff --git a/pkg/config/config_edition_test.go b/pkg/config/config_edition_test.go new file mode 100644 index 0000000..9a73d67 --- /dev/null +++ b/pkg/config/config_edition_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetScalingConstraints", func() { + It("marks Enterprise as causing downtime on scale", func() { + c := GetScalingConstraints(EditionEnterprise) + Expect(c.DowntimeOnScale).To(BeTrue()) + }) + + It("gives Enterprise Plus near-zero downtime with tighter intervals", func() { + c := GetScalingConstraints(EditionEnterprisePlus) + Expect(c.DowntimeOnScale).To(BeFalse()) + Expect(c.MinUpscaleInterval).To(Equal("30m")) + Expect(c.MinDownscaleInterval).To(Equal("3h")) + }) + + It("falls back to restrictive defaults for an unknown edition", func() { + c := GetScalingConstraints(Edition("SOMETHING_ELSE")) + Expect(c.DowntimeOnScale).To(BeTrue()) + Expect(c.MinUpscaleInterval).To(Equal("24h")) + Expect(c.MinDownscaleInterval).To(Equal("24h")) + }) +}) + +var _ = Describe("ParseEdition", func() { + It("parses enterprise_plus regardless of case", func() { + Expect(ParseEdition("enterprise_plus")).To(Equal(EditionEnterprisePlus)) + Expect(ParseEdition("ENTERPRISE_PLUS")).To(Equal(EditionEnterprisePlus)) + }) + + It("parses enterprise", func() { + Expect(ParseEdition("enterprise")).To(Equal(EditionEnterprise)) + }) + + It("defaults unknown values to Enterprise", func() { + Expect(ParseEdition("mystery")).To(Equal(EditionEnterprise)) + }) +}) + +var _ = Describe("DefaultConfig", func() { + It("returns sane default scaling thresholds and intervals", func() { + c := DefaultConfig() + Expect(c.ScaleUpThreshold).To(Equal(0.8)) + Expect(c.ScaleDownThreshold).To(Equal(0.5)) + Expect(c.MetricsInterval).To(Equal(5 * time.Minute)) + }) +}) diff --git a/pkg/config/config_suite_test.go b/pkg/config/config_suite_test.go new file mode 100644 index 0000000..28c15f2 --- /dev/null +++ b/pkg/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/pkg/config/machine_types_test.go b/pkg/config/machine_types_test.go new file mode 100644 index 0000000..8c74c89 --- /dev/null +++ b/pkg/config/machine_types_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetMachineType", func() { + Context("registry lookups", func() { + It("returns the registered machine type for a known name", func() { + mt, err := GetMachineType("db-n2-standard-4") + Expect(err).NotTo(HaveOccurred()) + Expect(mt.CPU).To(Equal(4)) + Expect(mt.MemoryGB).To(Equal(16.0)) + Expect(mt.Series).To(Equal("n2")) + Expect(mt.Tier).To(Equal("standard")) + }) + + It("returns an error for an unknown machine type", func() { + _, err := GetMachineType("db-does-not-exist") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + }) + + Context("custom machine types", func() { + It("parses a valid custom machine type", func() { + mt, err := GetMachineType("db-custom-4-16384") + Expect(err).NotTo(HaveOccurred()) + Expect(mt.CPU).To(Equal(4)) + Expect(mt.MemoryGB).To(Equal(16.0)) + Expect(mt.Series).To(Equal("custom")) + }) + + It("rejects a custom type with CPU out of the 1..96 range", func() { + _, err := GetMachineType("db-custom-100-16384") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a custom type whose memory ratio is too low", func() { + // 4 vCPUs requires at least 3.6 GB; 1024 MB = 1 GB is below the minimum. + _, err := GetMachineType("db-custom-4-1024") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a custom type with an invalid format", func() { + _, err := GetMachineType("db-custom-4") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("performance-optimized machine types", func() { + It("parses a valid perf-optimized machine type", func() { + mt, err := GetMachineType("db-perf-optimized-N-2") + Expect(err).NotTo(HaveOccurred()) + Expect(mt.CPU).To(Equal(2)) + Expect(mt.MemoryGB).To(Equal(16.0)) + Expect(mt.Series).To(Equal("perf-optimized")) + Expect(mt.Tier).To(Equal("performance")) + }) + + It("returns an error for an unknown perf-optimized suffix", func() { + _, err := GetMachineType("db-perf-optimized-N-99") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("parseCustomMachineType", func() { + It("derives the highmem tier when memory per CPU exceeds 4 GB", func() { + mt, err := parseCustomMachineType("db-custom-4-20480") // 20 GB => 5 GB/vCPU + Expect(err).NotTo(HaveOccurred()) + Expect(mt.Tier).To(Equal("highmem")) + }) + + It("rejects a non-custom name", func() { + _, err := parseCustomMachineType("db-n2-standard-4") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("GetNextLargerMachineType", func() { + It("returns the next larger registry type in the same series/tier", func() { + next, err := GetNextLargerMachineType("db-n2-standard-2") + Expect(err).NotTo(HaveOccurred()) + Expect(next).To(Equal("db-n2-standard-4")) + }) + + It("advances perf-optimized types up the sequence", func() { + next, err := GetNextLargerMachineType("db-perf-optimized-N-2") + Expect(err).NotTo(HaveOccurred()) + Expect(next).To(Equal("db-perf-optimized-N-4")) + }) + + It("errors when already at the largest perf-optimized size", func() { + _, err := GetNextLargerMachineType("db-perf-optimized-N-16") + Expect(err).To(HaveOccurred()) + }) + + It("returns a different, valid custom type when scaling a custom machine up", func() { + next, err := GetNextLargerMachineType("db-custom-4-16384") + Expect(err).NotTo(HaveOccurred()) + Expect(next).NotTo(Equal("db-custom-4-16384")) + Expect(next).To(HavePrefix("db-custom-")) + + // The returned custom type must itself be parseable/valid. + mt, err := GetMachineType(next) + Expect(err).NotTo(HaveOccurred()) + Expect(mt.Series).To(Equal("custom")) + }) + + It("propagates errors for unknown machine types", func() { + _, err := GetNextLargerMachineType("db-nope") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("GetNextSmallerMachineType", func() { + It("returns the next smaller registry type in the same series/tier", func() { + next, err := GetNextSmallerMachineType("db-n2-standard-4") + Expect(err).NotTo(HaveOccurred()) + Expect(next).To(Equal("db-n2-standard-2")) + }) + + It("errors when nothing smaller exists in the series/tier", func() { + _, err := GetNextSmallerMachineType("db-n2-standard-2") + Expect(err).To(HaveOccurred()) + }) + + It("steps perf-optimized types down the sequence", func() { + next, err := GetNextSmallerMachineType("db-perf-optimized-N-4") + Expect(err).NotTo(HaveOccurred()) + Expect(next).To(Equal("db-perf-optimized-N-2")) + }) + + It("errors when already at the smallest perf-optimized size", func() { + _, err := GetNextSmallerMachineType("db-perf-optimized-N-2") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("MachineTypeRegistry", func() { + It("uses names that match their map keys", func() { + for key, mt := range MachineTypeRegistry { + Expect(mt.Name).To(Equal(key)) + Expect(strings.HasPrefix(key, "db-")).To(BeTrue()) + } + }) +}) diff --git a/pkg/daemon/config_test.go b/pkg/daemon/config_test.go new file mode 100644 index 0000000..7dc249d --- /dev/null +++ b/pkg/daemon/config_test.go @@ -0,0 +1,60 @@ +package daemon + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/config" +) + +var _ = Describe("validateConfig", func() { + It("returns an error when cfg is nil", func() { + Expect(validateConfig(nil, 30*time.Minute, 8080)).To(HaveOccurred()) + }) + + It("returns an error when ProjectID is empty", func() { + cfg := &config.Config{ProjectID: ""} + Expect(validateConfig(cfg, 30*time.Minute, 8080)).To(HaveOccurred()) + }) + + It("returns an error when interval is zero", func() { + cfg := &config.Config{ProjectID: "test-project"} + Expect(validateConfig(cfg, 0, 8080)).To(HaveOccurred()) + }) + + It("returns an error when interval is negative", func() { + cfg := &config.Config{ProjectID: "test-project"} + Expect(validateConfig(cfg, -time.Second, 8080)).To(HaveOccurred()) + }) + + It("returns an error when httpPort is zero", func() { + cfg := &config.Config{ProjectID: "test-project"} + Expect(validateConfig(cfg, 30*time.Minute, 0)).To(HaveOccurred()) + }) + + It("returns an error when httpPort is out of range", func() { + cfg := &config.Config{ProjectID: "test-project"} + Expect(validateConfig(cfg, 30*time.Minute, 70000)).To(HaveOccurred()) + }) + + It("returns nil for a valid configuration", func() { + cfg := &config.Config{ProjectID: "test-project"} + Expect(validateConfig(cfg, 30*time.Minute, 8080)).To(Succeed()) + }) +}) + +var _ = Describe("NewDaemonConfig", func() { + It("exposes the provided values through the Config interface", func() { + cfg := &config.Config{ProjectID: "test-project", DryRun: true} + + daemonCfg := NewDaemonConfig(cfg, 30*time.Minute, 8080, true) + + Expect(daemonCfg.GetInterval()).To(Equal(30 * time.Minute)) + Expect(daemonCfg.GetHTTPPort()).To(Equal(8080)) + Expect(daemonCfg.IsMetricsEnabled()).To(BeTrue()) + Expect(daemonCfg.GetProjectID()).To(Equal(cfg.ProjectID)) + Expect(daemonCfg.IsDryRun()).To(Equal(cfg.DryRun)) + }) +}) diff --git a/pkg/daemon/daemon_suite_test.go b/pkg/daemon/daemon_suite_test.go new file mode 100644 index 0000000..5a9c5cb --- /dev/null +++ b/pkg/daemon/daemon_suite_test.go @@ -0,0 +1,13 @@ +package daemon + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDaemon(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Daemon Suite") +} diff --git a/pkg/daemon/loop_synctest_test.go b/pkg/daemon/loop_synctest_test.go new file mode 100644 index 0000000..8553418 --- /dev/null +++ b/pkg/daemon/loop_synctest_test.go @@ -0,0 +1,83 @@ +package daemon + +import ( + "context" + "sync" + "testing" + "testing/synctest" + "time" +) + +// fakeRunner is a threadsafe CycleRunner that counts how many cycles ran. +type fakeRunner struct { + mu sync.Mutex + count int +} + +func (r *fakeRunner) RunCycle(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + r.count++ + return nil +} + +func (r *fakeRunner) getCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.count +} + +// fakeLoopConfig is a minimal Config implementation for driving the loop. +// Only GetInterval is meaningful; the rest return zero values. +type fakeLoopConfig struct{} + +func (fakeLoopConfig) GetInterval() time.Duration { return 30 * time.Minute } +func (fakeLoopConfig) GetHTTPPort() int { return 0 } +func (fakeLoopConfig) IsMetricsEnabled() bool { return false } +func (fakeLoopConfig) IsDryRun() bool { return false } +func (fakeLoopConfig) GetProjectID() string { return "test" } + +// TestAutoscalingLoopFakeClock exercises the daemon's autoscaling loop using +// the Go 1.25+ testing/synctest fake clock. Inside synctest.Test the clock is +// virtual: time.Sleep and the loop's time.NewTicker advance instantly, so this +// test simulates multiple 30-minute intervals in essentially zero real +// wall-clock time. synctest.Wait blocks until every goroutine in the bubble is +// durably blocked, letting us assert on cycle counts deterministically. +func TestAutoscalingLoopFakeClock(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + r := &fakeRunner{} + ctx, cancel := context.WithCancel(context.Background()) + d := &Daemon{ + config: fakeLoopConfig{}, + runner: r, + ctx: ctx, + cancel: cancel, + } + + d.wg.Add(1) + go d.autoscalingLoop() + + // The immediate startup cycle runs, then the loop blocks in select. + synctest.Wait() + if got := r.getCount(); got != 1 { + t.Fatalf("after startup: expected count 1, got %d", got) + } + + // Advance the fake clock by one interval so the ticker fires. + time.Sleep(30 * time.Minute) + synctest.Wait() + if got := r.getCount(); got != 2 { + t.Fatalf("after 1 interval: expected count 2, got %d", got) + } + + time.Sleep(30 * time.Minute) + synctest.Wait() + if got := r.getCount(); got != 3 { + t.Errorf("after 2 intervals: expected count 3, got %d", got) + } + + cancel() + synctest.Wait() + d.wg.Wait() + }) +} diff --git a/pkg/rules/constraints_test.go b/pkg/rules/constraints_test.go new file mode 100644 index 0000000..4d33824 --- /dev/null +++ b/pkg/rules/constraints_test.go @@ -0,0 +1,88 @@ +package rules + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/config" +) + +var _ = Describe("Constraints", func() { + var cfg *config.Config + + BeforeEach(func() { + cfg = config.DefaultConfig() + }) + + Describe("CheckScalingConstraints", func() { + // completeMetrics has enough data points to avoid the low-completeness warning. + completeMetrics := func() *config.MetricsSummary { + expected := int(cfg.MetricsPeriod / cfg.MetricsInterval) + return &config.MetricsSummary{DataPoints: expected} + } + + It("warns when high availability is enabled", func() { + instance := &config.InstanceInfo{HighAvailability: true} + + warnings := CheckScalingConstraints(instance, completeMetrics(), cfg) + + Expect(warnings).To(ContainElement(ContainSubstring("high availability"))) + }) + + It("warns when backups are enabled", func() { + instance := &config.InstanceInfo{BackupEnabled: true} + + warnings := CheckScalingConstraints(instance, completeMetrics(), cfg) + + Expect(warnings).To(ContainElement(ContainSubstring("backups enabled"))) + }) + + It("warns when the instance was scaled recently", func() { + instance := &config.InstanceInfo{LastScaledTime: time.Now()} + + warnings := CheckScalingConstraints(instance, completeMetrics(), cfg) + + Expect(warnings).To(ContainElement(ContainSubstring("scaled recently"))) + }) + + It("warns when metrics data is incomplete", func() { + instance := &config.InstanceInfo{} + metrics := &config.MetricsSummary{DataPoints: 10} + + warnings := CheckScalingConstraints(instance, metrics, cfg) + + Expect(warnings).To(ContainElement(ContainSubstring("Limited metrics data"))) + }) + }) + + Describe("EstimateDowntime", func() { + It("returns a positive duration for an Enterprise instance", func() { + instance := &config.InstanceInfo{Edition: config.EditionEnterprise} + + downtime := EstimateDowntime(instance, "db-n2-standard-4", "db-n2-standard-8") + + Expect(downtime).To(BeNumerically(">", time.Duration(0))) + }) + + It("returns zero downtime for an Enterprise Plus instance", func() { + instance := &config.InstanceInfo{Edition: config.EditionEnterprisePlus} + + downtime := EstimateDowntime(instance, "db-n2-standard-4", "db-n2-standard-8") + + Expect(downtime).To(Equal(time.Duration(0))) + }) + }) + + Describe("GetOptimalScalingWindow", func() { + It("returns a 24h window for Enterprise Plus (no downtime on scale)", func() { + constraints := config.GetScalingConstraints(config.EditionEnterprisePlus) + metrics := &config.MetricsData{} + + window := GetOptimalScalingWindow(metrics, constraints) + + Expect(window.Duration).To(Equal(24 * time.Hour)) + }) + }) +}) diff --git a/pkg/rules/engine_test.go b/pkg/rules/engine_test.go new file mode 100644 index 0000000..0468746 --- /dev/null +++ b/pkg/rules/engine_test.go @@ -0,0 +1,191 @@ +package rules + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/cloudsql" + "github.com/fraser-isbester/cloudsql-autoscaler/pkg/config" +) + +var _ = Describe("Engine", func() { + var ( + cfg *config.Config + engine *Engine + instance *config.InstanceInfo + ) + + BeforeEach(func() { + cfg = config.DefaultConfig() + engine = NewEngine(cfg) + instance = &config.InstanceInfo{ + Name: "test-instance", + MachineType: "db-n2-standard-4", + Edition: config.EditionEnterprisePlus, + } + }) + + Describe("AnalyzeInstance", func() { + Context("when there is insufficient metrics data", func() { + It("does not recommend scaling", func() { + metrics := &config.MetricsSummary{DataPoints: 5} + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.ShouldScale).To(BeFalse()) + Expect(decision.Reason).To(ContainSubstring("Insufficient")) + }) + }) + + Context("when load is high", func() { + It("recommends scaling up to the next larger machine type", func() { + metrics := &config.MetricsSummary{ + CPUP95: 95, + MemoryP95Pct: 60, + DataPoints: 100, + } + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.ShouldScale).To(BeTrue()) + Expect(decision.RecommendedType).To(Equal("db-n2-standard-8")) + }) + }) + + Context("when load is low", func() { + It("recommends scaling down to the next smaller machine type", func() { + metrics := &config.MetricsSummary{ + CPUP95: 20, + MemoryP95Pct: 20, + DataPoints: 100, + } + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.ShouldScale).To(BeTrue()) + Expect(decision.RecommendedType).To(Equal("db-n2-standard-2")) + }) + }) + + Context("when utilization is within the target range", func() { + It("does not recommend scaling", func() { + metrics := &config.MetricsSummary{ + CPUP95: 60, + MemoryP95Pct: 60, + DataPoints: 100, + } + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.ShouldScale).To(BeFalse()) + Expect(decision.Reason).To(ContainSubstring("within target range")) + }) + }) + + Context("with an Enterprise edition instance", func() { + It("expects downtime for a scaling operation", func() { + instance.Edition = config.EditionEnterprise + metrics := &config.MetricsSummary{ + CPUP95: 95, + MemoryP95Pct: 60, + DataPoints: 100, + } + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.ShouldScale).To(BeTrue()) + Expect(decision.DowntimeExpected).To(BeTrue()) + }) + }) + + Context("with an Enterprise Plus instance", func() { + var metrics *config.MetricsSummary + + BeforeEach(func() { + instance.Edition = config.EditionEnterprisePlus + metrics = &config.MetricsSummary{ + CPUP95: 95, + MemoryP95Pct: 60, + DataPoints: 100, + } + }) + + It("expects downtime when scaled up too recently", func() { + instance.LastScaledTime = time.Now() + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.DowntimeExpected).To(BeTrue()) + }) + + It("does not expect downtime when the last scale was long ago", func() { + instance.LastScaledTime = time.Now().Add(-25 * time.Hour) + + decision, err := engine.AnalyzeInstance(instance, metrics) + + Expect(err).NotTo(HaveOccurred()) + Expect(decision.DowntimeExpected).To(BeFalse()) + }) + }) + }) + + Describe("ValidateScalingDecision", func() { + It("returns an error when downtime is expected and not forced", func() { + decision := &cloudsql.ScalingDecision{ + ShouldScale: true, + CurrentType: "db-n2-standard-4", + RecommendedType: "db-n2-standard-8", + DowntimeExpected: true, + DowntimeReason: "requires downtime", + } + + err := engine.ValidateScalingDecision(decision, false) + + Expect(err).To(HaveOccurred()) + }) + + It("returns nil when downtime is expected but forced", func() { + decision := &cloudsql.ScalingDecision{ + ShouldScale: true, + CurrentType: "db-n2-standard-4", + RecommendedType: "db-n2-standard-8", + DowntimeExpected: true, + DowntimeReason: "requires downtime", + } + + err := engine.ValidateScalingDecision(decision, true) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an error when the recommended type matches the current type", func() { + decision := &cloudsql.ScalingDecision{ + ShouldScale: true, + CurrentType: "db-n2-standard-4", + RecommendedType: "db-n2-standard-4", + } + + err := engine.ValidateScalingDecision(decision, false) + + Expect(err).To(HaveOccurred()) + }) + + It("returns nil when no scaling is required", func() { + decision := &cloudsql.ScalingDecision{ + ShouldScale: false, + } + + err := engine.ValidateScalingDecision(decision, false) + + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/pkg/rules/rules_suite_test.go b/pkg/rules/rules_suite_test.go new file mode 100644 index 0000000..15f1b0d --- /dev/null +++ b/pkg/rules/rules_suite_test.go @@ -0,0 +1,13 @@ +package rules + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRules(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Rules Suite") +} diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..2cfc32f --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,6 @@ + + + + > + ↑ + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..2e002dc --- /dev/null +++ b/site/index.html @@ -0,0 +1,202 @@ + + + + + + cloudsql-autoscaler — right-size your Cloud SQL instances automatically + + + + + + + +
+ +
+ +

Right-size your Cloud SQL instances automatically.

+

+ A tool for Google Cloud SQL that analyzes real utilization and + recommends — or applies — the right machine type. It looks at sustained + load instead of momentary spikes, respects edition + downtime constraints, and runs as a + one-shot CLI or a continuous daemon. +

+ +
+
+ +
+
# Install
+$ go install github.com/fraser-isbester/cloudsql-autoscaler/cmd/cloudsql-autoscaler@latest
+
+# Analyze all instances (dry-run by default)
+$ cloudsql-autoscaler --project my-gcp-project
+
+# Apply scaling recommendations
+$ cloudsql-autoscaler --project my-gcp-project --dry-run=false
+
+ + +
+ + +
+
+

What is it?

+

+ cloudsql-autoscaler watches your Cloud SQL + instances and figures out when they're over- or under-provisioned. It + pulls three days of CPU and memory data from Cloud Monitoring, uses + P95 percentiles to separate real sustained + load from temporary spikes, and recommends machine-type changes within + safe constraints. +

+

+ Scaling on Cloud SQL isn't free — some changes require a restart. The + autoscaler understands the difference between + Enterprise Plus near-zero-downtime + windows and Enterprise edition + downtime requirements, so it can apply changes safely. Run it once from + your laptop, on a schedule, or as an always-on daemon in your cluster. +

+
+
+ + +
+
+

Features

+
+
+

01Smart P95 analysis

+

+ Uses 3 days of CPU/memory metrics and P95 percentiles to identify + sustained load and avoid scaling on temporary spikes. +

+
+
+

02Downtime aware

+

+ Understands Enterprise Plus + zero-downtime windows versus + Enterprise edition downtime + requirements before applying changes. +

+
+
+

03CLI · Docker · Kubernetes

+

+ Run it as a one-shot CLI, a Docker container, or a Kubernetes + deployment with Workload Identity. +

+
+
+

04Scaling profiles

+

+ Choose conservative, default, or + aggressive profiles to tune how eagerly it resizes. +

+
+
+

05Prometheus & health

+

+ Built-in Prometheus metrics plus /health, + /ready, and /status endpoints for + daemon mode. +

+
+
+

06Dry-run by default

+

+ Recommendations are shown as table or + json; nothing changes until you pass + --dry-run=false. +

+
+
+
+
+ + +
+
+

Deployment

+

+ Ship it however you run infrastructure. Pull the container image and + run it as a daemon, or deploy the bundled Kubernetes manifests. +

+ +
+

Docker

+
# Pull and run as a daemon
+docker run -d \
+  -e GOOGLE_APPLICATION_CREDENTIALS=/creds.json \
+  -v /path/to/creds.json:/creds.json:ro \
+  ghcr.io/fraser-isbester/cloudsql-autoscaler:latest \
+  --daemon --project my-gcp-project
+
+ +
+

Kubernetes

+
# Clone and deploy
+git clone https://github.com/fraser-isbester/cloudsql-autoscaler.git
+cd cloudsql-autoscaler
+make deploy-k8s
+
+# Check status
+make k8s-status
+make k8s-logs
+

+ Requires a GKE cluster with Workload Identity enabled and a service + account with Cloud SQL Admin permissions. +

+
+
+
+
+ + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..c0c441a --- /dev/null +++ b/site/styles.css @@ -0,0 +1,331 @@ +/* cloudsql-autoscaler branding site — terminal aesthetic + Google Cloud blue/green palette */ + +:root { + --blue: #4285f4; /* primary accent / links / logo */ + --green: #34a853; /* secondary accent / scaling up / success */ + --amber: #fbbc04; /* warnings / downtime callouts */ + --red: #ea4335; + --blue-bright: #669df6; + + --bg: #0a0a0a; + --panel: #141414; + --panel-2: #1c1c1c; + --header-bg: #303030; + --border: #2a2a2a; + --fg: #d0d0d0; + --fg-bright: #ffffff; + --dim: #585858; + + --mono: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", "Cascadia Code", + "Consolas", monospace; + --maxw: 1040px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--mono); + font-size: 15px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: var(--blue); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +/* subtle terminal grid backdrop */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: radial-gradient( + 1200px 600px at 50% -10%, + rgba(66, 133, 244, 0.07), + transparent 70% + ); +} + +/* ---------- Nav ---------- */ +.nav { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: rgba(10, 10, 10, 0.85); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} +.nav-brand { + font-weight: 700; + letter-spacing: 0.08em; + color: var(--blue); + text-transform: lowercase; +} +.nav-brand::before { + content: "> "; + color: var(--green); +} +.nav-links { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; +} +.nav-links a { + color: var(--fg); +} +.nav-links a:hover, +.nav-links a.active { + color: var(--blue); + text-decoration: none; +} + +/* ---------- Layout ---------- */ +main { + position: relative; + z-index: 1; +} +.wrap { + max-width: var(--maxw); + margin: 0 auto; + padding: 0 1.25rem; +} +section { + padding: 3.5rem 0; + border-bottom: 1px solid var(--border); +} +h1, +h2, +h3 { + color: var(--fg-bright); + line-height: 1.25; +} +h2 { + font-size: 1.6rem; + margin-top: 0; +} +h2::before { + content: "# "; + color: var(--green); +} +.muted { + color: var(--dim); +} +.accent { + color: var(--blue); +} +.accent-green { + color: var(--green); +} +.accent-amber { + color: var(--amber); +} + +/* ---------- Hero ---------- */ +.hero { + text-align: center; + padding: 4rem 1.25rem 3.5rem; +} +.hero .logo { + color: var(--blue); + font-size: clamp(0.4rem, 1.5vw, 0.8rem); + line-height: 1.1; + margin: 0 auto 1.5rem; + display: inline-block; + text-align: left; + text-shadow: 0 0 18px rgba(66, 133, 244, 0.35); + white-space: pre; + overflow-x: auto; + max-width: 100%; +} +.hero .tagline { + font-size: clamp(1.1rem, 2.6vw, 1.5rem); + color: var(--fg-bright); + margin: 0.5rem auto 0.75rem; + max-width: 44ch; +} +.hero .sub { + color: var(--dim); + max-width: 62ch; + margin: 0 auto 1.75rem; +} + +/* ---------- Buttons ---------- */ +.btn-row { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; + margin-top: 1.5rem; +} +.btn { + display: inline-block; + padding: 0.6rem 1.2rem; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg); + background: var(--panel); + font-weight: 600; + transition: all 0.15s ease; +} +.btn:hover { + text-decoration: none; + border-color: var(--blue); + color: var(--blue); + box-shadow: 0 0 0 1px var(--blue); +} +.btn-primary { + background: var(--blue); + color: #001024; + border-color: var(--blue); +} +.btn-primary:hover { + background: var(--blue-bright); + color: #001024; + box-shadow: 0 0 22px rgba(66, 133, 244, 0.45); +} + +/* ---------- Code ---------- */ +code { + font-family: var(--mono); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.1em 0.4em; + color: var(--blue); + font-size: 0.9em; +} +pre { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem 1.15rem; + overflow-x: auto; + position: relative; +} +pre code { + background: none; + border: none; + padding: 0; + color: var(--fg); +} +.terminal { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--panel); + max-width: 720px; + margin: 0 auto; + text-align: left; +} +.terminal-bar { + background: var(--header-bg); + padding: 0.5rem 0.75rem; + display: flex; + gap: 0.4rem; + align-items: center; +} +.dot { + width: 11px; + height: 11px; + border-radius: 50%; + display: inline-block; +} +.dot.r { + background: var(--red); +} +.dot.y { + background: var(--amber); +} +.dot.g { + background: var(--green); +} +.terminal pre { + border: none; + border-radius: 0; + margin: 0; +} +.terminal .prompt { + color: var(--green); +} +.terminal .comment { + color: var(--dim); +} + +/* ---------- Feature grid ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-top: 1.75rem; +} +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1.25rem; + transition: border-color 0.15s ease; +} +.card:hover { + border-color: var(--blue-bright); +} +.card h3 { + margin: 0 0 0.5rem; + font-size: 1.05rem; + color: var(--blue); +} +.card h3 .tag { + color: var(--green); + margin-right: 0.4rem; +} +.card p { + margin: 0; + color: var(--fg); +} + +/* ---------- Deployment ---------- */ +.deploy-block { + margin-top: 1.5rem; +} +.deploy-block h3 { + color: var(--green); + font-size: 1.05rem; + margin-bottom: 0.5rem; +} + +/* ---------- Footer ---------- */ +.footer { + position: relative; + z-index: 1; + text-align: center; + padding: 2.5rem 1.25rem; + color: var(--dim); + font-size: 0.85rem; +} +.footer a { + color: var(--dim); +} +.footer a:hover { + color: var(--blue); +} From f12c4e352f40e158f89966b27dd5fffd30d7cb8d Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Wed, 8 Jul 2026 11:21:02 -0700 Subject: [PATCH 2/4] fix(deps): patch all known CVEs (grpc, otel, crypto/tls) Resolves the Dependabot alerts and the govulncheck-reachable stdlib finding: - google.golang.org/grpc 1.73.0 -> 1.82.0 (GHSA-p77j-4mvh-x3m3, critical) - go.opentelemetry.io/otel 1.36.0 -> 1.44.0 (GHSA-mh2q-q3fh-2475, high) - Go toolchain -> 1.26.5 for crypto/tls GO-2026-5856 (reachable via the daemon HTTPS server and Cloud SQL client) - x/net (0.56.0) and x/crypto (0.53.0) already exceed the patched versions from earlier test-dep upgrades. govulncheck now reports 0 reachable vulnerabilities. The one remaining module-level advisory (GO-2026-5932, x/crypto/openpgp unmaintained) has no fix available and is not imported by this code. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 24 +++++++++++---------- go.sum | 67 ++++++++++++++++++++++++++++++++++------------------------ 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index e6e7449..5f7f157 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/fraser-isbester/cloudsql-autoscaler -go 1.26.0 +go 1.26.5 require ( - cloud.google.com/go/compute/metadata v0.7.0 + cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/monitoring v1.24.2 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.9.1 google.golang.org/api v0.241.0 - google.golang.org/protobuf v1.36.7 + google.golang.org/protobuf v1.36.11 ) require ( @@ -36,24 +36,26 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // 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.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.0 // indirect ) diff --git a/go.sum b/go.sum index 445c5b5..28c7366 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -12,9 +12,16 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -68,6 +75,8 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -78,15 +87,15 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -95,22 +104,22 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 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/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= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -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/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -119,8 +128,8 @@ golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= @@ -131,18 +140,20 @@ golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.241.0 h1:QKwqWQlkc6O895LchPEDUSYr22Xp3NCxpQRiWTB6avE= google.golang.org/api v0.241.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-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= -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.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= From 9939210ac91ab763fd011ac115e5a54ab26f6ad5 Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Wed, 8 Jul 2026 11:30:32 -0700 Subject: [PATCH 3/4] fix(lint): resolve golangci-lint v2 findings in existing code The old golangci config was v1-format and never loaded under the v2 binary, so these pre-existing issues were never caught: - check json.Encoder.Encode / analyzer.Close return values (errcheck) - use metadata.ProjectIDWithContext instead of the deprecated metadata.ProjectID (staticcheck SA1019) - drop the no-op max_connections flag loop (staticcheck SA9003) - remove the unused ctx param from runDaemon (unparam) - drop the constant-valued param from a test helper (unparam) golangci-lint run ./... now reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/cloudsql-autoscaler/main.go | 8 ++++---- pkg/cloudsql/client.go | 8 -------- pkg/cloudsql/cost_test.go | 12 ++++++------ pkg/daemon/http.go | 10 +++++----- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/cmd/cloudsql-autoscaler/main.go b/cmd/cloudsql-autoscaler/main.go index 4648016..20afc02 100644 --- a/cmd/cloudsql-autoscaler/main.go +++ b/cmd/cloudsql-autoscaler/main.go @@ -162,7 +162,7 @@ func runAutoscaler(cmd *cobra.Command, args []string) error { // Handle daemon mode if daemonMode { - return runDaemon(ctx, cfg) + return runDaemon(cfg) } // Handle one-shot mode @@ -170,7 +170,7 @@ func runAutoscaler(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create analyzer: %w", err) } - defer projectAnalyzer.Close() + defer func() { _ = projectAnalyzer.Close() }() if output != "table" && output != "json" { return fmt.Errorf("invalid output format: %s (must be 'table' or 'json')", output) @@ -182,7 +182,7 @@ func runAutoscaler(cmd *cobra.Command, args []string) error { return analyzeAllInstances(ctx, projectAnalyzer) } -func runDaemon(ctx context.Context, cfg *config.Config) error { +func runDaemon(cfg *config.Config) error { // Initialize metrics if enabled if enableMetrics { daemon.InitMetrics() @@ -416,7 +416,7 @@ func countErrors(results []OutputResult) int { func getDefaultProjectID(ctx context.Context) (string, error) { if metadata.OnGCE() { - project, err := metadata.ProjectID() + project, err := metadata.ProjectIDWithContext(ctx) if err == nil { return project, nil } diff --git a/pkg/cloudsql/client.go b/pkg/cloudsql/client.go index 40c9694..9806f08 100644 --- a/pkg/cloudsql/client.go +++ b/pkg/cloudsql/client.go @@ -73,14 +73,6 @@ func (c *Client) GetInstance(ctx context.Context, instanceName string) (*config. info.Zone = instance.GceZone } - // Get max connections from database flags if set - for _, flag := range instance.Settings.DatabaseFlags { - if flag.Name == "max_connections" { - // Parse max connections value - // Note: Proper parsing would be needed here - } - } - return info, nil } diff --git a/pkg/cloudsql/cost_test.go b/pkg/cloudsql/cost_test.go index 0c46fc4..e4f99e6 100644 --- a/pkg/cloudsql/cost_test.go +++ b/pkg/cloudsql/cost_test.go @@ -31,30 +31,30 @@ var _ = Describe("EstimateCostSavings", func() { }) var _ = Describe("ValidateScaling", func() { - newRunnableInstance := func(machineType string) *config.InstanceInfo { + newRunnableInstance := func() *config.InstanceInfo { return &config.InstanceInfo{ Name: "test-instance", - MachineType: machineType, + MachineType: "db-n2-standard-4", State: "RUNNABLE", } } It("errors when the target machine type equals the current type", func() { - instance := newRunnableInstance("db-n2-standard-4") + instance := newRunnableInstance() err := ValidateScaling(instance, "db-n2-standard-4") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("same as current")) }) It("errors when the machine series would change", func() { - instance := newRunnableInstance("db-n2-standard-4") + instance := newRunnableInstance() err := ValidateScaling(instance, "db-e2-standard-4") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cannot change machine series")) }) It("errors when the instance is not in RUNNABLE state", func() { - instance := newRunnableInstance("db-n2-standard-4") + instance := newRunnableInstance() instance.State = "STOPPED" err := ValidateScaling(instance, "db-n2-standard-2") Expect(err).To(HaveOccurred()) @@ -62,7 +62,7 @@ var _ = Describe("ValidateScaling", func() { }) It("succeeds for a valid same-series change on a RUNNABLE instance", func() { - instance := newRunnableInstance("db-n2-standard-4") + instance := newRunnableInstance() err := ValidateScaling(instance, "db-n2-standard-2") Expect(err).ToNot(HaveOccurred()) }) diff --git a/pkg/daemon/http.go b/pkg/daemon/http.go index 06ef2f4..b0d68ca 100644 --- a/pkg/daemon/http.go +++ b/pkg/daemon/http.go @@ -68,7 +68,7 @@ func (s *HTTPServer) healthHandler(w http.ResponseWriter, r *http.Request) { "service": "cloudsql-autoscaler", } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // readinessHandler responds to readiness probe requests @@ -84,7 +84,7 @@ func (s *HTTPServer) readinessHandler(w http.ResponseWriter, r *http.Request) { "status": "not ready", "reason": "daemon not initialized", } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) return } @@ -94,7 +94,7 @@ func (s *HTTPServer) readinessHandler(w http.ResponseWriter, r *http.Request) { "timestamp": time.Now().UTC(), } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // statusHandler provides detailed daemon status @@ -106,11 +106,11 @@ func (s *HTTPServer) statusHandler(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "error": "daemon not available", } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) return } status := s.daemon.GetStatus() w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(status) + _ = json.NewEncoder(w).Encode(status) } From 2ff38690f79400c7675a88ab5d188d3bd199098f Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Wed, 8 Jul 2026 11:34:25 -0700 Subject: [PATCH 4/4] fix(ci): lowercase docker tag and bump Dockerfile Go to 1.26.5 The Docker Build Test job never ran before (it needs test+lint, and lint was failing), which hid two issues: - the image tag used ${{ github.repository }} ("Fraser-Isbester/...") -> "repository name must be lowercase". Use the lowercase image path. - the builder image was golang:1.24.4-alpine, incompatible with the go 1.26.5 directive. Bump to golang:1.26.5-alpine. Verified with a local docker build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c02df0f..c9c11e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -114,7 +114,7 @@ jobs: context: . file: ./Dockerfile push: false - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test + tags: ${{ env.REGISTRY }}/fraser-isbester/cloudsql-autoscaler:test cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index dffc4ce..fb77a1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for cloudsql-autoscaler # Stage 1: Build environment -FROM golang:1.24.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder # Set working directory WORKDIR /app