diff --git a/generated-usage-examples/copier-test/level-2/level-3/test-3.json b/generated-usage-examples/copier-test/level-2/level-3/test-3.json deleted file mode 100644 index 7c436e1..0000000 --- a/generated-usage-examples/copier-test/level-2/level-3/test-3.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - { - "test": "success" - } -] \ No newline at end of file diff --git a/generated-usage-examples/copier-test/test.txt b/generated-usage-examples/copier-test/test.txt deleted file mode 100644 index 83050f8..0000000 --- a/generated-usage-examples/copier-test/test.txt +++ /dev/null @@ -1 +0,0 @@ -Hello, world! \ No newline at end of file diff --git a/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-dev.json b/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-dev.json new file mode 100644 index 0000000..53173ba --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-dev.json @@ -0,0 +1,14 @@ +{ + "MONGODB_ATLAS_BASE_URL": "https://cloud-dev.mongodb.com", + "ATLAS_ORG_ID": "32b6e34b3d91647abb20e7b8", + "ATLAS_PROJECT_ID": "5e2211c17a3e5a48f5497de3", + "ATLAS_PROJECT_NAME": "Customer Portal - Dev", + "ATLAS_PROCESS_ID": "CustomerPortalDev-shard-00-00.ajlj3.mongodb.net:27017", + "programmatic_scaling": { + "target_tier": "M20", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} diff --git a/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-prod.json b/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-prod.json new file mode 100644 index 0000000..57134af --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/example-configs.snippet.config-prod.json @@ -0,0 +1,14 @@ +{ + "MONGODB_ATLAS_BASE_URL": "https://cloud.mongodb.com", + "ATLAS_ORG_ID": "32b6e34b3d91647abb20e7b8", + "ATLAS_PROJECT_ID": "5e2211c17a3e5a48f5497de3", + "ATLAS_PROJECT_NAME": "Customer Portal - Prod", + "ATLAS_PROCESS_ID": "CustomerPortalProd-shard-00-00.ajlj3.mongodb.net:27017", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.archive-collections.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.archive-collections.go index 88467fb..e22fdce 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.archive-collections.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.archive-collections.go @@ -47,7 +47,6 @@ func main() { fmt.Printf("\nFound %d clusters to analyze\n", len(clusters.GetResults())) - // Connect to each cluster and analyze collections for archiving failedArchives := 0 skippedCandidates := 0 totalCandidates := 0 diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-linked-orgs.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-linked-orgs.go index 990e0dd..9491457 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-linked-orgs.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-linked-orgs.go @@ -6,12 +6,12 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { @@ -55,4 +55,3 @@ func main() { fmt.Printf(" %d. %v\n", i+1, org) } } - diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go index 47994fb..6e53f89 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go @@ -9,11 +9,10 @@ import ( "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/metrics" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/metrics" ) func main() { diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go index 5ee5c43..dcfdd7f 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go @@ -6,14 +6,14 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/fileutils" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.scale-cluster-programmatically-prod.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.scale-cluster-programmatically-prod.go new file mode 100644 index 0000000..91a23ed --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.scale-cluster-programmatically-prod.go @@ -0,0 +1,180 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/clusterutils" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/scale" + + "github.com/joho/godotenv" +) + +func main() { + envFile := ".env.production" + if err := godotenv.Load(envFile); err != nil { + log.Printf("Warning: could not load %s file: %v", envFile, err) + } + + secrets, cfg, err := config.LoadAllFromEnv() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + client, err := auth.NewClient(ctx, cfg, secrets) + if err != nil { + log.Fatalf("Failed to initialize authentication client: %v", err) + } + + projectID := cfg.ProjectID + if projectID == "" { + log.Fatal("Failed to find Project ID in configuration") + } + + procDetails, err := clusterutils.ListClusterProcessDetails(ctx, client, projectID) + if err != nil { + log.Printf("Warning: unable to map detailed processes to clusters: %v", err) + } + + // Based on the configuration settings, perform the following programmatic scaling: + // - Pre-scale ahead of a known traffic spike (e.g. planned bulk inserts) + // - Reactive scale when sustained compute utilization exceeds a threshold + // + // NOTE: Prefer Atlas built-in auto-scaling for gradual growth. Use programmatic scaling for exceptional events or custom logic. + scaling := scale.LoadScalingConfig(cfg) + fmt.Printf("Starting scaling analysis for project: %s\n", projectID) + fmt.Printf("Configuration - Target tier: %s, Pre-scale: %v, CPU threshold: %.1f%%, Period: %d min, Dry run: %v\n", + scaling.TargetTier, scaling.PreScale, scaling.CPUThreshold, scaling.PeriodMinutes, scaling.DryRun) + + clusterList, _, err := client.ClustersApi.ListClusters(ctx, projectID).Execute() + if err != nil { + log.Fatalf("Failed to list clusters: %v", err) + } + + clusters := clusterList.GetResults() + fmt.Printf("\nFound %d clusters to analyze for scaling\n", len(clusters)) + + // Track scaling operations across all clusters + scalingCandidates := 0 + successfulScales := 0 + failedScales := 0 + skippedClusters := 0 + + for _, cluster := range clusters { + clusterName := cluster.GetName() + fmt.Printf("\n=== Analyzing cluster: %s ===\n", clusterName) + + // Skip clusters that are not in IDLE state + if cluster.HasStateName() && cluster.GetStateName() != "IDLE" { + fmt.Printf("- Skipping cluster %s: not in IDLE state (current: %s)\n", clusterName, cluster.GetStateName()) + skippedClusters++ + continue + } + + currentTier, err := scale.ExtractInstanceSize(&cluster) + if err != nil { + fmt.Printf("- Skipping cluster %s: failed to extract current tier: %v\n", clusterName, err) + skippedClusters++ + continue + } + fmt.Printf("- Current tier: %s, Target tier: %s\n", currentTier, scaling.TargetTier) + + // Skip if already at target tier + if strings.EqualFold(currentTier, scaling.TargetTier) { + fmt.Printf("- No action needed: cluster already at target tier %s\n", scaling.TargetTier) + continue + } + + // Shared tier handling: skip reactive CPU (metrics unavailable) unless pre-scale + if scale.IsSharedTier(currentTier) && !scaling.PreScale { + fmt.Printf("- Shared tier (%s): reactive CPU metrics unavailable; skipping (enable PreScale to force scale)\n", currentTier) + continue + } + + // Gather process info for dedicated tiers + var processID string + var primaryID string + var processIDs []string + if procs, ok := procDetails[clusterName]; ok && len(procs) > 0 { + for _, p := range procs { + processIDs = append(processIDs, p.ID) + } + if pid, okp := clusterutils.GetPrimaryProcessID(procs); okp { + primaryID = pid + } + processID = processIDs[0] + } + if len(processIDs) > 0 && !scale.IsSharedTier(currentTier) { + fmt.Printf("- Found %d processes (primary=%s)\n", len(processIDs), primaryID) + } else if processID != "" { + fmt.Printf("- Using process ID: %s for metrics\n", processID) + } + + // Evaluate scaling decision based on configuration and metrics + var shouldScale bool + var reason string + if !scale.IsSharedTier(currentTier) && len(processIDs) > 0 { // dedicated tier with multiple processes + shouldScale, reason = scale.EvaluateDecisionAggregated(ctx, client, projectID, clusterName, processIDs, primaryID, scaling) + } else if !scale.IsSharedTier(currentTier) && processID != "" { // fallback if no aggregation possible + shouldScale, reason = scale.EvaluateDecisionForProcess(ctx, client, projectID, clusterName, processID, scaling) + } else if !scale.IsSharedTier(currentTier) { // dedicated tier but no process info + shouldScale, reason = scale.EvaluateDecision(ctx, client, projectID, clusterName, scaling) + } else { // shared tier (M0/M2/M5) + shouldScale = scaling.PreScale + if shouldScale { + reason = "pre-scale event flag set (shared tier)" + } else { + reason = "shared tier without pre-scale" + } + } + if !shouldScale { + fmt.Printf("- Conditions not met: %s\n", reason) + continue + } + + scalingCandidates++ + fmt.Printf("- Scaling decision: proceed -> %s\n", reason) + + if scaling.DryRun { + fmt.Printf("- DRY_RUN=true: would scale cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + continue + } + + if err := scale.ExecuteClusterScaling(ctx, client, projectID, clusterName, &cluster, scaling.TargetTier); err != nil { + fmt.Printf("- ERROR: Failed to scale cluster %s: %v\n", clusterName, err) + failedScales++ + continue + } + fmt.Printf("- Successfully initiated scaling for cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + } + + fmt.Printf("\n=== Scaling Operation Summary ===\n") + fmt.Printf("Total clusters analyzed: %d\n", len(clusters)) + fmt.Printf("Scaling candidates identified: %d\n", scalingCandidates) + fmt.Printf("Successful scaling operations: %d\n", successfulScales) + fmt.Printf("Failed scaling operations: %d\n", failedScales) + fmt.Printf("Skipped clusters: %d\n", skippedClusters) + + if failedScales > 0 { + fmt.Printf("WARNING: %d of %d scaling operations failed\n", failedScales, scalingCandidates) + } + + if successfulScales > 0 && !scaling.DryRun { + fmt.Println("\nAtlas will perform rolling resizes with zero-downtime semantics.") + fmt.Println("Monitor status in the Atlas UI or poll cluster states until STATE_NAME becomes IDLE.") + } + fmt.Println("Scaling analysis and operations completed.") +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/.env.example b/generated-usage-examples/go/atlas-sdk-go/project-copy/.env.example deleted file mode 100644 index 66f7781..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -MONGODB_ATLAS_SERVICE_ACCOUNT_ID= -MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= -ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads # optional directory for downloads -CONFIG_PATH=./configs/config..json # path to corresponding config file \ No newline at end of file diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/.gitignore b/generated-usage-examples/go/atlas-sdk-go/project-copy/.gitignore index ac48429..b08d51b 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/.gitignore +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/.gitignore @@ -1,10 +1,11 @@ # Secrets (keep example) .env !.env.example -.env.production +.env.* # Configs (keep example) configs/config.json +configs/config.*.json !configs/config.example.json # temporary files diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/LICENSE.md b/generated-usage-examples/go/atlas-sdk-go/project-copy/LICENSE.md index c319da3..f47af0a 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/LICENSE.md +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/LICENSE.md @@ -174,28 +174,3 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md b/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md index 96b9817..da069bd 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md @@ -18,6 +18,7 @@ Currently, the repository includes examples that demonstrate the following: - Return all linked organizations from a specific billing organization - Get historical invoices for an organization - Programmatically archive Atlas cluster data +- Proactively or reactively scale clusters based on configuration As the Architecture Center documentation evolves, this repository will be updated with new examples and improvements to existing code. @@ -30,19 +31,20 @@ and improvements to existing code. │ ├── billing/ │ ├── monitoring/ │ └── performance/ -├── configs # Atlas configuration template +├── configs # Atlas configuration templates & environment-specific configs │ └── config.example.json ├── internal # Shared utilities and helpers │ ├── archive/ │ ├── auth/ │ ├── billing/ -│ ├── clusters/ +│ ├── clusterutils/ │ ├── config/ │ ├── data/ │ ├── errors/ │ ├── fileutils/ │ ├── logs/ -│ └── metrics/ +│ ├── metrics/ +│ └── scale/ ├── go.mod ├── go.sum ├── CHANGELOG.md # List of major changes to the project @@ -52,90 +54,111 @@ and improvements to existing code. ## Prerequisites -- Go 1.16 or later -- A MongoDB Atlas project and cluster -- Service account credentials with appropriate permissions. See - [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/). - -## Setting Environment Variables - -1. Create a `.env.` file in the root directory with your MongoDB Atlas service account credentials. For example, create a `.env.development` file for your dev environment: - ```dotenv - MONGODB_ATLAS_SERVICE_ACCOUNT_ID= - MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= - ATLAS_DOWNLOADS_DIR="tmp/atlas_downloads" # optional download directory - CONFIG_PATH="configs/config.development.json" # optional path to Atlas config file - ``` - > **NOTE:** For production, use a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) - > instead of environment variables. - > See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). - -2. Create a `config..json` file in the `configs/` directory with your Atlas configuration details. For example, create a `configs/config.development.json` for your dev environment: - ```json - { - "MONGODB_ATLAS_BASE_URL": "", - "ATLAS_ORG_ID": "", - "ATLAS_PROJECT_ID": "", - "ATLAS_CLUSTER_NAME": "", - "ATLAS_PROCESS_ID": "" - } - ``` - > **NOTE:** The base URL defaults to `https://cloud.mongodb.com` if not specified. +- Go 1.24 or later +- A MongoDB Atlas organization, project, and at least one cluster +- Service account credentials with appropriate permissions and IP access. See + [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/) -## Running Examples +## Environment Variables -Examples in this project are intended to be run as individual scripts. -You can also adjust them to suit your needs: +Only a small set of environment variables are required. Programmatic scaling and DR settings are provided via the JSON config file — not separate env vars. -- Modify time ranges -- Add filtering parameters -- Change output formats +Create a `.env.` file (e.g. `.env.development`): -### Billing -#### Get Historical Invoices -```bash -go run examples/billing/historical/main.go -``` -#### Get Line-Item-Level Billing Data -```bash -go run examples/billing/line_items/main.go +```dotenv +# Required service account credentials +MONGODB_ATLAS_SERVICE_ACCOUNT_ID= +MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= + +# Optional: override default config path (defaults to configs/config.json if unset) +CONFIG_PATH=configs/config.development.json + +# Optional: base directory for downloaded artifacts (logs, archives, invoices) +ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads ``` -#### Get All Linked Organizations -```bash -go run examples/billing/linked_orgs/main.go + +> NOTE: For production, store secrets in a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) instead of plain environment variables. See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). + +## Configuration File + +Create `configs/config..json` (e.g. `configs/config.development.json`). If `CONFIG_PATH` is unset, the loader falls back to `configs/config.json`. + +Minimal example: +```json +{ + "ATLAS_ORG_ID": "", + "ATLAS_PROJECT_ID": "", + "ATLAS_CLUSTER_NAME": "", + "ATLAS_PROCESS_ID": "", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": false, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} ``` -### Logs -Logs output to `./logs` as `.gz` and `.txt`. +Field notes: +- `ATLAS_PROCESS_ID` is used for examples that operate directly on a single host (logs/metrics). Format: `hostname:port`. +- `programmatic_scaling` (optional) controls proactive (pre_scale_event) and reactive (cpu_threshold over cpu_period_minutes) scaling. +- `dry_run=true` ensures scaling logic logs intent without applying changes. +- Omit `programmatic_scaling` entirely to skip scaling analysis. +- Omit `disaster_recovery` if not exercising DR examples. -#### Fetch All Host Logs -```bash -go run examples/monitoring/logs/main.go -``` +Defaults applied when absent: +- `programmatic_scaling.target_tier` → `M50` +- `programmatic_scaling.cpu_threshold` → `75.0` +- `programmatic_scaling.cpu_period_minutes` → `60` +- `programmatic_scaling.dry_run` → `true` + +## Running Examples -### Metrics -Metrics print to the console. +Each example is an independent entrypoint. Ensure your `.env.` and matching config file are in place, then: -#### Get Disk Measurements ```bash +# Example: run with development environment +cp .env.example .env.development # (or create manually) +# edit .env.development and config file with real values + +# Billing - historical invoices +go run examples/billing/historical/main.go + +# Billing - line items +go run examples/billing/line_items/main.go + +# Billing - linked organizations +go run examples/billing/linked_orgs/main.go + +# Logs - fetch host logs +go run examples/monitoring/logs/main.go + +# Metrics - disk measurements go run examples/monitoring/metrics_disk/main.go -``` -#### Get Cluster Metrics -```bash +# Metrics - process CPU metrics go run examples/monitoring/metrics_process/main.go -``` - -### Performance -#### Archive Cluster Data -```bash +# Performance - archive cluster data go run examples/performance/archiving/main.go + +# Performance - programmatic scaling (dry run by default) +go run examples/performance/scaling/main.go ``` +### Programmatic Scaling Behavior + +The scaling example evaluates each cluster: +1. Skips non-IDLE clusters. +2. Applies `pre_scale_event` first (immediate scale intent). +3. For dedicated tiers: collects per-process CPU, prioritizes primary; falls back to aggregated average across processes. +4. For shared tiers (M0/M2/M5): skips reactive CPU (metrics limited); only pre-scale can trigger. +5. When `dry_run=false`, executes a tier change to `target_tier`. + ## Changelog -For list of major changes to this project, see [CHANGELOG](CHANGELOG.md). +For a list of major changes to this project, see [CHANGELOG](CHANGELOG.md). ## Reporting Issues diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/config.example.json b/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/config.example.json index 9034951..7d57bad 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/config.example.json +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/config.example.json @@ -3,5 +3,12 @@ "ATLAS_ORG_ID": "", "ATLAS_PROJECT_ID": "", "ATLAS_CLUSTER_NAME": "", - "ATLAS_PROCESS_ID": "" + "ATLAS_PROCESS_ID": "", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": false + } } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go index 909108c..dbeb48a 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go @@ -5,14 +5,14 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/fileutils" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go index 6508426..a112a63 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go @@ -8,11 +8,10 @@ import ( "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/metrics" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/metrics" ) func main() { diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/archiving/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/archiving/main.go index dd8e7be..9a774d3 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/archiving/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/archiving/main.go @@ -46,7 +46,6 @@ func main() { fmt.Printf("\nFound %d clusters to analyze\n", len(clusters.GetResults())) - // Connect to each cluster and analyze collections for archiving failedArchives := 0 skippedCandidates := 0 totalCandidates := 0 diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/scaling/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/scaling/main.go new file mode 100644 index 0000000..edb0fea --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/performance/scaling/main.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/clusterutils" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/scale" + + "github.com/joho/godotenv" +) + +func main() { + envFile := ".env.production" + if err := godotenv.Load(envFile); err != nil { + log.Printf("Warning: could not load %s file: %v", envFile, err) + } + + secrets, cfg, err := config.LoadAllFromEnv() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + client, err := auth.NewClient(ctx, cfg, secrets) + if err != nil { + log.Fatalf("Failed to initialize authentication client: %v", err) + } + + projectID := cfg.ProjectID + if projectID == "" { + log.Fatal("Failed to find Project ID in configuration") + } + + procDetails, err := clusterutils.ListClusterProcessDetails(ctx, client, projectID) + if err != nil { + log.Printf("Warning: unable to map detailed processes to clusters: %v", err) + } + + // Based on the configuration settings, perform the following programmatic scaling: + // - Pre-scale ahead of a known traffic spike (e.g. planned bulk inserts) + // - Reactive scale when sustained compute utilization exceeds a threshold + // + // NOTE: Prefer Atlas built-in auto-scaling for gradual growth. Use programmatic scaling for exceptional events or custom logic. + scaling := scale.LoadScalingConfig(cfg) + fmt.Printf("Starting scaling analysis for project: %s\n", projectID) + fmt.Printf("Configuration - Target tier: %s, Pre-scale: %v, CPU threshold: %.1f%%, Period: %d min, Dry run: %v\n", + scaling.TargetTier, scaling.PreScale, scaling.CPUThreshold, scaling.PeriodMinutes, scaling.DryRun) + + clusterList, _, err := client.ClustersApi.ListClusters(ctx, projectID).Execute() + if err != nil { + log.Fatalf("Failed to list clusters: %v", err) + } + + clusters := clusterList.GetResults() + fmt.Printf("\nFound %d clusters to analyze for scaling\n", len(clusters)) + + // Track scaling operations across all clusters + scalingCandidates := 0 + successfulScales := 0 + failedScales := 0 + skippedClusters := 0 + + for _, cluster := range clusters { + clusterName := cluster.GetName() + fmt.Printf("\n=== Analyzing cluster: %s ===\n", clusterName) + + // Skip clusters that are not in IDLE state + if cluster.HasStateName() && cluster.GetStateName() != "IDLE" { + fmt.Printf("- Skipping cluster %s: not in IDLE state (current: %s)\n", clusterName, cluster.GetStateName()) + skippedClusters++ + continue + } + + currentTier, err := scale.ExtractInstanceSize(&cluster) + if err != nil { + fmt.Printf("- Skipping cluster %s: failed to extract current tier: %v\n", clusterName, err) + skippedClusters++ + continue + } + fmt.Printf("- Current tier: %s, Target tier: %s\n", currentTier, scaling.TargetTier) + + // Skip if already at target tier + if strings.EqualFold(currentTier, scaling.TargetTier) { + fmt.Printf("- No action needed: cluster already at target tier %s\n", scaling.TargetTier) + continue + } + + // Shared tier handling: skip reactive CPU (metrics unavailable) unless pre-scale + if scale.IsSharedTier(currentTier) && !scaling.PreScale { + fmt.Printf("- Shared tier (%s): reactive CPU metrics unavailable; skipping (enable PreScale to force scale)\n", currentTier) + continue + } + + // Gather process info for dedicated tiers + var processID string + var primaryID string + var processIDs []string + if procs, ok := procDetails[clusterName]; ok && len(procs) > 0 { + for _, p := range procs { + processIDs = append(processIDs, p.ID) + } + if pid, okp := clusterutils.GetPrimaryProcessID(procs); okp { + primaryID = pid + } + processID = processIDs[0] + } + if len(processIDs) > 0 && !scale.IsSharedTier(currentTier) { + fmt.Printf("- Found %d processes (primary=%s)\n", len(processIDs), primaryID) + } else if processID != "" { + fmt.Printf("- Using process ID: %s for metrics\n", processID) + } + + // Evaluate scaling decision based on configuration and metrics + var shouldScale bool + var reason string + if !scale.IsSharedTier(currentTier) && len(processIDs) > 0 { // dedicated tier with multiple processes + shouldScale, reason = scale.EvaluateDecisionAggregated(ctx, client, projectID, clusterName, processIDs, primaryID, scaling) + } else if !scale.IsSharedTier(currentTier) && processID != "" { // fallback if no aggregation possible + shouldScale, reason = scale.EvaluateDecisionForProcess(ctx, client, projectID, clusterName, processID, scaling) + } else if !scale.IsSharedTier(currentTier) { // dedicated tier but no process info + shouldScale, reason = scale.EvaluateDecision(ctx, client, projectID, clusterName, scaling) + } else { // shared tier (M0/M2/M5) + shouldScale = scaling.PreScale + if shouldScale { + reason = "pre-scale event flag set (shared tier)" + } else { + reason = "shared tier without pre-scale" + } + } + if !shouldScale { + fmt.Printf("- Conditions not met: %s\n", reason) + continue + } + + scalingCandidates++ + fmt.Printf("- Scaling decision: proceed -> %s\n", reason) + + if scaling.DryRun { + fmt.Printf("- DRY_RUN=true: would scale cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + continue + } + + if err := scale.ExecuteClusterScaling(ctx, client, projectID, clusterName, &cluster, scaling.TargetTier); err != nil { + fmt.Printf("- ERROR: Failed to scale cluster %s: %v\n", clusterName, err) + failedScales++ + continue + } + fmt.Printf("- Successfully initiated scaling for cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + } + + fmt.Printf("\n=== Scaling Operation Summary ===\n") + fmt.Printf("Total clusters analyzed: %d\n", len(clusters)) + fmt.Printf("Scaling candidates identified: %d\n", scalingCandidates) + fmt.Printf("Successful scaling operations: %d\n", successfulScales) + fmt.Printf("Failed scaling operations: %d\n", failedScales) + fmt.Printf("Skipped clusters: %d\n", skippedClusters) + + if failedScales > 0 { + fmt.Printf("WARNING: %d of %d scaling operations failed\n", failedScales, scalingCandidates) + } + + if successfulScales > 0 && !scaling.DryRun { + fmt.Println("\nAtlas will perform rolling resizes with zero-downtime semantics.") + fmt.Println("Monitor status in the Atlas UI or poll cluster states until STATE_NAME becomes IDLE.") + } + fmt.Println("Scaling analysis and operations completed.") +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/archive/analyze.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/archive/analyze.go index d01c43d..2284b96 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/archive/analyze.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/archive/analyze.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "atlas-sdk-go/internal/clusters" + "atlas-sdk-go/internal/clusterutils" "go.mongodb.org/atlas-sdk/v20250219001/admin" "go.mongodb.org/mongo-driver/bson" @@ -101,7 +101,7 @@ func ListCollectionsWithCounts(ctx context.Context, sdk *admin.APIClient, projec stats := make([]CollectionStat, 0) // Get the SRV connection string for the cluster - srv, err := clusters.GetClusterSRVConnectionString(ctx, sdk, projectID, clusterName) + srv, err := clusterutils.GetClusterSRVConnectionString(ctx, sdk, projectID, clusterName) if err != nil || srv == "" { return stats } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusters/utils.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusters/utils.go deleted file mode 100644 index 6f1f51f..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusters/utils.go +++ /dev/null @@ -1,71 +0,0 @@ -package clusters - -import ( - "context" - "fmt" - - "atlas-sdk-go/internal/errors" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// ListClusterNames lists all clusters in a project and returns their names. -func ListClusterNames(ctx context.Context, sdk admin.ClustersApi, p *admin.ListClustersApiParams) ([]string, error) { - req := sdk.ListClusters(ctx, p.GroupId) - clusters, _, err := req.Execute() - if err != nil { - return nil, errors.FormatError("list clusters", p.GroupId, err) - } - - var names []string - if clusters != nil && clusters.Results != nil { - for _, cluster := range *clusters.Results { - if cluster.Name != nil { - names = append(names, *cluster.Name) - } - } - } - return names, nil -} - -// GetProcessIdForCluster retrieves the process ID for a given cluster -func GetProcessIdForCluster(ctx context.Context, sdk admin.MonitoringAndLogsApi, - p *admin.ListAtlasProcessesApiParams, clusterName string) (string, error) { - - req := sdk.ListAtlasProcesses(ctx, p.GroupId) - r, _, err := req.Execute() - if err != nil { - return "", errors.FormatError("list atlas processes", p.GroupId, err) - } - if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return "", nil - } - - // Find the process for the specified cluster - for _, process := range r.GetResults() { - hostName := process.GetUserAlias() - id := process.GetId() - if hostName != "" && hostName == clusterName { - if id != "" { - return id, nil - } - } - } - - return "", fmt.Errorf("no process found for cluster %s", clusterName) -} - -// GetClusterSRVConnectionString returns the standard SRV connection string for a cluster. -func GetClusterSRVConnectionString(ctx context.Context, client *admin.APIClient, projectID, clusterName string) (string, error) { - if client == nil { - return "", fmt.Errorf("nil atlas api client") - } - cluster, _, err := client.ClustersApi.GetCluster(ctx, projectID, clusterName).Execute() - if err != nil { - return "", errors.FormatError("get cluster", projectID, err) - } - if cluster == nil || cluster.ConnectionStrings == nil || cluster.ConnectionStrings.StandardSrv == nil { - return "", fmt.Errorf("no standard SRV connection string found for cluster %s", clusterName) - } - return *cluster.ConnectionStrings.StandardSrv, nil -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/connectionstring.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/connectionstring.go new file mode 100644 index 0000000..050a862 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/connectionstring.go @@ -0,0 +1,25 @@ +package clusterutils + +import ( + "context" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" +) + +// GetClusterSRVConnectionString returns the standard SRV connection string for a cluster. +func GetClusterSRVConnectionString(ctx context.Context, client *admin.APIClient, projectID, clusterName string) (string, error) { + if client == nil { + return "", fmt.Errorf("nil atlas api client") + } + cluster, _, err := client.ClustersApi.GetCluster(ctx, projectID, clusterName).Execute() + if err != nil { + return "", errors.FormatError("get cluster", projectID, err) + } + if cluster == nil || cluster.ConnectionStrings == nil || cluster.ConnectionStrings.StandardSrv == nil { + return "", fmt.Errorf("no standard SRV connection string found for cluster %s", clusterName) + } + return *cluster.ConnectionStrings.StandardSrv, nil +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/processes.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/processes.go new file mode 100644 index 0000000..a4c524e --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/clusterutils/processes.go @@ -0,0 +1,169 @@ +package clusterutils + +import ( + "context" + "fmt" + "strings" + + "atlas-sdk-go/internal/errors" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// ListClusterNames lists all clusters in a project and returns their names. +func ListClusterNames(ctx context.Context, sdk admin.ClustersApi, p *admin.ListClustersApiParams) ([]string, error) { + req := sdk.ListClusters(ctx, p.GroupId) + clusters, _, err := req.Execute() + if err != nil { + return nil, errors.FormatError("list clusters", p.GroupId, err) + } + + var names []string + if clusters != nil && clusters.Results != nil { + for _, cluster := range *clusters.Results { + if cluster.Name != nil { + names = append(names, *cluster.Name) + } + } + } + return names, nil +} + +// GetProcessIdForCluster retrieves the first matching process ID for a given cluster name. +// It only inspects the Atlas processes list and applies simple matching heuristics. +// If no match is found it returns an empty string and no error to allow callers to decide fallback behavior. +func GetProcessIdForCluster(ctx context.Context, monApi admin.MonitoringAndLogsApi, + p *admin.ListAtlasProcessesApiParams, clusterName string) (string, error) { + if p == nil || p.GroupId == "" { + return "", fmt.Errorf("missing group id") + } + if clusterName == "" { + return "", fmt.Errorf("empty cluster name") + } + + req := monApi.ListAtlasProcesses(ctx, p.GroupId) + resp, _, err := req.Execute() + if err != nil { + return "", errors.FormatError("list atlas processes", p.GroupId, err) + } + if resp == nil || resp.Results == nil || len(*resp.Results) == 0 { + return "", nil // no processes available + } + + lc := strings.ToLower(clusterName) + for _, proc := range *resp.Results { + id := safe(proc.Id) + alias := strings.ToLower(safe(proc.UserAlias)) + host := strings.ToLower(safe(proc.Hostname)) + + if alias == lc || strings.Contains(alias, lc+"-") || strings.Contains(alias, lc+"_") { + if id != "" { + return id, nil + } + } + // hostname often embeds the cluster name + if host == lc || strings.Contains(host, lc+"-") || strings.Contains(host, lc+"_") { + if id != "" { + return id, nil + } + } + } + return "", nil +} + +// safe helper returns dereferenced string pointer or empty. +func safe(p *string) string { + if p == nil { + return "" + } + return *p +} + +// ClusterProcess describes a process linked to a cluster including its role and hostname. +type ClusterProcess struct { + ID string + Hostname string + Role string // Atlas typeName e.g. REPLICA_PRIMARY, REPLICA_SECONDARY, MONGOS +} + +// ListClusterProcessDetails returns a mapping of cluster name to a list of ClusterProcess, including role and hostname. +// If only one cluster exists, all processes are assigned to it. +func ListClusterProcessDetails(ctx context.Context, client *admin.APIClient, projectID string) (map[string][]ClusterProcess, error) { + if client == nil { + return nil, fmt.Errorf("nil client") + } + if projectID == "" { + return nil, fmt.Errorf("empty project id") + } + + clReq := client.ClustersApi.ListClusters(ctx, projectID) + clResp, _, err := clReq.Execute() + if err != nil { + return nil, errors.FormatError("list clusters", projectID, err) + } + var clusterNames []string + if clResp != nil && clResp.Results != nil { + for _, c := range *clResp.Results { + if c.Name != nil { + clusterNames = append(clusterNames, *c.Name) + } + } + } + out := make(map[string][]ClusterProcess, len(clusterNames)) + for _, n := range clusterNames { + out[n] = []ClusterProcess{} + } + if len(clusterNames) == 0 { + return out, nil + } + lowerNames := make([]string, len(clusterNames)) + for i, n := range clusterNames { + lowerNames[i] = strings.ToLower(n) + } + + prReq := client.MonitoringAndLogsApi.ListAtlasProcesses(ctx, projectID) + prResp, _, err := prReq.Execute() + if err != nil { + return nil, errors.FormatError("list atlas processes", projectID, err) + } + if prResp == nil || prResp.Results == nil { + return out, nil + } + + for _, proc := range *prResp.Results { + id := safe(proc.Id) + if id == "" { + continue + } + alias := strings.ToLower(safe(proc.UserAlias)) + host := strings.ToLower(safe(proc.Hostname)) + role := safe(proc.TypeName) + hostRaw := safe(proc.Hostname) + + matched := false + for i, cnameLower := range lowerNames { + if cnameLower == alias || cnameLower == host || + strings.Contains(alias, cnameLower+"-") || strings.Contains(host, cnameLower+"-") || + strings.Contains(alias, cnameLower+"_") || strings.Contains(host, cnameLower+"_") { + cp := ClusterProcess{ID: id, Hostname: hostRaw, Role: role} + out[clusterNames[i]] = append(out[clusterNames[i]], cp) + matched = true + } + } + if !matched && len(clusterNames) == 1 { // attribute to single cluster fallback + cp := ClusterProcess{ID: id, Hostname: hostRaw, Role: role} + out[clusterNames[0]] = append(out[clusterNames[0]], cp) + } + } + return out, nil +} + +// GetPrimaryProcessID returns the ID of a primary process if present. +func GetPrimaryProcessID(processes []ClusterProcess) (string, bool) { + for _, p := range processes { + if p.Role == "REPLICA_PRIMARY" { + return p.ID, true + } + } + return "", false +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go index efef784..e264d10 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go @@ -10,12 +10,34 @@ import ( // Config holds the configuration for connecting to MongoDB Atlas type Config struct { - BaseURL string `json:"MONGODB_ATLAS_BASE_URL"` - OrgID string `json:"ATLAS_ORG_ID"` - ProjectID string `json:"ATLAS_PROJECT_ID"` - ClusterName string `json:"ATLAS_CLUSTER_NAME"` - HostName string `json:"ATLAS_HOSTNAME"` - ProcessID string `json:"ATLAS_PROCESS_ID"` + BaseURL string `json:"MONGODB_ATLAS_BASE_URL"` + OrgID string `json:"ATLAS_ORG_ID"` + ProjectID string `json:"ATLAS_PROJECT_ID"` + ClusterName string `json:"ATLAS_CLUSTER_NAME"` + HostName string `json:"ATLAS_HOSTNAME"` + ProcessID string `json:"ATLAS_PROCESS_ID"` + DR DrOptions `json:"disaster_recovery,omitempty"` + Scaling ScalingConfig `json:"programmatic_scaling,omitempty"` +} + +// DrOptions holds the disaster recovery configuration parameters. +// Only the fields relevant to the chosen Scenario are required. +type DrOptions struct { + Scenario string `json:"scenario,omitempty"` // "regional-outage" or "data-deletion" + TargetRegion string `json:"target_region,omitempty"` // Region receiving added capacity (regional-outage) + OutageRegion string `json:"outage_region,omitempty"` // Region considered impaired (regional-outage) + AddNodes int `json:"add_nodes,omitempty"` // Number of electable nodes to add (default: 1) + SnapshotID string `json:"snapshot_id,omitempty"` // Snapshot ID to restore (data-deletion) + DryRun bool `json:"dry_run,omitempty"` // If true, only log intended actions +} + +// ScalingConfig holds the programmatic scaling configuration parameters. +type ScalingConfig struct { + TargetTier string `json:"target_tier,omitempty"` // Desired tier for scaling operations (e.g. M50) + PreScale bool `json:"pre_scale_event,omitempty"` // Immediate scale for all clusters (e.g. planned launch/event) + CPUThreshold float64 `json:"cpu_threshold,omitempty"` // Average CPU % threshold to trigger reactive scale + PeriodMinutes int `json:"cpu_period_minutes,omitempty"` // Lookback window in minutes for CPU averaging + DryRun bool `json:"dry_run,omitempty"` // If true, only log intended actions without executing } // LoadConfig reads a JSON configuration file and returns a Config struct diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/analyze.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/analyze.go new file mode 100644 index 0000000..d72de48 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/analyze.go @@ -0,0 +1,174 @@ +package scale + +import ( + "context" + "errors" + "fmt" + "strings" + + "atlas-sdk-go/internal/clusterutils" + "atlas-sdk-go/internal/metrics" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// GetAverageProcessCPU fetches host CPU metrics and returns a simple average percentage over the lookback period. +func GetAverageProcessCPU(ctx context.Context, client *admin.APIClient, projectID, clusterName string, periodMinutes int) (float64, error) { + // Defensive validation so examples/tests can pass nil or bad inputs without panics + if client == nil { + return 0, fmt.Errorf("nil atlas client") + } + if projectID == "" { + return 0, fmt.Errorf("empty project id") + } + if clusterName == "" { + return 0, fmt.Errorf("empty cluster name") + } + if periodMinutes <= 0 { + return 0, fmt.Errorf("invalid period minutes: %d", periodMinutes) + } + + procID, err := clusterutils.GetProcessIdForCluster(ctx, client.MonitoringAndLogsApi, &admin.ListAtlasProcessesApiParams{GroupId: projectID}, clusterName) + if err != nil { + return 0, err + } + if procID == "" { + return 0, fmt.Errorf("no process found for cluster %s", clusterName) + } + + granularity := "PT1M" + period := fmt.Sprintf("PT%vM", periodMinutes) + metricsList := []string{"PROCESS_CPU_USER"} + m, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, &admin.GetHostMeasurementsApiParams{ + GroupId: projectID, + ProcessId: procID, + Granularity: &granularity, + Period: &period, + M: &metricsList, + }) + if err != nil { + return 0, err + } + + if m == nil || !m.HasMeasurements() { + return 0, fmt.Errorf("no measurements returned") + } + meas := m.GetMeasurements() + if len(meas) == 0 || !meas[0].HasDataPoints() { + return 0, fmt.Errorf("no datapoints returned") + } + + total := 0.0 + count := 0.0 + for _, dp := range meas[0].GetDataPoints() { + if dp.HasValue() { + v := float64(dp.GetValue()) + total += v + count++ + } + } + if count == 0 { + return 0, fmt.Errorf("no usable datapoint values") + } + avg := total / count + // Convert fractional to % if needed + if avg <= 1.0 { + avg *= 100.0 + } + return avg, nil +} + +// GetAverageCPUForProcess fetches host CPU metrics for a specific process ID and returns an average percentage. +func GetAverageCPUForProcess(ctx context.Context, client *admin.APIClient, projectID, processID string, periodMinutes int) (float64, error) { + if client == nil { + return 0, fmt.Errorf("nil atlas client") + } + if projectID == "" { + return 0, fmt.Errorf("empty project id") + } + if processID == "" { + return 0, fmt.Errorf("empty process id") + } + if periodMinutes <= 0 { + return 0, fmt.Errorf("invalid period minutes: %d", periodMinutes) + } + granularity := "PT1M" + period := fmt.Sprintf("PT%vM", periodMinutes) + metricsList := []string{"PROCESS_CPU_USER"} + m, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, &admin.GetHostMeasurementsApiParams{ + GroupId: projectID, + ProcessId: processID, + Granularity: &granularity, + Period: &period, + M: &metricsList, + }) + if err != nil { + return 0, err + } + if m == nil || !m.HasMeasurements() { + return 0, fmt.Errorf("no measurements returned") + } + meas := m.GetMeasurements() + if len(meas) == 0 || !meas[0].HasDataPoints() { + return 0, fmt.Errorf("no datapoints returned") + } + total := 0.0 + count := 0.0 + for _, dp := range meas[0].GetDataPoints() { + if dp.HasValue() { + v := float64(dp.GetValue()) + total += v + count++ + } + } + if count == 0 { + return 0, fmt.Errorf("no usable datapoint values") + } + avg := total / count + if avg <= 1.0 { // convert fractional to percent + avg *= 100.0 + } + return avg, nil +} + +// GetAverageCPUForProcesses returns a map of processID -> average CPU (percent) ignoring processes with errors. +func GetAverageCPUForProcesses(ctx context.Context, client *admin.APIClient, projectID string, processIDs []string, periodMinutes int) map[string]float64 { + out := make(map[string]float64, len(processIDs)) + for _, pid := range processIDs { + avg, err := GetAverageCPUForProcess(ctx, client, projectID, pid, periodMinutes) + if err != nil { + continue + } + out[pid] = avg + } + return out +} + +// ExtractInstanceSize retrieves the electable instance size from the first region config. +func ExtractInstanceSize(cur *admin.ClusterDescription20240805) (string, error) { + if cur == nil || !cur.HasReplicationSpecs() { + return "", errors.New("cluster has no replication specs") + } + repl := cur.GetReplicationSpecs() + if len(repl) == 0 { + return "", errors.New("no replication specs entries") + } + rcs := repl[0].GetRegionConfigs() + if len(rcs) == 0 || !rcs[0].HasElectableSpecs() { + return "", errors.New("no region config electable specs") + } + es := rcs[0].GetElectableSpecs() + if !es.HasInstanceSize() { + return "", errors.New("electable specs missing instance size") + } + return es.GetInstanceSize(), nil +} + +// IsSharedTier returns true if the tier is not a dedicated tier (M0, M2, M5) cluster. +func IsSharedTier(tier string) bool { + if tier == "" { + return false + } + upper := strings.ToUpper(tier) + return upper == "M0" || upper == "M2" || upper == "M5" +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/config.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/config.go new file mode 100644 index 0000000..707efce --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/config.go @@ -0,0 +1,35 @@ +package scale + +import ( + "atlas-sdk-go/internal/config" +) + +// ScalingConfig exposes config within scale package for tests and callers while reusing config.ScalingConfig. +type ScalingConfig = config.ScalingConfig + +const ( + defaultTargetTier = "M50" + defaultCPUThreshold = 75.0 + defaultPeriodMinutes = 60 +) + +// LoadScalingConfig loads programmatic scaling configuration with sensible defaults. +// Defaults are applied for missing optional fields to align with Atlas auto-scaling guidance. +func LoadScalingConfig(cfg config.Config) config.ScalingConfig { + sc := cfg.Scaling + + // Apply defaults for missing values + if sc.TargetTier == "" { + sc.TargetTier = defaultTargetTier + } + + if sc.CPUThreshold == 0 { + sc.CPUThreshold = defaultCPUThreshold + } + + if sc.PeriodMinutes == 0 { + sc.PeriodMinutes = defaultPeriodMinutes + } + + return sc +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/evaluate.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/evaluate.go new file mode 100644 index 0000000..c32f373 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/evaluate.go @@ -0,0 +1,104 @@ +package scale + +import ( + "context" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/config" +) + +// EvaluateDecision returns true if scaling should occur and a human-readable reason. +func EvaluateDecision(ctx context.Context, client *admin.APIClient, projectID, clusterName string, sc config.ScalingConfig) (bool, string) { + // Pre-scale always wins (explicit operator intent for predictable events) + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + + // Reactive scaling based on sustained CPU utilization + // Aligned with Atlas auto-scaling guidance: 75% for 1 hour triggers upscaling + avgCPU, err := GetAverageProcessCPU(ctx, client, projectID, clusterName, sc.PeriodMinutes) + if err != nil { + fmt.Printf(" Warning: unable to compute average CPU for reactive scaling: %v\n", err) + return false, "metrics unavailable for reactive scaling decision" + } + + fmt.Printf(" Average CPU last %d minutes: %.1f%% (threshold: %.1f%%)\n", + sc.PeriodMinutes, avgCPU, sc.CPUThreshold) + + if avgCPU > sc.CPUThreshold { + return true, fmt.Sprintf("sustained CPU utilization %.1f%% > %.1f%% threshold over %d minutes", + avgCPU, sc.CPUThreshold, sc.PeriodMinutes) + } + + return false, fmt.Sprintf("CPU utilization %.1f%% below threshold %.1f%%", avgCPU, sc.CPUThreshold) +} + +// EvaluateDecisionForProcess mirrors EvaluateDecision but uses an explicit process ID. +func EvaluateDecisionForProcess(ctx context.Context, client *admin.APIClient, projectID, clusterName, processID string, sc config.ScalingConfig) (bool, string) { + // Pre-scale always wins (explicit operator intent for predictable events) + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + + // Reactive scaling based on sustained CPU utilization + // Aligned with Atlas auto-scaling guidance: 75% for 1 hour triggers upscaling + avgCPU, err := GetAverageCPUForProcess(ctx, client, projectID, processID, sc.PeriodMinutes) + if err != nil { + fmt.Printf(" Warning: unable to compute average CPU for reactive scaling (cluster=%s process=%s): %v\n", clusterName, processID, err) + return false, "metrics unavailable for reactive scaling decision" + } + + fmt.Printf(" Average CPU last %d minutes (process %s): %.1f%% (threshold: %.1f%%)\n", + sc.PeriodMinutes, processID, avgCPU, sc.CPUThreshold) + + if avgCPU > sc.CPUThreshold { + return true, fmt.Sprintf("sustained CPU utilization %.1f%% > %.1f%% threshold over %d minutes", + avgCPU, sc.CPUThreshold, sc.PeriodMinutes) + } + + return false, fmt.Sprintf("CPU utilization %.1f%% below threshold %.1f%%", avgCPU, sc.CPUThreshold) +} + +// EvaluateDecisionAggregated evaluates scaling using multiple process metrics. +// Strategy: +// 1. If PreScale set -> scale. +// 2. If primary metrics available and exceed threshold -> scale. +// 3. Else compute average across all available processes -> scale if exceeds threshold. +// 4. If no metrics -> not scale (metrics unavailable). +func EvaluateDecisionAggregated(ctx context.Context, client *admin.APIClient, projectID, clusterName string, processIDs []string, primaryID string, sc config.ScalingConfig) (bool, string) { + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + if client == nil || projectID == "" || clusterName == "" || sc.PeriodMinutes <= 0 { + return false, "invalid inputs for aggregated evaluation" + } + if len(processIDs) == 0 { + return EvaluateDecision(ctx, client, projectID, clusterName, sc) + } + cpus := GetAverageCPUForProcesses(ctx, client, projectID, processIDs, sc.PeriodMinutes) + if len(cpus) == 0 { + fmt.Printf(" Warning: no usable metrics across %d processes for cluster %s\n", len(processIDs), clusterName) + return false, "metrics unavailable for reactive scaling decision" + } + if primaryID != "" { + if v, ok := cpus[primaryID]; ok { + fmt.Printf(" Primary process %s average CPU: %.1f%% (threshold: %.1f%%)\n", primaryID, v, sc.CPUThreshold) + if v > sc.CPUThreshold { + return true, fmt.Sprintf("primary CPU %.1f%% > %.1f%% threshold", v, sc.CPUThreshold) + } + } + } + // Aggregate across all processes + sum := 0.0 + for _, v := range cpus { + sum += v + } + agg := sum / float64(len(cpus)) + fmt.Printf(" Aggregated average CPU across %d processes: %.1f%% (threshold: %.1f%%)\n", len(cpus), agg, sc.CPUThreshold) + if agg > sc.CPUThreshold { + return true, fmt.Sprintf("aggregated CPU %.1f%% > %.1f%% threshold", agg, sc.CPUThreshold) + } + return false, fmt.Sprintf("aggregated CPU %.1f%% below threshold %.1f%%", agg, sc.CPUThreshold) +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/execute.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/execute.go new file mode 100644 index 0000000..2dfab61 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/scale/execute.go @@ -0,0 +1,68 @@ +package scale + +import ( + "context" + "errors" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// ExecuteClusterScaling performs the scaling operation by updating the cluster's instance sizes. +func ExecuteClusterScaling(ctx context.Context, client *admin.APIClient, projectID, clusterName string, + cluster *admin.ClusterDescription20240805, targetTier string) error { + // Defensive validation so example tests using nil / empty parameters don't panic. + if client == nil { + return errors.New("nil atlas client") + } + if projectID == "" { + return errors.New("empty project id") + } + if clusterName == "" { + return errors.New("empty cluster name") + } + if targetTier == "" { + return errors.New("empty target tier") + } + + payload := buildScalePayload(cluster, targetTier) + if payload == nil { + return fmt.Errorf("failed to build scaling payload") + } + + _, _, err := client.ClustersApi.UpdateCluster(ctx, projectID, clusterName, payload).Execute() + return err +} + +// buildScalePayload copies current replication specs and updates instance sizes to targetTier. +func buildScalePayload(cur *admin.ClusterDescription20240805, targetTier string) *admin.ClusterDescription20240805 { + payload := admin.NewClusterDescription20240805() + if cur == nil || !cur.HasReplicationSpecs() { + return nil + } + + repl := cur.GetReplicationSpecs() + for i := range repl { + rcs := repl[i].GetRegionConfigs() + for j := range rcs { + if rcs[j].HasElectableSpecs() { + es := rcs[j].GetElectableSpecs() + es.SetInstanceSize(targetTier) + rcs[j].SetElectableSpecs(es) + } + if rcs[j].HasReadOnlySpecs() { + ros := rcs[j].GetReadOnlySpecs() + ros.SetInstanceSize(targetTier) + rcs[j].SetReadOnlySpecs(ros) + } + if rcs[j].HasAnalyticsSpecs() { + as := rcs[j].GetAnalyticsSpecs() + as.SetInstanceSize(targetTier) + rcs[j].SetAnalyticsSpecs(as) + } + } + repl[i].SetRegionConfigs(rcs) + } + payload.SetReplicationSpecs(repl) + return payload +} diff --git a/generated-usage-examples/renamed-test-examples/example.js b/generated-usage-examples/renamed-test-examples/example.js deleted file mode 100644 index a2248ef..0000000 --- a/generated-usage-examples/renamed-test-examples/example.js +++ /dev/null @@ -1 +0,0 @@ -console.log('test'); diff --git a/generated-usage-examples/renamed-test-examples/subdir/renamed-nested.py b/generated-usage-examples/renamed-test-examples/subdir/renamed-nested.py deleted file mode 100644 index bb3ea65..0000000 --- a/generated-usage-examples/renamed-test-examples/subdir/renamed-nested.py +++ /dev/null @@ -1 +0,0 @@ -print('nested test') diff --git a/usage-examples/go/atlas-sdk-go/.env.development.example b/usage-examples/go/atlas-sdk-go/.env.development.example new file mode 100644 index 0000000..b18adff --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/.env.development.example @@ -0,0 +1,11 @@ +MONGODB_ATLAS_SERVICE_ACCOUNT_ID=mdb_sa_id_123abc123abc123abc123abc +MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET=mdb_sa_sk_123abc123abc123abc123abc123abc123abc +ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads # optional directory for downloads +CONFIG_PATH=./configs/config.development.json + +# Programmatic scaling settings +SCALE_TO_TIER=M30 +PRE_SCALE_EVENT=false +CPU_THRESHOLD=75.0 +CPU_PERIOD_MINUTES=60 +DRY_RUN=true \ No newline at end of file diff --git a/usage-examples/go/atlas-sdk-go/.env.example b/usage-examples/go/atlas-sdk-go/.env.example index 66f7781..6204107 100644 --- a/usage-examples/go/atlas-sdk-go/.env.example +++ b/usage-examples/go/atlas-sdk-go/.env.example @@ -1,4 +1,11 @@ MONGODB_ATLAS_SERVICE_ACCOUNT_ID= MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads # optional directory for downloads -CONFIG_PATH=./configs/config..json # path to corresponding config file \ No newline at end of file +CONFIG_PATH=./configs/config..json # path to corresponding config file + +# Programmatic scaling settings +SCALE_TO_TIER=M50 +PRE_SCALE_EVENT=true +CPU_THRESHOLD=75.0 +CPU_PERIOD_MINUTES=60 +DRY_RUN=false \ No newline at end of file diff --git a/usage-examples/go/atlas-sdk-go/.env.production.example b/usage-examples/go/atlas-sdk-go/.env.production.example new file mode 100644 index 0000000..7c92596 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/.env.production.example @@ -0,0 +1,11 @@ +MONGODB_ATLAS_SERVICE_ACCOUNT_ID=mdb_sa_id_123abc123abc123abc123abc +MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET=mdb_sa_sk_123abc123abc123abc123abc123abc123abc +ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads # optional directory for downloads +CONFIG_PATH=./configs/config.development.json + +# Programmatic scaling settings +SCALE_TO_TIER=M50 +PRE_SCALE_EVENT=true +CPU_THRESHOLD=75.0 +CPU_PERIOD_MINUTES=60 +DRY_RUN=false \ No newline at end of file diff --git a/usage-examples/go/atlas-sdk-go/.gitignore b/usage-examples/go/atlas-sdk-go/.gitignore index ac48429..b08d51b 100644 --- a/usage-examples/go/atlas-sdk-go/.gitignore +++ b/usage-examples/go/atlas-sdk-go/.gitignore @@ -1,10 +1,11 @@ # Secrets (keep example) .env !.env.example -.env.production +.env.* # Configs (keep example) configs/config.json +configs/config.*.json !configs/config.example.json # temporary files diff --git a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md index 074dc64..ea643f8 100644 --- a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md +++ b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md @@ -22,9 +22,10 @@ Files are copied . ├── examples/ # Self-contained, runnable examples by category │ ├── billing/ -│ └── monitoring/ +│ ├── monitoring/ +│ └── performance/ ├── configs/ # Atlas details -├── internal # Shared utilities and helpers (NOTE: ALL TEST FILES ARE INTERNAL ONLY - DON'T COPY TO ARTIFACT REPO) +├── internal/ # Shared utilities and helpers (NOTE: ALL TEST FILES ARE INTERNAL ONLY - DON'T COPY TO ARTIFACT REPO) ├── go.mod ├── CHANGELOG.md # User-facing list of major project changes │── README.md # User-facing README for copied project diff --git a/usage-examples/go/atlas-sdk-go/LICENSE.md b/usage-examples/go/atlas-sdk-go/LICENSE.md index c319da3..f47af0a 100644 --- a/usage-examples/go/atlas-sdk-go/LICENSE.md +++ b/usage-examples/go/atlas-sdk-go/LICENSE.md @@ -174,28 +174,3 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/usage-examples/go/atlas-sdk-go/README.md b/usage-examples/go/atlas-sdk-go/README.md index 96b9817..da069bd 100644 --- a/usage-examples/go/atlas-sdk-go/README.md +++ b/usage-examples/go/atlas-sdk-go/README.md @@ -18,6 +18,7 @@ Currently, the repository includes examples that demonstrate the following: - Return all linked organizations from a specific billing organization - Get historical invoices for an organization - Programmatically archive Atlas cluster data +- Proactively or reactively scale clusters based on configuration As the Architecture Center documentation evolves, this repository will be updated with new examples and improvements to existing code. @@ -30,19 +31,20 @@ and improvements to existing code. │ ├── billing/ │ ├── monitoring/ │ └── performance/ -├── configs # Atlas configuration template +├── configs # Atlas configuration templates & environment-specific configs │ └── config.example.json ├── internal # Shared utilities and helpers │ ├── archive/ │ ├── auth/ │ ├── billing/ -│ ├── clusters/ +│ ├── clusterutils/ │ ├── config/ │ ├── data/ │ ├── errors/ │ ├── fileutils/ │ ├── logs/ -│ └── metrics/ +│ ├── metrics/ +│ └── scale/ ├── go.mod ├── go.sum ├── CHANGELOG.md # List of major changes to the project @@ -52,90 +54,111 @@ and improvements to existing code. ## Prerequisites -- Go 1.16 or later -- A MongoDB Atlas project and cluster -- Service account credentials with appropriate permissions. See - [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/). - -## Setting Environment Variables - -1. Create a `.env.` file in the root directory with your MongoDB Atlas service account credentials. For example, create a `.env.development` file for your dev environment: - ```dotenv - MONGODB_ATLAS_SERVICE_ACCOUNT_ID= - MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= - ATLAS_DOWNLOADS_DIR="tmp/atlas_downloads" # optional download directory - CONFIG_PATH="configs/config.development.json" # optional path to Atlas config file - ``` - > **NOTE:** For production, use a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) - > instead of environment variables. - > See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). - -2. Create a `config..json` file in the `configs/` directory with your Atlas configuration details. For example, create a `configs/config.development.json` for your dev environment: - ```json - { - "MONGODB_ATLAS_BASE_URL": "", - "ATLAS_ORG_ID": "", - "ATLAS_PROJECT_ID": "", - "ATLAS_CLUSTER_NAME": "", - "ATLAS_PROCESS_ID": "" - } - ``` - > **NOTE:** The base URL defaults to `https://cloud.mongodb.com` if not specified. +- Go 1.24 or later +- A MongoDB Atlas organization, project, and at least one cluster +- Service account credentials with appropriate permissions and IP access. See + [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/) -## Running Examples +## Environment Variables -Examples in this project are intended to be run as individual scripts. -You can also adjust them to suit your needs: +Only a small set of environment variables are required. Programmatic scaling and DR settings are provided via the JSON config file — not separate env vars. -- Modify time ranges -- Add filtering parameters -- Change output formats +Create a `.env.` file (e.g. `.env.development`): -### Billing -#### Get Historical Invoices -```bash -go run examples/billing/historical/main.go -``` -#### Get Line-Item-Level Billing Data -```bash -go run examples/billing/line_items/main.go +```dotenv +# Required service account credentials +MONGODB_ATLAS_SERVICE_ACCOUNT_ID= +MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET= + +# Optional: override default config path (defaults to configs/config.json if unset) +CONFIG_PATH=configs/config.development.json + +# Optional: base directory for downloaded artifacts (logs, archives, invoices) +ATLAS_DOWNLOADS_DIR=tmp/atlas_downloads ``` -#### Get All Linked Organizations -```bash -go run examples/billing/linked_orgs/main.go + +> NOTE: For production, store secrets in a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) instead of plain environment variables. See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). + +## Configuration File + +Create `configs/config..json` (e.g. `configs/config.development.json`). If `CONFIG_PATH` is unset, the loader falls back to `configs/config.json`. + +Minimal example: +```json +{ + "ATLAS_ORG_ID": "", + "ATLAS_PROJECT_ID": "", + "ATLAS_CLUSTER_NAME": "", + "ATLAS_PROCESS_ID": "", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": false, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} ``` -### Logs -Logs output to `./logs` as `.gz` and `.txt`. +Field notes: +- `ATLAS_PROCESS_ID` is used for examples that operate directly on a single host (logs/metrics). Format: `hostname:port`. +- `programmatic_scaling` (optional) controls proactive (pre_scale_event) and reactive (cpu_threshold over cpu_period_minutes) scaling. +- `dry_run=true` ensures scaling logic logs intent without applying changes. +- Omit `programmatic_scaling` entirely to skip scaling analysis. +- Omit `disaster_recovery` if not exercising DR examples. -#### Fetch All Host Logs -```bash -go run examples/monitoring/logs/main.go -``` +Defaults applied when absent: +- `programmatic_scaling.target_tier` → `M50` +- `programmatic_scaling.cpu_threshold` → `75.0` +- `programmatic_scaling.cpu_period_minutes` → `60` +- `programmatic_scaling.dry_run` → `true` + +## Running Examples -### Metrics -Metrics print to the console. +Each example is an independent entrypoint. Ensure your `.env.` and matching config file are in place, then: -#### Get Disk Measurements ```bash +# Example: run with development environment +cp .env.example .env.development # (or create manually) +# edit .env.development and config file with real values + +# Billing - historical invoices +go run examples/billing/historical/main.go + +# Billing - line items +go run examples/billing/line_items/main.go + +# Billing - linked organizations +go run examples/billing/linked_orgs/main.go + +# Logs - fetch host logs +go run examples/monitoring/logs/main.go + +# Metrics - disk measurements go run examples/monitoring/metrics_disk/main.go -``` -#### Get Cluster Metrics -```bash +# Metrics - process CPU metrics go run examples/monitoring/metrics_process/main.go -``` - -### Performance -#### Archive Cluster Data -```bash +# Performance - archive cluster data go run examples/performance/archiving/main.go + +# Performance - programmatic scaling (dry run by default) +go run examples/performance/scaling/main.go ``` +### Programmatic Scaling Behavior + +The scaling example evaluates each cluster: +1. Skips non-IDLE clusters. +2. Applies `pre_scale_event` first (immediate scale intent). +3. For dedicated tiers: collects per-process CPU, prioritizes primary; falls back to aggregated average across processes. +4. For shared tiers (M0/M2/M5): skips reactive CPU (metrics limited); only pre-scale can trigger. +5. When `dry_run=false`, executes a tier change to `target_tier`. + ## Changelog -For list of major changes to this project, see [CHANGELOG](CHANGELOG.md). +For a list of major changes to this project, see [CHANGELOG](CHANGELOG.md). ## Reporting Issues diff --git a/usage-examples/go/atlas-sdk-go/configs/config.example.json b/usage-examples/go/atlas-sdk-go/configs/config.example.json index 9034951..7d57bad 100644 --- a/usage-examples/go/atlas-sdk-go/configs/config.example.json +++ b/usage-examples/go/atlas-sdk-go/configs/config.example.json @@ -3,5 +3,12 @@ "ATLAS_ORG_ID": "", "ATLAS_PROJECT_ID": "", "ATLAS_CLUSTER_NAME": "", - "ATLAS_PROCESS_ID": "" + "ATLAS_PROCESS_ID": "", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": false + } } diff --git a/usage-examples/go/atlas-sdk-go/configs/example-configs.json b/usage-examples/go/atlas-sdk-go/configs/example-configs.json new file mode 100644 index 0000000..53b4e3d --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/configs/example-configs.json @@ -0,0 +1,33 @@ +// :snippet-start: config-prod +{ + "MONGODB_ATLAS_BASE_URL": "https://cloud.mongodb.com", + "ATLAS_ORG_ID": "32b6e34b3d91647abb20e7b8", + "ATLAS_PROJECT_ID": "5e2211c17a3e5a48f5497de3", + "ATLAS_PROJECT_NAME": "Customer Portal - Prod", + "ATLAS_PROCESS_ID": "CustomerPortalProd-shard-00-00.ajlj3.mongodb.net:27017", + "programmatic_scaling": { + "target_tier": "M50", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} +// :snippet-end: [config-prod] + +// :snippet-start: config-dev +{ + "MONGODB_ATLAS_BASE_URL": "https://cloud-dev.mongodb.com", + "ATLAS_ORG_ID": "32b6e34b3d91647abb20e7b8", + "ATLAS_PROJECT_ID": "5e2211c17a3e5a48f5497de3", + "ATLAS_PROJECT_NAME": "Customer Portal - Dev", + "ATLAS_PROCESS_ID": "CustomerPortalDev-shard-00-00.ajlj3.mongodb.net:27017", + "programmatic_scaling": { + "target_tier": "M20", + "pre_scale_event": true, + "cpu_threshold": 75.0, + "cpu_period_minutes": 60, + "dry_run": true + } +} +// :snippet-end: [config-dev] \ No newline at end of file diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go index 5a178a2..64e519f 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -9,14 +9,14 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/fileutils" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go index 4190a71..5513d00 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go @@ -12,11 +12,10 @@ import ( "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/metrics" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/metrics" ) func main() { diff --git a/usage-examples/go/atlas-sdk-go/examples/performance/archiving/main.go b/usage-examples/go/atlas-sdk-go/examples/performance/archiving/main.go index 1e5c41b..9f2b1f9 100644 --- a/usage-examples/go/atlas-sdk-go/examples/performance/archiving/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/performance/archiving/main.go @@ -50,7 +50,6 @@ func main() { fmt.Printf("\nFound %d clusters to analyze\n", len(clusters.GetResults())) - // Connect to each cluster and analyze collections for archiving failedArchives := 0 skippedCandidates := 0 totalCandidates := 0 diff --git a/usage-examples/go/atlas-sdk-go/examples/performance/scaling/main.go b/usage-examples/go/atlas-sdk-go/examples/performance/scaling/main.go new file mode 100644 index 0000000..1956bf9 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/performance/scaling/main.go @@ -0,0 +1,212 @@ +// :snippet-start: scale-cluster-programmatically-prod +// :state-remove-start: copy +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +// :state-remove-end: [copy] +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/clusterutils" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/scale" + + "github.com/joho/godotenv" +) + +func main() { + envFile := ".env.production" + if err := godotenv.Load(envFile); err != nil { + log.Printf("Warning: could not load %s file: %v", envFile, err) + } + + secrets, cfg, err := config.LoadAllFromEnv() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + client, err := auth.NewClient(ctx, cfg, secrets) + if err != nil { + log.Fatalf("Failed to initialize authentication client: %v", err) + } + + projectID := cfg.ProjectID + if projectID == "" { + log.Fatal("Failed to find Project ID in configuration") + } + + procDetails, err := clusterutils.ListClusterProcessDetails(ctx, client, projectID) + if err != nil { + log.Printf("Warning: unable to map detailed processes to clusters: %v", err) + } + + // Based on the configuration settings, perform the following programmatic scaling: + // - Pre-scale ahead of a known traffic spike (e.g. planned bulk inserts) + // - Reactive scale when sustained compute utilization exceeds a threshold + // + // NOTE: Prefer Atlas built-in auto-scaling for gradual growth. Use programmatic scaling for exceptional events or custom logic. + scaling := scale.LoadScalingConfig(cfg) + fmt.Printf("Starting scaling analysis for project: %s\n", projectID) + fmt.Printf("Configuration - Target tier: %s, Pre-scale: %v, CPU threshold: %.1f%%, Period: %d min, Dry run: %v\n", + scaling.TargetTier, scaling.PreScale, scaling.CPUThreshold, scaling.PeriodMinutes, scaling.DryRun) + + clusterList, _, err := client.ClustersApi.ListClusters(ctx, projectID).Execute() + if err != nil { + log.Fatalf("Failed to list clusters: %v", err) + } + + clusters := clusterList.GetResults() + fmt.Printf("\nFound %d clusters to analyze for scaling\n", len(clusters)) + + // Track scaling operations across all clusters + scalingCandidates := 0 + successfulScales := 0 + failedScales := 0 + skippedClusters := 0 + + for _, cluster := range clusters { + clusterName := cluster.GetName() + fmt.Printf("\n=== Analyzing cluster: %s ===\n", clusterName) + + // Skip clusters that are not in IDLE state + if cluster.HasStateName() && cluster.GetStateName() != "IDLE" { + fmt.Printf("- Skipping cluster %s: not in IDLE state (current: %s)\n", clusterName, cluster.GetStateName()) + skippedClusters++ + continue + } + + currentTier, err := scale.ExtractInstanceSize(&cluster) + if err != nil { + fmt.Printf("- Skipping cluster %s: failed to extract current tier: %v\n", clusterName, err) + skippedClusters++ + continue + } + fmt.Printf("- Current tier: %s, Target tier: %s\n", currentTier, scaling.TargetTier) + + // Skip if already at target tier + if strings.EqualFold(currentTier, scaling.TargetTier) { + fmt.Printf("- No action needed: cluster already at target tier %s\n", scaling.TargetTier) + continue + } + + // Shared tier handling: skip reactive CPU (metrics unavailable) unless pre-scale + if scale.IsSharedTier(currentTier) && !scaling.PreScale { + fmt.Printf("- Shared tier (%s): reactive CPU metrics unavailable; skipping (enable PreScale to force scale)\n", currentTier) + continue + } + + // Gather process info for dedicated tiers + var processID string + var primaryID string + var processIDs []string + if procs, ok := procDetails[clusterName]; ok && len(procs) > 0 { + for _, p := range procs { + processIDs = append(processIDs, p.ID) + } + if pid, okp := clusterutils.GetPrimaryProcessID(procs); okp { + primaryID = pid + } + processID = processIDs[0] + } + if len(processIDs) > 0 && !scale.IsSharedTier(currentTier) { + fmt.Printf("- Found %d processes (primary=%s)\n", len(processIDs), primaryID) + } else if processID != "" { + fmt.Printf("- Using process ID: %s for metrics\n", processID) + } + + // Evaluate scaling decision based on configuration and metrics + var shouldScale bool + var reason string + if !scale.IsSharedTier(currentTier) && len(processIDs) > 0 { // dedicated tier with multiple processes + shouldScale, reason = scale.EvaluateDecisionAggregated(ctx, client, projectID, clusterName, processIDs, primaryID, scaling) + } else if !scale.IsSharedTier(currentTier) && processID != "" { // fallback if no aggregation possible + shouldScale, reason = scale.EvaluateDecisionForProcess(ctx, client, projectID, clusterName, processID, scaling) + } else if !scale.IsSharedTier(currentTier) { // dedicated tier but no process info + shouldScale, reason = scale.EvaluateDecision(ctx, client, projectID, clusterName, scaling) + } else { // shared tier (M0/M2/M5) + shouldScale = scaling.PreScale + if shouldScale { + reason = "pre-scale event flag set (shared tier)" + } else { + reason = "shared tier without pre-scale" + } + } + if !shouldScale { + fmt.Printf("- Conditions not met: %s\n", reason) + continue + } + + scalingCandidates++ + fmt.Printf("- Scaling decision: proceed -> %s\n", reason) + + if scaling.DryRun { + fmt.Printf("- DRY_RUN=true: would scale cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + continue + } + + if err := scale.ExecuteClusterScaling(ctx, client, projectID, clusterName, &cluster, scaling.TargetTier); err != nil { + fmt.Printf("- ERROR: Failed to scale cluster %s: %v\n", clusterName, err) + failedScales++ + continue + } + fmt.Printf("- Successfully initiated scaling for cluster %s from %s to %s\n", + clusterName, currentTier, scaling.TargetTier) + successfulScales++ + } + + fmt.Printf("\n=== Scaling Operation Summary ===\n") + fmt.Printf("Total clusters analyzed: %d\n", len(clusters)) + fmt.Printf("Scaling candidates identified: %d\n", scalingCandidates) + fmt.Printf("Successful scaling operations: %d\n", successfulScales) + fmt.Printf("Failed scaling operations: %d\n", failedScales) + fmt.Printf("Skipped clusters: %d\n", skippedClusters) + + if failedScales > 0 { + fmt.Printf("WARNING: %d of %d scaling operations failed\n", failedScales, scalingCandidates) + } + + if successfulScales > 0 && !scaling.DryRun { + fmt.Println("\nAtlas will perform rolling resizes with zero-downtime semantics.") + fmt.Println("Monitor status in the Atlas UI or poll cluster states until STATE_NAME becomes IDLE.") + } + fmt.Println("Scaling analysis and operations completed.") +} + +// :snippet-end: [scale-cluster-programmatically-prod] +// :state-remove-start: copy +// NOTE: INTERNAL +// ** OUTPUT EXAMPLE ** +// +//Starting scaling analysis for project: 5f60207f14dfb25d23101102 +//Configuration - Target tier: M50, Pre-scale: true, CPU threshold: 75.0%, Period: 60 min, Dry run: true +// +//Found 2 clusters to analyze for scaling +// +//=== Analyzing cluster: Cluster0 === +//- Current tier: M10, Target tier: M50 +//- Found 3 processes (primary=atlas-6yd18i-shard-00-01.nr3ko.mongodb.net:27017) +//- Scaling decision: proceed -> pre-scale event flag set (predictable traffic spike) +//- DRY_RUN=true: would scale cluster Cluster0 from M10 to M50 +// +//=== Analyzing cluster: AtlasCluster === +//- Current tier: M0, Target tier: M50 +//- Scaling decision: proceed -> pre-scale event flag set (shared tier) +//- DRY_RUN=true: would scale cluster AtlasCluster from M0 to M50 +// +//=== Scaling Operation Summary === +//Total clusters analyzed: 2 +//Scaling candidates identified: 2 +//Successful scaling operations: 2 +//Failed scaling operations: 0 +//Skipped clusters: 0 +//Scaling analysis and operations completed. +// :state-remove-end: [copy] diff --git a/usage-examples/go/atlas-sdk-go/internal/archive/analyze.go b/usage-examples/go/atlas-sdk-go/internal/archive/analyze.go index d01c43d..2284b96 100644 --- a/usage-examples/go/atlas-sdk-go/internal/archive/analyze.go +++ b/usage-examples/go/atlas-sdk-go/internal/archive/analyze.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "atlas-sdk-go/internal/clusters" + "atlas-sdk-go/internal/clusterutils" "go.mongodb.org/atlas-sdk/v20250219001/admin" "go.mongodb.org/mongo-driver/bson" @@ -101,7 +101,7 @@ func ListCollectionsWithCounts(ctx context.Context, sdk *admin.APIClient, projec stats := make([]CollectionStat, 0) // Get the SRV connection string for the cluster - srv, err := clusters.GetClusterSRVConnectionString(ctx, sdk, projectID, clusterName) + srv, err := clusterutils.GetClusterSRVConnectionString(ctx, sdk, projectID, clusterName) if err != nil || srv == "" { return stats } diff --git a/usage-examples/go/atlas-sdk-go/internal/clusters/utils.go b/usage-examples/go/atlas-sdk-go/internal/clusters/utils.go deleted file mode 100644 index 6f1f51f..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/clusters/utils.go +++ /dev/null @@ -1,71 +0,0 @@ -package clusters - -import ( - "context" - "fmt" - - "atlas-sdk-go/internal/errors" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// ListClusterNames lists all clusters in a project and returns their names. -func ListClusterNames(ctx context.Context, sdk admin.ClustersApi, p *admin.ListClustersApiParams) ([]string, error) { - req := sdk.ListClusters(ctx, p.GroupId) - clusters, _, err := req.Execute() - if err != nil { - return nil, errors.FormatError("list clusters", p.GroupId, err) - } - - var names []string - if clusters != nil && clusters.Results != nil { - for _, cluster := range *clusters.Results { - if cluster.Name != nil { - names = append(names, *cluster.Name) - } - } - } - return names, nil -} - -// GetProcessIdForCluster retrieves the process ID for a given cluster -func GetProcessIdForCluster(ctx context.Context, sdk admin.MonitoringAndLogsApi, - p *admin.ListAtlasProcessesApiParams, clusterName string) (string, error) { - - req := sdk.ListAtlasProcesses(ctx, p.GroupId) - r, _, err := req.Execute() - if err != nil { - return "", errors.FormatError("list atlas processes", p.GroupId, err) - } - if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return "", nil - } - - // Find the process for the specified cluster - for _, process := range r.GetResults() { - hostName := process.GetUserAlias() - id := process.GetId() - if hostName != "" && hostName == clusterName { - if id != "" { - return id, nil - } - } - } - - return "", fmt.Errorf("no process found for cluster %s", clusterName) -} - -// GetClusterSRVConnectionString returns the standard SRV connection string for a cluster. -func GetClusterSRVConnectionString(ctx context.Context, client *admin.APIClient, projectID, clusterName string) (string, error) { - if client == nil { - return "", fmt.Errorf("nil atlas api client") - } - cluster, _, err := client.ClustersApi.GetCluster(ctx, projectID, clusterName).Execute() - if err != nil { - return "", errors.FormatError("get cluster", projectID, err) - } - if cluster == nil || cluster.ConnectionStrings == nil || cluster.ConnectionStrings.StandardSrv == nil { - return "", fmt.Errorf("no standard SRV connection string found for cluster %s", clusterName) - } - return *cluster.ConnectionStrings.StandardSrv, nil -} diff --git a/usage-examples/go/atlas-sdk-go/internal/clusterutils/connectionstring.go b/usage-examples/go/atlas-sdk-go/internal/clusterutils/connectionstring.go new file mode 100644 index 0000000..050a862 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/clusterutils/connectionstring.go @@ -0,0 +1,25 @@ +package clusterutils + +import ( + "context" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" +) + +// GetClusterSRVConnectionString returns the standard SRV connection string for a cluster. +func GetClusterSRVConnectionString(ctx context.Context, client *admin.APIClient, projectID, clusterName string) (string, error) { + if client == nil { + return "", fmt.Errorf("nil atlas api client") + } + cluster, _, err := client.ClustersApi.GetCluster(ctx, projectID, clusterName).Execute() + if err != nil { + return "", errors.FormatError("get cluster", projectID, err) + } + if cluster == nil || cluster.ConnectionStrings == nil || cluster.ConnectionStrings.StandardSrv == nil { + return "", fmt.Errorf("no standard SRV connection string found for cluster %s", clusterName) + } + return *cluster.ConnectionStrings.StandardSrv, nil +} diff --git a/usage-examples/go/atlas-sdk-go/internal/clusterutils/processes.go b/usage-examples/go/atlas-sdk-go/internal/clusterutils/processes.go new file mode 100644 index 0000000..a4c524e --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/clusterutils/processes.go @@ -0,0 +1,169 @@ +package clusterutils + +import ( + "context" + "fmt" + "strings" + + "atlas-sdk-go/internal/errors" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// ListClusterNames lists all clusters in a project and returns their names. +func ListClusterNames(ctx context.Context, sdk admin.ClustersApi, p *admin.ListClustersApiParams) ([]string, error) { + req := sdk.ListClusters(ctx, p.GroupId) + clusters, _, err := req.Execute() + if err != nil { + return nil, errors.FormatError("list clusters", p.GroupId, err) + } + + var names []string + if clusters != nil && clusters.Results != nil { + for _, cluster := range *clusters.Results { + if cluster.Name != nil { + names = append(names, *cluster.Name) + } + } + } + return names, nil +} + +// GetProcessIdForCluster retrieves the first matching process ID for a given cluster name. +// It only inspects the Atlas processes list and applies simple matching heuristics. +// If no match is found it returns an empty string and no error to allow callers to decide fallback behavior. +func GetProcessIdForCluster(ctx context.Context, monApi admin.MonitoringAndLogsApi, + p *admin.ListAtlasProcessesApiParams, clusterName string) (string, error) { + if p == nil || p.GroupId == "" { + return "", fmt.Errorf("missing group id") + } + if clusterName == "" { + return "", fmt.Errorf("empty cluster name") + } + + req := monApi.ListAtlasProcesses(ctx, p.GroupId) + resp, _, err := req.Execute() + if err != nil { + return "", errors.FormatError("list atlas processes", p.GroupId, err) + } + if resp == nil || resp.Results == nil || len(*resp.Results) == 0 { + return "", nil // no processes available + } + + lc := strings.ToLower(clusterName) + for _, proc := range *resp.Results { + id := safe(proc.Id) + alias := strings.ToLower(safe(proc.UserAlias)) + host := strings.ToLower(safe(proc.Hostname)) + + if alias == lc || strings.Contains(alias, lc+"-") || strings.Contains(alias, lc+"_") { + if id != "" { + return id, nil + } + } + // hostname often embeds the cluster name + if host == lc || strings.Contains(host, lc+"-") || strings.Contains(host, lc+"_") { + if id != "" { + return id, nil + } + } + } + return "", nil +} + +// safe helper returns dereferenced string pointer or empty. +func safe(p *string) string { + if p == nil { + return "" + } + return *p +} + +// ClusterProcess describes a process linked to a cluster including its role and hostname. +type ClusterProcess struct { + ID string + Hostname string + Role string // Atlas typeName e.g. REPLICA_PRIMARY, REPLICA_SECONDARY, MONGOS +} + +// ListClusterProcessDetails returns a mapping of cluster name to a list of ClusterProcess, including role and hostname. +// If only one cluster exists, all processes are assigned to it. +func ListClusterProcessDetails(ctx context.Context, client *admin.APIClient, projectID string) (map[string][]ClusterProcess, error) { + if client == nil { + return nil, fmt.Errorf("nil client") + } + if projectID == "" { + return nil, fmt.Errorf("empty project id") + } + + clReq := client.ClustersApi.ListClusters(ctx, projectID) + clResp, _, err := clReq.Execute() + if err != nil { + return nil, errors.FormatError("list clusters", projectID, err) + } + var clusterNames []string + if clResp != nil && clResp.Results != nil { + for _, c := range *clResp.Results { + if c.Name != nil { + clusterNames = append(clusterNames, *c.Name) + } + } + } + out := make(map[string][]ClusterProcess, len(clusterNames)) + for _, n := range clusterNames { + out[n] = []ClusterProcess{} + } + if len(clusterNames) == 0 { + return out, nil + } + lowerNames := make([]string, len(clusterNames)) + for i, n := range clusterNames { + lowerNames[i] = strings.ToLower(n) + } + + prReq := client.MonitoringAndLogsApi.ListAtlasProcesses(ctx, projectID) + prResp, _, err := prReq.Execute() + if err != nil { + return nil, errors.FormatError("list atlas processes", projectID, err) + } + if prResp == nil || prResp.Results == nil { + return out, nil + } + + for _, proc := range *prResp.Results { + id := safe(proc.Id) + if id == "" { + continue + } + alias := strings.ToLower(safe(proc.UserAlias)) + host := strings.ToLower(safe(proc.Hostname)) + role := safe(proc.TypeName) + hostRaw := safe(proc.Hostname) + + matched := false + for i, cnameLower := range lowerNames { + if cnameLower == alias || cnameLower == host || + strings.Contains(alias, cnameLower+"-") || strings.Contains(host, cnameLower+"-") || + strings.Contains(alias, cnameLower+"_") || strings.Contains(host, cnameLower+"_") { + cp := ClusterProcess{ID: id, Hostname: hostRaw, Role: role} + out[clusterNames[i]] = append(out[clusterNames[i]], cp) + matched = true + } + } + if !matched && len(clusterNames) == 1 { // attribute to single cluster fallback + cp := ClusterProcess{ID: id, Hostname: hostRaw, Role: role} + out[clusterNames[0]] = append(out[clusterNames[0]], cp) + } + } + return out, nil +} + +// GetPrimaryProcessID returns the ID of a primary process if present. +func GetPrimaryProcessID(processes []ClusterProcess) (string, bool) { + for _, p := range processes { + if p.Role == "REPLICA_PRIMARY" { + return p.ID, true + } + } + return "", false +} diff --git a/usage-examples/go/atlas-sdk-go/internal/clusters/utils_test.go b/usage-examples/go/atlas-sdk-go/internal/clusterutils/processes_test.go similarity index 90% rename from usage-examples/go/atlas-sdk-go/internal/clusters/utils_test.go rename to usage-examples/go/atlas-sdk-go/internal/clusterutils/processes_test.go index 06b9e7f..0011bd2 100644 --- a/usage-examples/go/atlas-sdk-go/internal/clusters/utils_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/clusterutils/processes_test.go @@ -1,4 +1,4 @@ -package clusters +package clusterutils import ( "context" @@ -239,3 +239,29 @@ func TestGetClusterSRVConnectionString_ApiError(t *testing.T) { assert.Empty(t, srv) assert.Contains(t, err.Error(), "get cluster") } + +func TestGetPrimaryProcessID(t *testing.T) { + t.Parallel() + cases := []struct { + name string + processes []ClusterProcess + expectID string + found bool + }{ + {"primary_present", []ClusterProcess{{ID: "a", Role: "REPLICA_SECONDARY"}, {ID: "b", Role: "REPLICA_PRIMARY"}}, "b", true}, + {"no_primary", []ClusterProcess{{ID: "a", Role: "REPLICA_SECONDARY"}}, "", false}, + {"empty", []ClusterProcess{}, "", false}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + id, ok := GetPrimaryProcessID(c.processes) + if ok != c.found { + t.Fatalf("expected found=%v got=%v", c.found, ok) + } + if id != c.expectID { + t.Fatalf("expected id=%s got=%s", c.expectID, id) + } + }) + } +} diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go index efef784..e264d10 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -10,12 +10,34 @@ import ( // Config holds the configuration for connecting to MongoDB Atlas type Config struct { - BaseURL string `json:"MONGODB_ATLAS_BASE_URL"` - OrgID string `json:"ATLAS_ORG_ID"` - ProjectID string `json:"ATLAS_PROJECT_ID"` - ClusterName string `json:"ATLAS_CLUSTER_NAME"` - HostName string `json:"ATLAS_HOSTNAME"` - ProcessID string `json:"ATLAS_PROCESS_ID"` + BaseURL string `json:"MONGODB_ATLAS_BASE_URL"` + OrgID string `json:"ATLAS_ORG_ID"` + ProjectID string `json:"ATLAS_PROJECT_ID"` + ClusterName string `json:"ATLAS_CLUSTER_NAME"` + HostName string `json:"ATLAS_HOSTNAME"` + ProcessID string `json:"ATLAS_PROCESS_ID"` + DR DrOptions `json:"disaster_recovery,omitempty"` + Scaling ScalingConfig `json:"programmatic_scaling,omitempty"` +} + +// DrOptions holds the disaster recovery configuration parameters. +// Only the fields relevant to the chosen Scenario are required. +type DrOptions struct { + Scenario string `json:"scenario,omitempty"` // "regional-outage" or "data-deletion" + TargetRegion string `json:"target_region,omitempty"` // Region receiving added capacity (regional-outage) + OutageRegion string `json:"outage_region,omitempty"` // Region considered impaired (regional-outage) + AddNodes int `json:"add_nodes,omitempty"` // Number of electable nodes to add (default: 1) + SnapshotID string `json:"snapshot_id,omitempty"` // Snapshot ID to restore (data-deletion) + DryRun bool `json:"dry_run,omitempty"` // If true, only log intended actions +} + +// ScalingConfig holds the programmatic scaling configuration parameters. +type ScalingConfig struct { + TargetTier string `json:"target_tier,omitempty"` // Desired tier for scaling operations (e.g. M50) + PreScale bool `json:"pre_scale_event,omitempty"` // Immediate scale for all clusters (e.g. planned launch/event) + CPUThreshold float64 `json:"cpu_threshold,omitempty"` // Average CPU % threshold to trigger reactive scale + PeriodMinutes int `json:"cpu_period_minutes,omitempty"` // Lookback window in minutes for CPU averaging + DryRun bool `json:"dry_run,omitempty"` // If true, only log intended actions without executing } // LoadConfig reads a JSON configuration file and returns a Config struct diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/analyze.go b/usage-examples/go/atlas-sdk-go/internal/scale/analyze.go new file mode 100644 index 0000000..d72de48 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/analyze.go @@ -0,0 +1,174 @@ +package scale + +import ( + "context" + "errors" + "fmt" + "strings" + + "atlas-sdk-go/internal/clusterutils" + "atlas-sdk-go/internal/metrics" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// GetAverageProcessCPU fetches host CPU metrics and returns a simple average percentage over the lookback period. +func GetAverageProcessCPU(ctx context.Context, client *admin.APIClient, projectID, clusterName string, periodMinutes int) (float64, error) { + // Defensive validation so examples/tests can pass nil or bad inputs without panics + if client == nil { + return 0, fmt.Errorf("nil atlas client") + } + if projectID == "" { + return 0, fmt.Errorf("empty project id") + } + if clusterName == "" { + return 0, fmt.Errorf("empty cluster name") + } + if periodMinutes <= 0 { + return 0, fmt.Errorf("invalid period minutes: %d", periodMinutes) + } + + procID, err := clusterutils.GetProcessIdForCluster(ctx, client.MonitoringAndLogsApi, &admin.ListAtlasProcessesApiParams{GroupId: projectID}, clusterName) + if err != nil { + return 0, err + } + if procID == "" { + return 0, fmt.Errorf("no process found for cluster %s", clusterName) + } + + granularity := "PT1M" + period := fmt.Sprintf("PT%vM", periodMinutes) + metricsList := []string{"PROCESS_CPU_USER"} + m, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, &admin.GetHostMeasurementsApiParams{ + GroupId: projectID, + ProcessId: procID, + Granularity: &granularity, + Period: &period, + M: &metricsList, + }) + if err != nil { + return 0, err + } + + if m == nil || !m.HasMeasurements() { + return 0, fmt.Errorf("no measurements returned") + } + meas := m.GetMeasurements() + if len(meas) == 0 || !meas[0].HasDataPoints() { + return 0, fmt.Errorf("no datapoints returned") + } + + total := 0.0 + count := 0.0 + for _, dp := range meas[0].GetDataPoints() { + if dp.HasValue() { + v := float64(dp.GetValue()) + total += v + count++ + } + } + if count == 0 { + return 0, fmt.Errorf("no usable datapoint values") + } + avg := total / count + // Convert fractional to % if needed + if avg <= 1.0 { + avg *= 100.0 + } + return avg, nil +} + +// GetAverageCPUForProcess fetches host CPU metrics for a specific process ID and returns an average percentage. +func GetAverageCPUForProcess(ctx context.Context, client *admin.APIClient, projectID, processID string, periodMinutes int) (float64, error) { + if client == nil { + return 0, fmt.Errorf("nil atlas client") + } + if projectID == "" { + return 0, fmt.Errorf("empty project id") + } + if processID == "" { + return 0, fmt.Errorf("empty process id") + } + if periodMinutes <= 0 { + return 0, fmt.Errorf("invalid period minutes: %d", periodMinutes) + } + granularity := "PT1M" + period := fmt.Sprintf("PT%vM", periodMinutes) + metricsList := []string{"PROCESS_CPU_USER"} + m, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, &admin.GetHostMeasurementsApiParams{ + GroupId: projectID, + ProcessId: processID, + Granularity: &granularity, + Period: &period, + M: &metricsList, + }) + if err != nil { + return 0, err + } + if m == nil || !m.HasMeasurements() { + return 0, fmt.Errorf("no measurements returned") + } + meas := m.GetMeasurements() + if len(meas) == 0 || !meas[0].HasDataPoints() { + return 0, fmt.Errorf("no datapoints returned") + } + total := 0.0 + count := 0.0 + for _, dp := range meas[0].GetDataPoints() { + if dp.HasValue() { + v := float64(dp.GetValue()) + total += v + count++ + } + } + if count == 0 { + return 0, fmt.Errorf("no usable datapoint values") + } + avg := total / count + if avg <= 1.0 { // convert fractional to percent + avg *= 100.0 + } + return avg, nil +} + +// GetAverageCPUForProcesses returns a map of processID -> average CPU (percent) ignoring processes with errors. +func GetAverageCPUForProcesses(ctx context.Context, client *admin.APIClient, projectID string, processIDs []string, periodMinutes int) map[string]float64 { + out := make(map[string]float64, len(processIDs)) + for _, pid := range processIDs { + avg, err := GetAverageCPUForProcess(ctx, client, projectID, pid, periodMinutes) + if err != nil { + continue + } + out[pid] = avg + } + return out +} + +// ExtractInstanceSize retrieves the electable instance size from the first region config. +func ExtractInstanceSize(cur *admin.ClusterDescription20240805) (string, error) { + if cur == nil || !cur.HasReplicationSpecs() { + return "", errors.New("cluster has no replication specs") + } + repl := cur.GetReplicationSpecs() + if len(repl) == 0 { + return "", errors.New("no replication specs entries") + } + rcs := repl[0].GetRegionConfigs() + if len(rcs) == 0 || !rcs[0].HasElectableSpecs() { + return "", errors.New("no region config electable specs") + } + es := rcs[0].GetElectableSpecs() + if !es.HasInstanceSize() { + return "", errors.New("electable specs missing instance size") + } + return es.GetInstanceSize(), nil +} + +// IsSharedTier returns true if the tier is not a dedicated tier (M0, M2, M5) cluster. +func IsSharedTier(tier string) bool { + if tier == "" { + return false + } + upper := strings.ToUpper(tier) + return upper == "M0" || upper == "M2" || upper == "M5" +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/analyze_test.go b/usage-examples/go/atlas-sdk-go/internal/scale/analyze_test.go new file mode 100644 index 0000000..9423e01 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/analyze_test.go @@ -0,0 +1,220 @@ +package scale + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "go.mongodb.org/atlas-sdk/v20250219001/mockadmin" +) + +const fixedTS = "2023-04-01T12:00:00Z" + +func parseTS(t *testing.T, ts string) time.Time { + v, err := time.Parse(time.RFC3339, ts) + require.NoError(t, err) + return v +} + +func TestGetAverageProcessCPU(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + projectID string + clusterName string + periodMinutes int + processID string + processList *admin.PaginatedHostViewAtlas + measurements *admin.ApiMeasurementsGeneralViewAtlas + expectError bool + expectedCPU float64 + msg string + }{ + { + name: "fractional_datapoints", + projectID: "proj1", + clusterName: "clusterA", + periodMinutes: 60, + processID: "procA", + processList: &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ + Id: admin.PtrString("procA"), + UserAlias: admin.PtrString("clusterA"), + }}}, + measurements: &admin.ApiMeasurementsGeneralViewAtlas{Measurements: &[]admin.MetricsMeasurementAtlas{{ + Name: admin.PtrString("PROCESS_CPU_USER"), + DataPoints: &[]admin.MetricDataPointAtlas{{ + Timestamp: admin.PtrTime(parseTS(t, fixedTS)), + Value: admin.PtrFloat32(0.75), + }, { + Timestamp: admin.PtrTime(parseTS(t, "2023-04-01T12:01:00Z")), + Value: admin.PtrFloat32(0.80), + }}, + }}}, + expectedCPU: 77.5, + msg: "average of fractional datapoints converted to percent", + }, + { + name: "percentage_datapoints", + projectID: "proj1", + clusterName: "clusterA", + periodMinutes: 60, + processID: "procA", + processList: &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ + Id: admin.PtrString("procA"), + UserAlias: admin.PtrString("clusterA"), + }}}, + measurements: &admin.ApiMeasurementsGeneralViewAtlas{Measurements: &[]admin.MetricsMeasurementAtlas{{ + Name: admin.PtrString("PROCESS_CPU_USER"), + DataPoints: &[]admin.MetricDataPointAtlas{{ + Timestamp: admin.PtrTime(parseTS(t, fixedTS)), + Value: admin.PtrFloat32(75.0), + }, { + Timestamp: admin.PtrTime(parseTS(t, "2023-04-01T12:01:00Z")), + Value: admin.PtrFloat32(80.0), + }}, + }}}, + expectedCPU: 77.5, + msg: "average of already-percentage datapoints", + }, + { + name: "no_process_found", + projectID: "proj1", + clusterName: "clusterA", + periodMinutes: 60, + processID: "", // won't be used + processList: &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ // alias mismatch + Id: admin.PtrString("procX"), + UserAlias: admin.PtrString("otherCluster"), + }}}, + expectError: true, + msg: "error when no process matches cluster", + }, + { + name: "no_measurements", + projectID: "proj1", + clusterName: "clusterA", + periodMinutes: 60, + processID: "procA", + processList: &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ + Id: admin.PtrString("procA"), + UserAlias: admin.PtrString("clusterA"), + }}}, + measurements: &admin.ApiMeasurementsGeneralViewAtlas{}, + expectError: true, + msg: "error when measurements absent", + }, + { + name: "empty_datapoints", + projectID: "proj1", + clusterName: "clusterA", + periodMinutes: 60, + processID: "procA", + processList: &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ + Id: admin.PtrString("procA"), + UserAlias: admin.PtrString("clusterA"), + }}}, + measurements: &admin.ApiMeasurementsGeneralViewAtlas{Measurements: &[]admin.MetricsMeasurementAtlas{{ + Name: admin.PtrString("PROCESS_CPU_USER"), + DataPoints: &[]admin.MetricDataPointAtlas{}, + }}}, + expectError: true, + msg: "error when datapoints slice empty", + }, + } + + for _, tc := range tests { + c := tc + t.Run(c.name, func(t *testing.T) { + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + + // Expect list processes + mockSvc.EXPECT(). + ListAtlasProcesses(mock.Anything, c.projectID). + Return(admin.ListAtlasProcessesApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + ListAtlasProcessesExecute(mock.Anything). + Return(c.processList, nil, nil).Once() + + // Determine if we will find matching process + found := false + if c.processList != nil && c.processList.Results != nil { + for _, p := range *c.processList.Results { + if p.UserAlias != nil && *p.UserAlias == c.clusterName { + found = true + break + } + } + } + if found && c.processID != "" { // simulate metrics call + mockSvc.EXPECT(). + GetHostMeasurements(mock.Anything, c.projectID, c.processID). + Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + GetHostMeasurementsExecute(mock.Anything). + Return(c.measurements, nil, nil).Once() + } + + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + val, err := GetAverageProcessCPU(ctx, client, c.projectID, c.clusterName, c.periodMinutes) + if c.expectError { + require.Error(t, err, c.msg) + return + } + require.NoError(t, err, c.msg) + require.InDelta(t, c.expectedCPU, val, 0.01, c.msg) + }) + } +} + +func TestGetAverageProcessCPU_InputValidation(t *testing.T) { + ctx := context.Background() + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + + cases := []struct { + name string + projectID string + clusterName string + period int + }{ + {"empty_project", "", "c", 60}, + {"empty_cluster", "p", "", 60}, + {"zero_period", "p", "c", 0}, + {"negative_period", "p", "c", -5}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + _, err := GetAverageProcessCPU(ctx, client, c.projectID, c.clusterName, c.period) + require.Error(t, err) + }) + } +} + +func BenchmarkGetAverageProcessCPU(b *testing.B) { + ctx := context.Background() + mockSvc := mockadmin.NewMonitoringAndLogsApi(b) + procList := &admin.PaginatedHostViewAtlas{Results: &[]admin.ApiHostViewAtlas{{ + Id: admin.PtrString("proc"), + UserAlias: admin.PtrString("benchCluster"), + }}} + meas := &admin.ApiMeasurementsGeneralViewAtlas{Measurements: &[]admin.MetricsMeasurementAtlas{{ + Name: admin.PtrString("PROCESS_CPU_USER"), + DataPoints: &[]admin.MetricDataPointAtlas{{ + Timestamp: admin.PtrTime(time.Now()), + Value: admin.PtrFloat32(0.50), + }}, + }}} + mockSvc.EXPECT().ListAtlasProcesses(mock.Anything, "benchProj").Return(admin.ListAtlasProcessesApiRequest{ApiService: mockSvc}).Maybe() + mockSvc.EXPECT().ListAtlasProcessesExecute(mock.Anything).Return(procList, nil, nil).Maybe() + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, "benchProj", "proc").Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Maybe() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(meas, nil, nil).Maybe() + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = GetAverageProcessCPU(ctx, client, "benchProj", "benchCluster", 60) + } +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/config.go b/usage-examples/go/atlas-sdk-go/internal/scale/config.go new file mode 100644 index 0000000..707efce --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/config.go @@ -0,0 +1,35 @@ +package scale + +import ( + "atlas-sdk-go/internal/config" +) + +// ScalingConfig exposes config within scale package for tests and callers while reusing config.ScalingConfig. +type ScalingConfig = config.ScalingConfig + +const ( + defaultTargetTier = "M50" + defaultCPUThreshold = 75.0 + defaultPeriodMinutes = 60 +) + +// LoadScalingConfig loads programmatic scaling configuration with sensible defaults. +// Defaults are applied for missing optional fields to align with Atlas auto-scaling guidance. +func LoadScalingConfig(cfg config.Config) config.ScalingConfig { + sc := cfg.Scaling + + // Apply defaults for missing values + if sc.TargetTier == "" { + sc.TargetTier = defaultTargetTier + } + + if sc.CPUThreshold == 0 { + sc.CPUThreshold = defaultCPUThreshold + } + + if sc.PeriodMinutes == 0 { + sc.PeriodMinutes = defaultPeriodMinutes + } + + return sc +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/evaluate.go b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate.go new file mode 100644 index 0000000..c32f373 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate.go @@ -0,0 +1,104 @@ +package scale + +import ( + "context" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/config" +) + +// EvaluateDecision returns true if scaling should occur and a human-readable reason. +func EvaluateDecision(ctx context.Context, client *admin.APIClient, projectID, clusterName string, sc config.ScalingConfig) (bool, string) { + // Pre-scale always wins (explicit operator intent for predictable events) + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + + // Reactive scaling based on sustained CPU utilization + // Aligned with Atlas auto-scaling guidance: 75% for 1 hour triggers upscaling + avgCPU, err := GetAverageProcessCPU(ctx, client, projectID, clusterName, sc.PeriodMinutes) + if err != nil { + fmt.Printf(" Warning: unable to compute average CPU for reactive scaling: %v\n", err) + return false, "metrics unavailable for reactive scaling decision" + } + + fmt.Printf(" Average CPU last %d minutes: %.1f%% (threshold: %.1f%%)\n", + sc.PeriodMinutes, avgCPU, sc.CPUThreshold) + + if avgCPU > sc.CPUThreshold { + return true, fmt.Sprintf("sustained CPU utilization %.1f%% > %.1f%% threshold over %d minutes", + avgCPU, sc.CPUThreshold, sc.PeriodMinutes) + } + + return false, fmt.Sprintf("CPU utilization %.1f%% below threshold %.1f%%", avgCPU, sc.CPUThreshold) +} + +// EvaluateDecisionForProcess mirrors EvaluateDecision but uses an explicit process ID. +func EvaluateDecisionForProcess(ctx context.Context, client *admin.APIClient, projectID, clusterName, processID string, sc config.ScalingConfig) (bool, string) { + // Pre-scale always wins (explicit operator intent for predictable events) + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + + // Reactive scaling based on sustained CPU utilization + // Aligned with Atlas auto-scaling guidance: 75% for 1 hour triggers upscaling + avgCPU, err := GetAverageCPUForProcess(ctx, client, projectID, processID, sc.PeriodMinutes) + if err != nil { + fmt.Printf(" Warning: unable to compute average CPU for reactive scaling (cluster=%s process=%s): %v\n", clusterName, processID, err) + return false, "metrics unavailable for reactive scaling decision" + } + + fmt.Printf(" Average CPU last %d minutes (process %s): %.1f%% (threshold: %.1f%%)\n", + sc.PeriodMinutes, processID, avgCPU, sc.CPUThreshold) + + if avgCPU > sc.CPUThreshold { + return true, fmt.Sprintf("sustained CPU utilization %.1f%% > %.1f%% threshold over %d minutes", + avgCPU, sc.CPUThreshold, sc.PeriodMinutes) + } + + return false, fmt.Sprintf("CPU utilization %.1f%% below threshold %.1f%%", avgCPU, sc.CPUThreshold) +} + +// EvaluateDecisionAggregated evaluates scaling using multiple process metrics. +// Strategy: +// 1. If PreScale set -> scale. +// 2. If primary metrics available and exceed threshold -> scale. +// 3. Else compute average across all available processes -> scale if exceeds threshold. +// 4. If no metrics -> not scale (metrics unavailable). +func EvaluateDecisionAggregated(ctx context.Context, client *admin.APIClient, projectID, clusterName string, processIDs []string, primaryID string, sc config.ScalingConfig) (bool, string) { + if sc.PreScale { + return true, "pre-scale event flag set (predictable traffic spike)" + } + if client == nil || projectID == "" || clusterName == "" || sc.PeriodMinutes <= 0 { + return false, "invalid inputs for aggregated evaluation" + } + if len(processIDs) == 0 { + return EvaluateDecision(ctx, client, projectID, clusterName, sc) + } + cpus := GetAverageCPUForProcesses(ctx, client, projectID, processIDs, sc.PeriodMinutes) + if len(cpus) == 0 { + fmt.Printf(" Warning: no usable metrics across %d processes for cluster %s\n", len(processIDs), clusterName) + return false, "metrics unavailable for reactive scaling decision" + } + if primaryID != "" { + if v, ok := cpus[primaryID]; ok { + fmt.Printf(" Primary process %s average CPU: %.1f%% (threshold: %.1f%%)\n", primaryID, v, sc.CPUThreshold) + if v > sc.CPUThreshold { + return true, fmt.Sprintf("primary CPU %.1f%% > %.1f%% threshold", v, sc.CPUThreshold) + } + } + } + // Aggregate across all processes + sum := 0.0 + for _, v := range cpus { + sum += v + } + agg := sum / float64(len(cpus)) + fmt.Printf(" Aggregated average CPU across %d processes: %.1f%% (threshold: %.1f%%)\n", len(cpus), agg, sc.CPUThreshold) + if agg > sc.CPUThreshold { + return true, fmt.Sprintf("aggregated CPU %.1f%% > %.1f%% threshold", agg, sc.CPUThreshold) + } + return false, fmt.Sprintf("aggregated CPU %.1f%% below threshold %.1f%%", agg, sc.CPUThreshold) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_aggregated_test.go b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_aggregated_test.go new file mode 100644 index 0000000..9734eeb --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_aggregated_test.go @@ -0,0 +1,117 @@ +package scale + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "go.mongodb.org/atlas-sdk/v20250219001/mockadmin" +) + +// helper to build measurements with provided float32 values (interpreting <=1 as fractional) +func buildMeasurements(vals ...float32) *admin.ApiMeasurementsGeneralViewAtlas { + pts := make([]admin.MetricDataPointAtlas, 0, len(vals)) + for i, v := range vals { + pts = append(pts, admin.MetricDataPointAtlas{ + Timestamp: admin.PtrTime(time.Unix(int64(1700000000+i*60), 0).UTC()), + Value: admin.PtrFloat32(v), + }) + } + return &admin.ApiMeasurementsGeneralViewAtlas{Measurements: &[]admin.MetricsMeasurementAtlas{{ + Name: admin.PtrString("PROCESS_CPU_USER"), + DataPoints: &pts, + }}} +} + +func TestEvaluateDecisionAggregated_PrimaryTriggers(t *testing.T) { + ctx := context.Background() + projectID := "proj1" + clusterName := "clusterA" + primaryID := "primary:27017" + secondaryID := "secondary:27017" + + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + + // Primary metrics average 80% (0.80 fractional) > threshold 75 + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, primaryID).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(buildMeasurements(0.80, 0.80), nil, nil).Once() + // Secondary metrics average 50% + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, secondaryID).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(buildMeasurements(0.50, 0.50), nil, nil).Once() + + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + sc := ScalingConfig{TargetTier: "M50", CPUThreshold: 75, PeriodMinutes: 60} + + shouldScale, reason := EvaluateDecisionAggregated(ctx, client, projectID, clusterName, []string{primaryID, secondaryID}, primaryID, sc) + require.True(t, shouldScale, "expected scaling due to primary > threshold") + require.Contains(t, reason, "primary CPU", "reason should reference primary trigger") +} + +func TestEvaluateDecisionAggregated_AggregatedTriggers(t *testing.T) { + ctx := context.Background() + projectID := "proj1" + clusterName := "clusterA" + primaryID := "primary:27017" + sec1 := "sec1:27017" + sec2 := "sec2:27017" + + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + // Primary 70% (below threshold) + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, primaryID).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(buildMeasurements(0.70, 0.70), nil, nil).Once() + // Secondaries high (85%) raising aggregate above threshold 75 + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, sec1).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(buildMeasurements(0.85, 0.85), nil, nil).Once() + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, sec2).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(buildMeasurements(0.85, 0.85), nil, nil).Once() + + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + sc := ScalingConfig{TargetTier: "M50", CPUThreshold: 75, PeriodMinutes: 60} + + shouldScale, reason := EvaluateDecisionAggregated(ctx, client, projectID, clusterName, []string{primaryID, sec1, sec2}, primaryID, sc) + require.True(t, shouldScale, "expected scaling due to aggregated > threshold") + require.Contains(t, reason, "aggregated CPU", "reason should reference aggregated trigger") +} + +func TestEvaluateDecisionAggregated_NoMetrics(t *testing.T) { + ctx := context.Background() + projectID := "proj1" + clusterName := "clusterA" + primaryID := "primary:27017" + sec := "sec:27017" + + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + // Return errors (simulate metrics not available) by returning nil measurements with not found error detail + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, primaryID).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(nil, nil, fmt.Errorf("not found")) + mockSvc.EXPECT().GetHostMeasurements(mock.Anything, projectID, sec).Return(admin.GetHostMeasurementsApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT().GetHostMeasurementsExecute(mock.Anything).Return(nil, nil, fmt.Errorf("not found")) + + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + sc := ScalingConfig{TargetTier: "M50", CPUThreshold: 75, PeriodMinutes: 60} + + shouldScale, reason := EvaluateDecisionAggregated(ctx, client, projectID, clusterName, []string{primaryID, sec}, primaryID, sc) + require.False(t, shouldScale, "expected not to scale with no metrics") + require.Contains(t, reason, "metrics unavailable", "expected metrics unavailable reason") +} + +func TestEvaluateDecisionAggregated_PreScaleShortCircuit(t *testing.T) { + ctx := context.Background() + projectID := "proj1" + clusterName := "clusterA" + primaryID := "primary:27017" + sec := "sec:27017" + + mockSvc := mockadmin.NewMonitoringAndLogsApi(t) + // No expectations for metrics (shouldn't be called) + client := &admin.APIClient{MonitoringAndLogsApi: mockSvc} + sc := ScalingConfig{TargetTier: "M50", CPUThreshold: 75, PeriodMinutes: 60, PreScale: true} + + shouldScale, reason := EvaluateDecisionAggregated(ctx, client, projectID, clusterName, []string{primaryID, sec}, primaryID, sc) + require.True(t, shouldScale, "expected scale due to pre-scale") + require.Contains(t, reason, "pre-scale", "reason should mention pre-scale") +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_test.go b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_test.go new file mode 100644 index 0000000..7457a0d --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/evaluate_test.go @@ -0,0 +1,240 @@ +package scale + +import ( + "context" + "testing" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func TestScalingConfig(t *testing.T) { + tests := []struct { + name string + config ScalingConfig + valid bool + }{ + { + name: "valid_config_with_defaults", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 75.0, + PeriodMinutes: 60, + DryRun: false, + }, + valid: true, + }, + { + name: "valid_config_pre_scale", + config: ScalingConfig{ + TargetTier: "M100", + PreScale: true, + CPUThreshold: 80.0, + PeriodMinutes: 30, + DryRun: true, + }, + valid: true, + }, + { + name: "empty_target_tier", + config: ScalingConfig{ + TargetTier: "", + PreScale: false, + CPUThreshold: 75.0, + PeriodMinutes: 60, + DryRun: false, + }, + valid: false, + }, + { + name: "invalid_cpu_threshold_zero", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 0.0, + PeriodMinutes: 60, + DryRun: false, + }, + valid: false, + }, + { + name: "invalid_cpu_threshold_negative", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: -10.0, + PeriodMinutes: 60, + DryRun: false, + }, + valid: false, + }, + { + name: "invalid_period_zero", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 75.0, + PeriodMinutes: 0, + DryRun: false, + }, + valid: false, + }, + { + name: "invalid_period_negative", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 75.0, + PeriodMinutes: -30, + DryRun: false, + }, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Basic validation logic + isValid := tt.config.TargetTier != "" && + tt.config.CPUThreshold > 0 && + tt.config.PeriodMinutes > 0 + + if isValid != tt.valid { + t.Errorf("Expected validity %v, got %v for config: %+v", tt.valid, isValid, tt.config) + } + }) + } +} + +func TestEvaluateDecision(t *testing.T) { + tests := []struct { + name string + config ScalingConfig + expectedResult bool + expectedReason string + description string + }{ + { + name: "pre_scale_enabled", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: true, + CPUThreshold: 75.0, + PeriodMinutes: 60, + DryRun: false, + }, + expectedResult: true, + expectedReason: "pre-scale event flag set (predictable traffic spike)", + description: "Should return true when PreScale is enabled", + }, + { + name: "pre_scale_disabled_no_metrics", + config: ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 75.0, + PeriodMinutes: 60, + DryRun: false, + }, + expectedResult: false, + expectedReason: "metrics unavailable for reactive scaling decision", + description: "Should return false when metrics are unavailable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + // Use nil client to simulate metrics unavailability for testing + var client *admin.APIClient = nil + + shouldScale, reason := EvaluateDecision(ctx, client, "test-project", "test-cluster", tt.config) + + if shouldScale != tt.expectedResult { + t.Errorf("Expected scaling decision %v, got %v", tt.expectedResult, shouldScale) + } + + if reason != tt.expectedReason { + t.Errorf("Expected reason '%s', got '%s'", tt.expectedReason, reason) + } + + t.Logf("Test: %s - %s", tt.name, tt.description) + }) + } +} + +func TestEvaluateDecision_PreScaleLogic(t *testing.T) { + // Test that PreScale always wins regardless of other settings + config := ScalingConfig{ + TargetTier: "M50", + PreScale: true, + CPUThreshold: 999.0, // Impossible threshold + PeriodMinutes: 1, + DryRun: false, + } + + ctx := context.Background() + var client *admin.APIClient = nil + + shouldScale, reason := EvaluateDecision(ctx, client, "test-project", "test-cluster", config) + + if !shouldScale { + t.Error("Expected scaling decision to be true when PreScale is enabled") + } + + expectedReason := "pre-scale event flag set (predictable traffic spike)" + if reason != expectedReason { + t.Errorf("Expected reason '%s', got '%s'", expectedReason, reason) + } +} + +func TestScalingConfig_DefaultValues(t *testing.T) { + // Test typical default values that would be used + defaultConfig := ScalingConfig{ + TargetTier: "M50", + PreScale: false, + CPUThreshold: 75.0, // Aligned with Atlas auto-scaling + PeriodMinutes: 60, // 1 hour lookback + DryRun: false, + } + + // Verify the default values make sense + if defaultConfig.TargetTier == "" { + t.Error("Default TargetTier should not be empty") + } + + if defaultConfig.CPUThreshold <= 0 || defaultConfig.CPUThreshold > 100 { + t.Errorf("Default CPUThreshold should be between 0-100, got %f", defaultConfig.CPUThreshold) + } + + if defaultConfig.PeriodMinutes <= 0 { + t.Errorf("Default PeriodMinutes should be positive, got %d", defaultConfig.PeriodMinutes) + } + + // Test that default values align with Atlas recommendations + if defaultConfig.CPUThreshold != 75.0 { + t.Errorf("Expected CPU threshold aligned with Atlas auto-scaling (75%%), got %f", defaultConfig.CPUThreshold) + } + + if defaultConfig.PeriodMinutes != 60 { + t.Errorf("Expected period aligned with Atlas auto-scaling (60 min), got %d", defaultConfig.PeriodMinutes) + } +} + +// BenchmarkEvaluateDecision provides a benchmark for the scaling decision logic +func BenchmarkEvaluateDecision(b *testing.B) { + ctx := context.Background() + var client *admin.APIClient = nil + config := ScalingConfig{ + TargetTier: "M50", + PreScale: true, // Use PreScale to avoid API calls + CPUThreshold: 75.0, + PeriodMinutes: 60, + DryRun: false, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = EvaluateDecision(ctx, client, "test-project", "test-cluster", config) + } +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/execute.go b/usage-examples/go/atlas-sdk-go/internal/scale/execute.go new file mode 100644 index 0000000..2dfab61 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/execute.go @@ -0,0 +1,68 @@ +package scale + +import ( + "context" + "errors" + "fmt" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// ExecuteClusterScaling performs the scaling operation by updating the cluster's instance sizes. +func ExecuteClusterScaling(ctx context.Context, client *admin.APIClient, projectID, clusterName string, + cluster *admin.ClusterDescription20240805, targetTier string) error { + // Defensive validation so example tests using nil / empty parameters don't panic. + if client == nil { + return errors.New("nil atlas client") + } + if projectID == "" { + return errors.New("empty project id") + } + if clusterName == "" { + return errors.New("empty cluster name") + } + if targetTier == "" { + return errors.New("empty target tier") + } + + payload := buildScalePayload(cluster, targetTier) + if payload == nil { + return fmt.Errorf("failed to build scaling payload") + } + + _, _, err := client.ClustersApi.UpdateCluster(ctx, projectID, clusterName, payload).Execute() + return err +} + +// buildScalePayload copies current replication specs and updates instance sizes to targetTier. +func buildScalePayload(cur *admin.ClusterDescription20240805, targetTier string) *admin.ClusterDescription20240805 { + payload := admin.NewClusterDescription20240805() + if cur == nil || !cur.HasReplicationSpecs() { + return nil + } + + repl := cur.GetReplicationSpecs() + for i := range repl { + rcs := repl[i].GetRegionConfigs() + for j := range rcs { + if rcs[j].HasElectableSpecs() { + es := rcs[j].GetElectableSpecs() + es.SetInstanceSize(targetTier) + rcs[j].SetElectableSpecs(es) + } + if rcs[j].HasReadOnlySpecs() { + ros := rcs[j].GetReadOnlySpecs() + ros.SetInstanceSize(targetTier) + rcs[j].SetReadOnlySpecs(ros) + } + if rcs[j].HasAnalyticsSpecs() { + as := rcs[j].GetAnalyticsSpecs() + as.SetInstanceSize(targetTier) + rcs[j].SetAnalyticsSpecs(as) + } + } + repl[i].SetRegionConfigs(rcs) + } + payload.SetReplicationSpecs(repl) + return payload +} diff --git a/usage-examples/go/atlas-sdk-go/internal/scale/execute_test.go b/usage-examples/go/atlas-sdk-go/internal/scale/execute_test.go new file mode 100644 index 0000000..14cdaab --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/scale/execute_test.go @@ -0,0 +1,517 @@ +package scale + +import ( + "context" + "testing" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func TestExecuteClusterScaling(t *testing.T) { + tests := []struct { + name string + projectID string + clusterName string + targetTier string + cluster *admin.ClusterDescription20240805 + expectError bool + description string + }{ + { + name: "valid_parameters", + projectID: "test-project-id", + clusterName: "test-cluster", + targetTier: "M50", + cluster: createMockClusterWithElectableSpecs(), + expectError: true, // Will error in test env due to no real API + description: "Should attempt to scale cluster with valid parameters", + }, + { + name: "empty_project_id", + projectID: "", + clusterName: "test-cluster", + targetTier: "M50", + cluster: createMockClusterWithElectableSpecs(), + expectError: true, + description: "Should handle empty project ID", + }, + { + name: "empty_cluster_name", + projectID: "test-project-id", + clusterName: "", + targetTier: "M50", + cluster: createMockClusterWithElectableSpecs(), + expectError: true, + description: "Should handle empty cluster name", + }, + { + name: "empty_target_tier", + projectID: "test-project-id", + clusterName: "test-cluster", + targetTier: "", + cluster: createMockClusterWithElectableSpecs(), + expectError: true, + description: "Should handle empty target tier", + }, + { + name: "nil_cluster", + projectID: "test-project-id", + clusterName: "test-cluster", + targetTier: "M50", + cluster: nil, + expectError: true, + description: "Should handle nil cluster and fail to build payload", + }, + { + name: "cluster_without_replication_specs", + projectID: "test-project-id", + clusterName: "test-cluster", + targetTier: "M50", + cluster: &admin.ClusterDescription20240805{}, + expectError: true, + description: "Should handle cluster without replication specs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + var client *admin.APIClient = nil + + err := ExecuteClusterScaling(ctx, client, tt.projectID, tt.clusterName, tt.cluster, tt.targetTier) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error for test case %s, but got none", tt.name) + } + } else { + if err != nil { + t.Errorf("Unexpected error for test case %s: %v", tt.name, err) + } + } + + t.Logf("Test case: %s - %s", tt.name, tt.description) + }) + } +} + +func TestBuildScalePayload(t *testing.T) { + tests := []struct { + name string + cluster *admin.ClusterDescription20240805 + targetTier string + expectNil bool + description string + }{ + { + name: "nil_cluster", + cluster: nil, + targetTier: "M50", + expectNil: true, + description: "Should return nil for nil cluster", + }, + { + name: "cluster_without_replication_specs", + cluster: &admin.ClusterDescription20240805{}, + targetTier: "M50", + expectNil: true, + description: "Should return nil for cluster without replication specs", + }, + { + name: "valid_cluster_with_electable_specs", + cluster: createMockClusterWithElectableSpecs(), + targetTier: "M50", + expectNil: false, + description: "Should create payload for cluster with electable specs", + }, + { + name: "valid_cluster_with_readonly_specs", + cluster: createMockClusterWithReadOnlySpecs(), + targetTier: "M100", + expectNil: false, + description: "Should create payload for cluster with read-only specs", + }, + { + name: "valid_cluster_with_analytics_specs", + cluster: createMockClusterWithAnalyticsSpecs(), + targetTier: "M200", + expectNil: false, + description: "Should create payload for cluster with analytics specs", + }, + { + name: "valid_cluster_with_all_specs", + cluster: createMockClusterWithAllSpecs(), + targetTier: "M300", + expectNil: false, + description: "Should create payload for cluster with all spec types", + }, + { + name: "empty_target_tier", + cluster: createMockClusterWithElectableSpecs(), + targetTier: "", + expectNil: false, + description: "Should create payload even with empty target tier", + }, + { + name: "multiple_replication_specs", + cluster: createMockClusterWithMultipleReplicationSpecs(), + targetTier: "M50", + expectNil: false, + description: "Should handle clusters with multiple replication specs", + }, + { + name: "multiple_region_configs", + cluster: createMockClusterWithMultipleRegionConfigs(), + targetTier: "M100", + expectNil: false, + description: "Should handle clusters with multiple region configs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := buildScalePayload(tt.cluster, tt.targetTier) + + if tt.expectNil { + if payload != nil { + t.Errorf("Expected nil payload for test case %s, but got non-nil", tt.name) + } + } else { + if payload == nil { + t.Errorf("Expected non-nil payload for test case %s, but got nil", tt.name) + } else { + // Verify the payload has replication specs + if !payload.HasReplicationSpecs() { + t.Errorf("Expected payload to have replication specs for test case %s", tt.name) + } + } + } + + t.Logf("Test case: %s - %s", tt.name, tt.description) + }) + } +} + +func TestBuildScalePayload_InstanceSizeUpdate(t *testing.T) { + targetTier := "M50" + + // Test electable specs update + t.Run("electable_specs_update", func(t *testing.T) { + cluster := createMockClusterWithElectableSpecs() + payload := buildScalePayload(cluster, targetTier) + + if payload == nil { + t.Fatal("Expected non-nil payload") + } + + if !payload.HasReplicationSpecs() { + t.Fatal("Expected payload to have replication specs") + } + + repl := payload.GetReplicationSpecs() + if len(repl) == 0 { + t.Fatal("Expected at least one replication spec") + } + + rcs := repl[0].GetRegionConfigs() + if len(rcs) == 0 { + t.Fatal("Expected at least one region config") + } + + if rcs[0].HasElectableSpecs() { + es := rcs[0].GetElectableSpecs() + if es.HasInstanceSize() { + actualTier := es.GetInstanceSize() + if actualTier != targetTier { + t.Errorf("Expected electable instance size %s, got %s", targetTier, actualTier) + } + } else { + t.Error("Expected electable specs to have instance size") + } + } + }) + + // Test read-only specs update + t.Run("readonly_specs_update", func(t *testing.T) { + cluster := createMockClusterWithReadOnlySpecs() + payload := buildScalePayload(cluster, targetTier) + + if payload == nil { + t.Fatal("Expected non-nil payload") + } + + repl := payload.GetReplicationSpecs() + rcs := repl[0].GetRegionConfigs() + + if rcs[0].HasReadOnlySpecs() { + ros := rcs[0].GetReadOnlySpecs() + if ros.HasInstanceSize() { + actualTier := ros.GetInstanceSize() + if actualTier != targetTier { + t.Errorf("Expected read-only instance size %s, got %s", targetTier, actualTier) + } + } + } + }) + + // Test analytics specs update + t.Run("analytics_specs_update", func(t *testing.T) { + cluster := createMockClusterWithAnalyticsSpecs() + payload := buildScalePayload(cluster, targetTier) + + if payload == nil { + t.Fatal("Expected non-nil payload") + } + + repl := payload.GetReplicationSpecs() + rcs := repl[0].GetRegionConfigs() + + if rcs[0].HasAnalyticsSpecs() { + as := rcs[0].GetAnalyticsSpecs() + if as.HasInstanceSize() { + actualTier := as.GetInstanceSize() + if actualTier != targetTier { + t.Errorf("Expected analytics instance size %s, got %s", targetTier, actualTier) + } + } + } + }) + + // Test all specs update + t.Run("all_specs_update", func(t *testing.T) { + cluster := createMockClusterWithAllSpecs() + payload := buildScalePayload(cluster, targetTier) + + if payload == nil { + t.Fatal("Expected non-nil payload") + } + + repl := payload.GetReplicationSpecs() + rcs := repl[0].GetRegionConfigs() + + // Check all spec types are updated + if rcs[0].HasElectableSpecs() { + es := rcs[0].GetElectableSpecs() + if es.GetInstanceSize() != targetTier { + t.Errorf("Expected electable instance size %s, got %s", targetTier, es.GetInstanceSize()) + } + } + + if rcs[0].HasReadOnlySpecs() { + ros := rcs[0].GetReadOnlySpecs() + if ros.GetInstanceSize() != targetTier { + t.Errorf("Expected read-only instance size %s, got %s", targetTier, ros.GetInstanceSize()) + } + } + + if rcs[0].HasAnalyticsSpecs() { + as := rcs[0].GetAnalyticsSpecs() + if as.GetInstanceSize() != targetTier { + t.Errorf("Expected analytics instance size %s, got %s", targetTier, as.GetInstanceSize()) + } + } + }) +} + +func TestBuildScalePayload_PreservesOtherSettings(t *testing.T) { + // Test that buildScalePayload only changes instance sizes and preserves other settings + cluster := createMockClusterWithElectableSpecs() + originalRepl := cluster.GetReplicationSpecs() + + payload := buildScalePayload(cluster, "M100") + + if payload == nil { + t.Fatal("Expected non-nil payload") + } + + newRepl := payload.GetReplicationSpecs() + + // Should have same number of replication specs + if len(newRepl) != len(originalRepl) { + t.Errorf("Expected %d replication specs, got %d", len(originalRepl), len(newRepl)) + } + + // Should have same number of region configs + if len(newRepl[0].GetRegionConfigs()) != len(originalRepl[0].GetRegionConfigs()) { + t.Error("Region config count should be preserved") + } +} + +// Helper functions to create mock cluster descriptions for testing + +func createMockClusterWithElectableSpecs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create electable specs using the correct Atlas SDK types + electableSpecs := admin.HardwareSpec20240805{} + electableSpecs.SetInstanceSize("M30") + + // Create region config + regionConfig := admin.NewCloudRegionConfig20240805() + regionConfig.SetElectableSpecs(electableSpecs) + + // Create replication spec + replSpec := admin.NewReplicationSpec20240805() + replSpec.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig}) + + // Set replication specs on cluster + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec}) + + return cluster +} + +func createMockClusterWithReadOnlySpecs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create read-only specs using the correct Atlas SDK types + readOnlySpecs := admin.DedicatedHardwareSpec20240805{} + readOnlySpecs.SetInstanceSize("M30") + + // Create region config + regionConfig := admin.NewCloudRegionConfig20240805() + regionConfig.SetReadOnlySpecs(readOnlySpecs) + + // Create replication spec + replSpec := admin.NewReplicationSpec20240805() + replSpec.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig}) + + // Set replication specs on cluster + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec}) + + return cluster +} + +func createMockClusterWithAnalyticsSpecs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create analytics specs using the correct Atlas SDK types + analyticsSpecs := admin.DedicatedHardwareSpec20240805{} + analyticsSpecs.SetInstanceSize("M30") + + // Create region config + regionConfig := admin.NewCloudRegionConfig20240805() + regionConfig.SetAnalyticsSpecs(analyticsSpecs) + + // Create replication spec + replSpec := admin.NewReplicationSpec20240805() + replSpec.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig}) + + // Set replication specs on cluster + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec}) + + return cluster +} + +func createMockClusterWithAllSpecs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create all spec types using the correct Atlas SDK types + electableSpecs := admin.HardwareSpec20240805{} + electableSpecs.SetInstanceSize("M30") + + readOnlySpecs := admin.DedicatedHardwareSpec20240805{} + readOnlySpecs.SetInstanceSize("M30") + + analyticsSpecs := admin.DedicatedHardwareSpec20240805{} + analyticsSpecs.SetInstanceSize("M30") + + // Create region config with all specs + regionConfig := admin.NewCloudRegionConfig20240805() + regionConfig.SetElectableSpecs(electableSpecs) + regionConfig.SetReadOnlySpecs(readOnlySpecs) + regionConfig.SetAnalyticsSpecs(analyticsSpecs) + + // Create replication spec + replSpec := admin.NewReplicationSpec20240805() + replSpec.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig}) + + // Set replication specs on cluster + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec}) + + return cluster +} + +func createMockClusterWithMultipleReplicationSpecs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create first replication spec + electableSpecs1 := admin.HardwareSpec20240805{} + electableSpecs1.SetInstanceSize("M30") + regionConfig1 := admin.NewCloudRegionConfig20240805() + regionConfig1.SetElectableSpecs(electableSpecs1) + replSpec1 := admin.NewReplicationSpec20240805() + replSpec1.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig1}) + + // Create second replication spec + electableSpecs2 := admin.HardwareSpec20240805{} + electableSpecs2.SetInstanceSize("M40") + regionConfig2 := admin.NewCloudRegionConfig20240805() + regionConfig2.SetElectableSpecs(electableSpecs2) + replSpec2 := admin.NewReplicationSpec20240805() + replSpec2.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig2}) + + // Set multiple replication specs + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec1, *replSpec2}) + + return cluster +} + +func createMockClusterWithMultipleRegionConfigs() *admin.ClusterDescription20240805 { + cluster := admin.NewClusterDescription20240805() + + // Create first region config + electableSpecs1 := admin.HardwareSpec20240805{} + electableSpecs1.SetInstanceSize("M30") + regionConfig1 := admin.NewCloudRegionConfig20240805() + regionConfig1.SetElectableSpecs(electableSpecs1) + + // Create second region config + electableSpecs2 := admin.HardwareSpec20240805{} + electableSpecs2.SetInstanceSize("M30") + regionConfig2 := admin.NewCloudRegionConfig20240805() + regionConfig2.SetElectableSpecs(electableSpecs2) + + // Create replication spec with multiple region configs + replSpec := admin.NewReplicationSpec20240805() + replSpec.SetRegionConfigs([]admin.CloudRegionConfig20240805{*regionConfig1, *regionConfig2}) + + // Set replication specs on cluster + cluster.SetReplicationSpecs([]admin.ReplicationSpec20240805{*replSpec}) + + return cluster +} + +// BenchmarkBuildScalePayload provides a benchmark for the payload building function +func BenchmarkBuildScalePayload(b *testing.B) { + cluster := createMockClusterWithAllSpecs() + targetTier := "M50" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = buildScalePayload(cluster, targetTier) + } +} + +// BenchmarkExecuteClusterScaling provides a benchmark for the cluster scaling function +func BenchmarkExecuteClusterScaling(b *testing.B) { + ctx := context.Background() + var client *admin.APIClient = nil + cluster := createMockClusterWithAllSpecs() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // This will fail quickly due to nil client, but measures the function call overhead + _ = ExecuteClusterScaling(ctx, client, "test-project", "test-cluster", cluster, "M50") + } +} + +// BenchmarkBuildScalePayload_LargeCluster benchmarks payload building for clusters with many specs +func BenchmarkBuildScalePayload_LargeCluster(b *testing.B) { + cluster := createMockClusterWithMultipleReplicationSpecs() + targetTier := "M200" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = buildScalePayload(cluster, targetTier) + } +} diff --git a/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh b/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh index 623bfd5..5bdba69 100755 --- a/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh +++ b/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh @@ -30,6 +30,7 @@ IGNORE_PATTERNS=( "*.gz" "*.log" "./logs" # for generated logs directory + "example-configs.json" # NOTE: DO NOT add pattern for ".gitignore"; we are including it in the artifact repo ) RENAME_PATTERNS=()