diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b5ac385..c9c11e9 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
@@ -80,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/.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/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
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/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/go.mod b/go.mod
index 0e1be83..5f7f157 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.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.6
+ google.golang.org/protobuf v1.36.11
)
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
@@ -30,21 +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
- golang.org/x/crypto v0.39.0 // indirect
- golang.org/x/net v0.41.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
+ 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.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 f46ba45..28c7366 100644
--- a/go.sum
+++ b/go.sum
@@ -2,28 +2,49 @@ 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=
+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=
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=
+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 +55,28 @@ 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/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=
@@ -50,55 +87,75 @@ 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.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=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+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=
+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.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=
-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=
-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=
+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=
+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.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=
+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=
+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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/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=
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/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/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..e4f99e6
--- /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() *config.InstanceInfo {
+ return &config.InstanceInfo{
+ Name: "test-instance",
+ MachineType: "db-n2-standard-4",
+ State: "RUNNABLE",
+ }
+ }
+
+ It("errors when the target machine type equals the current type", func() {
+ 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()
+ 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()
+ 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()
+ 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/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)
}
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.
+
+
+
+
+
+
+
+$ go install github.com/fraser-isbester/cloudsql-autoscaler/cmd/cloudsql-autoscaler@latest
+
+
+$ cloudsql-autoscaler --project my-gcp-project
+
+
+$ 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
+
+
+
01 Smart P95 analysis
+
+ Uses 3 days of CPU/memory metrics and P95 percentiles to identify
+ sustained load and avoid scaling on temporary spikes.
+
+
+
+
02 Downtime aware
+
+ Understands Enterprise Plus
+ zero-downtime windows versus
+ Enterprise edition downtime
+ requirements before applying changes.
+
+
+
+
03 CLI · Docker · Kubernetes
+
+ Run it as a one-shot CLI, a Docker container, or a Kubernetes
+ deployment with Workload Identity.
+
+
+
+
04 Scaling profiles
+
+ Choose conservative, default, or
+ aggressive profiles to tune how eagerly it resizes.
+
+
+
+
05 Prometheus & health
+
+ Built-in Prometheus metrics plus /health,
+ /ready, and /status endpoints for
+ daemon mode.
+
+
+
+
06 Dry-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
+
+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
+
+git clone https://github.com/fraser-isbester/cloudsql-autoscaler.git
+cd cloudsql-autoscaler
+make deploy-k8s
+
+
+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);
+}