diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml new file mode 100644 index 00000000..9dd342ac --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml @@ -0,0 +1,23 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: gcp + generationRules: + - resourceTypes: + - project + matchRules: + - type: pattern + pattern: ".+" + properties: [name] + mode: substring + slxs: + - baseName: gcp-artifact-registry-spend + qualifiers: ["project"] + baseTemplateName: gcp-artifact-registry-spend-analysis + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + templateName: gcp-artifact-registry-spend-analysis-sli.yaml + - type: runbook + templateName: gcp-artifact-registry-spend-analysis-taskset.yaml diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml new file mode 100644 index 00000000..72f083f2 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml @@ -0,0 +1,54 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + displayUnitsLong: Health Score + displayUnitsShort: score + locations: + - {{default_location}} + description: Measures Artifact Registry spend health for project {{ match_resource.project_name }} using anomaly, MoM growth, and concentration signals. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/gcp-artifact-registry-spend-analysis/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 300 + configProvided: + - name: GCP_PROJECT_IDS + value: "{{ match_resource.project_name }}" + - name: GCP_BILLING_EXPORT_TABLE + value: "{{ custom.gcp_billing_export_table | default('') }}" + - name: COST_ANALYSIS_LOOKBACK_DAYS + value: "{{ custom.cost_analysis_lookback_days | default('30') }}" + - name: ARTIFACT_COST_SPIKE_MULTIPLIER + value: "{{ custom.artifact_cost_spike_multiplier | default('2') }}" + - name: ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT + value: "{{ custom.artifact_mom_growth_threshold_percent | default('25') }}" + - name: ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT + value: "{{ custom.artifact_project_cost_threshold_percent | default('20') }}" + - name: GCP_ORG_WIDE_REPORT + value: "{{ custom.gcp_org_wide_report | default('false') }}" + secretsProvided: + {% if wb_version %} + {% include "gcp-auth.yaml" ignore missing %} + {% else %} + - name: gcp_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml new file mode 100644 index 00000000..ad27f9cf --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml @@ -0,0 +1,31 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/gcp/billing/billing.svg + alias: {{ match_resource.project_name }} Artifact Registry Spend Analysis + asMeasuredBy: Artifact registry spend health based on cost anomalies, month-over-month growth, and project spend concentration. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{workspace.owner_email}} + statement: GCP Artifact Registry and legacy Container Registry spend should remain predictable with no sustained anomalies or unchecked storage growth. + additionalContext: + {% include "gcp-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "gcp-tags.yaml" ignore missing %} + - name: cloud + value: gcp + - name: service + value: artifact-registry + - name: scope + value: project + - name: access + value: read-only diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml new file mode 100644 index 00000000..b5d2ed21 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml @@ -0,0 +1,47 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: Analyze Artifact Registry and legacy GCR spend for project {{ match_resource.project_name }} using BigQuery billing export. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/gcp-artifact-registry-spend-analysis/runbook.robot + configProvided: + - name: GCP_PROJECT_IDS + value: "{{ match_resource.project_name }}" + - name: GCP_BILLING_EXPORT_TABLE + value: "{{ custom.gcp_billing_export_table | default('') }}" + - name: COST_ANALYSIS_LOOKBACK_DAYS + value: "{{ custom.cost_analysis_lookback_days | default('30') }}" + - name: ARTIFACT_COST_SPIKE_MULTIPLIER + value: "{{ custom.artifact_cost_spike_multiplier | default('2') }}" + - name: ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT + value: "{{ custom.artifact_mom_growth_threshold_percent | default('25') }}" + - name: ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT + value: "{{ custom.artifact_project_cost_threshold_percent | default('20') }}" + - name: OUTPUT_FORMAT + value: "{{ custom.output_format | default('table') }}" + - name: GCP_ORG_WIDE_REPORT + value: "{{ custom.gcp_org_wide_report | default('false') }}" + secretsProvided: + {% if wb_version %} + {% include "gcp-auth.yaml" ignore missing %} + {% else %} + - name: gcp_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/README.md b/codebundles/gcp-artifact-registry-spend-analysis/.test/README.md new file mode 100644 index 00000000..4469d7c0 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/README.md @@ -0,0 +1,11 @@ +# Test infrastructure + +This CodeBundle analyzes BigQuery billing export data. Integration tests require an existing organization billing export table and GCP credentials with BigQuery read access. + +Run static validation from this directory: + +```bash +task +``` + +For live integration testing, configure `GCP_BILLING_EXPORT_TABLE` and `GOOGLE_APPLICATION_CREDENTIALS`, then execute individual task scripts from the bundle root. diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/Taskfile.yaml b/codebundles/gcp-artifact-registry-spend-analysis/.test/Taskfile.yaml new file mode 100644 index 00000000..ec5e6919 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/Taskfile.yaml @@ -0,0 +1,23 @@ +version: "3" + +tasks: + default: + desc: "Run CodeBundle validation suite" + cmds: + - task: validate-structure + + validate-structure: + desc: "Validate required files and basic script checks" + cmds: + - ./validate-all-tests.sh + + clean: + desc: "Remove local test artifacts" + cmds: + - rm -f ../artifact_*.json ../artifact_*.txt + + build-infra: + desc: "Placeholder for billing export test infra (requires org billing export)" + dir: terraform + cmds: + - echo "Billing export integration tests require an existing BigQuery billing export table." diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/backend.tf b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/backend.tf new file mode 100644 index 00000000..3c533e6b --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/backend.tf @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "terraform.tfstate" + } +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/main.tf b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/main.tf new file mode 100644 index 00000000..9a3e87cc --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/main.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" +} + +# BigQuery billing export is organization-scoped and cannot be synthesized in CI. +# This placeholder keeps the standard .test/terraform layout for future fixture work. + +output "note" { + value = "Use an existing GCP billing export table for integration testing." +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/outputs.tf b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/outputs.tf new file mode 100644 index 00000000..9d6085aa --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/outputs.tf @@ -0,0 +1,3 @@ +output "billing_export_table" { + value = var.billing_export_table +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/providers.tf b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/providers.tf new file mode 100644 index 00000000..11327841 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/providers.tf @@ -0,0 +1,3 @@ +terraform { + required_providers {} +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/terraform.tfvars b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..c430ad11 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/terraform.tfvars @@ -0,0 +1 @@ +billing_export_table = "" diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/variables.tf b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/variables.tf new file mode 100644 index 00000000..9daeab6f --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/terraform/variables.tf @@ -0,0 +1,5 @@ +variable "billing_export_table" { + type = string + description = "Optional billing export table for manual integration tests" + default = "" +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/.test/validate-all-tests.sh b/codebundles/gcp-artifact-registry-spend-analysis/.test/validate-all-tests.sh new file mode 100755 index 00000000..d9f94fb1 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/validate-all-tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +need=( + runbook.robot + sli.robot + README.md + artifact-billing-common.sh + analyze-artifact-registry-spend.sh + report-top-artifact-cost-contributors.sh + compare-artifact-spend-mom.sh + detect-artifact-cost-anomalies.sh + generate-artifact-spend-recommendations.sh + artifact-spend-sli-check.sh + .runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml + .runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml + .runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml + .runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml +) + +for f in "${need[@]}"; do + if [[ ! -e "$f" ]]; then + echo "missing: $f" >&2 + exit 1 + fi +done + +for script in *.sh; do + if [[ ! -x "$script" ]]; then + echo "not executable: $script" >&2 + exit 1 + fi +done + +# Validate JSON issue structure helpers using mock billing rows (no live GCP required) +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/mock_rows.json" <<'EOF' +[ + {"project_id":"proj-a","project_name":"Project A","service_name":"Artifact Registry","sku_description":"Artifact Registry Storage","usage_date":"2026-05-01","total_cost":"100","usage_amount":"10","usage_unit":"gibibyte month"}, + {"project_id":"proj-a","project_name":"Project A","service_name":"Container Registry","sku_description":"Container Registry Storage","usage_date":"2026-05-01","total_cost":"50","usage_amount":"5","usage_unit":"gibibyte month"}, + {"project_id":"proj-b","project_name":"Project B","service_name":"Artifact Registry","sku_description":"Artifact Registry Storage","usage_date":"2026-05-01","total_cost":"10","usage_amount":"1","usage_unit":"gibibyte month"} +] +EOF + +total=$(jq '[.[].total_cost | tonumber] | add' "$TMPDIR/mock_rows.json") +if [[ "$total" != "160" ]]; then + echo "mock aggregation failed: expected 160 got $total" >&2 + exit 1 +fi + +echo "gcp-artifact-registry-spend-analysis structure OK" diff --git a/codebundles/gcp-artifact-registry-spend-analysis/README.md b/codebundles/gcp-artifact-registry-spend-analysis/README.md new file mode 100644 index 00000000..980a76c6 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/README.md @@ -0,0 +1,70 @@ +# GCP Artifact Registry Spend Analysis + +Analyze Google Cloud Artifact Registry and legacy Container Registry (GCR) spend from BigQuery billing export. Surfaces storage and egress cost trends, top contributors, anomalies, and optimization recommendations so operators can align artifact spend with current or required artifacts rather than stale storage. + +## Overview + +- **Spend breakdown**: Per-project, per-SKU artifact spend with daily, weekly, and monthly rollups +- **Top contributors**: Rank projects and SKUs; flag projects exceeding spend share thresholds +- **Month-over-month trends**: Compare the last three complete calendar months and detect storage growth without pull activity +- **Anomaly detection**: Daily storage spikes (vs 7-day average) and sustained weekly deviations from 30-day trends +- **Optimization summary**: Actionable recommendations for cleanup policies, GCR migration, duplicate tags, and scanning scope +- **SLI health score**: 0–1 score from anomaly, MoM growth, and project concentration checks + +## Configuration + +### Required Variables + +None. When `GCP_PROJECT_IDS` is blank, projects with artifact-related billing activity are auto-discovered from the export. + +### Optional Variables + +- `GCP_PROJECT_IDS`: Comma-separated GCP project IDs to analyze; blank auto-discovers from billing export (default: `""`) +- `GCP_BILLING_EXPORT_TABLE`: BigQuery billing export table (`project.dataset.gcp_billing_export_v1_*`); auto-discovered if unset (default: `""`) +- `COST_ANALYSIS_LOOKBACK_DAYS`: Days of billing history to analyze (default: `30`) +- `ARTIFACT_COST_SPIKE_MULTIPLIER`: Daily cost spike threshold as multiple of 7-day average (default: `2`) +- `ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT`: Month-over-month growth percentage that triggers an issue (default: `25`) +- `ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT`: Project share of total artifact spend that triggers an issue; `0` disables (default: `20`) +- `OUTPUT_FORMAT`: Report format: `table`, `csv`, `json`, or `all` (default: `table`) +- `GCP_ORG_WIDE_REPORT`: When `true`, analyze org-wide artifact spend instead of filtering to `GCP_PROJECT_IDS` (default: `false`) + +### Secrets + +- `gcp_credentials`: GCP service account JSON with BigQuery billing export read access (`roles/bigquery.dataViewer`, `roles/bigquery.jobUser`, `roles/bigquery.metadataViewer` on the billing export project) + +## Prerequisites + +1. Enable [BigQuery billing export](https://cloud.google.com/billing/docs/how-to/export-data-bigquery) for your organization. +2. Grant the service account read access to the billing export dataset. +3. Install `gcloud`, `bq`, and `jq` in the execution environment. + +Artifact billing SKUs are matched using `service.description` and `sku.description` patterns for Artifact Registry, Container Registry, storage, egress, and vulnerability scanning. + +**Note:** BigQuery billing export does not expose individual repository names. Correlate high-spend projects with inventory output from the `gcp-artifact-registry-governance` bundle. + +## Tasks Overview + +### Analyze Artifact Registry Spend by Project and SKU + +Queries billing export for artifact-related SKUs and produces per-project, per-SKU totals with daily, weekly, and monthly rollups for the lookback window. + +### Report Top Artifact Registry Cost Contributors + +Ranks projects and SKUs by artifact storage and transfer spend. Raises issues when a project exceeds `ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT` or legacy GCR SKUs dominate spend. + +### Compare Artifact Registry Spend Month-over-Month + +Compares artifact costs across the last three complete calendar months. Raises issues when MoM growth exceeds `ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT` or storage grows without corresponding pull/transfer activity. + +### Detect Artifact Storage Cost Anomalies + +Detects daily storage cost spikes at `ARTIFACT_COST_SPIKE_MULTIPLIER` times the 7-day average and sustained weekly deviations from the 30-day trend. + +### Generate Artifact Registry Spend Optimization Summary + +Consolidates findings into recommendations: enable cleanup policies, retire legacy GCR, reduce duplicate tags, right-size scanning, and follow up on high-cost projects with the governance bundle. + +## Related Bundles + +- **gcp-project-cost-health**: Organization-wide GCP cost reporting and generic optimization recommendations +- **gcp-artifact-registry-governance**: Operational inventory and cleanup-policy checks for stale artifacts diff --git a/codebundles/gcp-artifact-registry-spend-analysis/analyze-artifact-registry-spend.sh b/codebundles/gcp-artifact-registry-spend-analysis/analyze-artifact-registry-spend.sh new file mode 100755 index 00000000..646096f3 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/analyze-artifact-registry-spend.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Analyze Artifact Registry and legacy GCR spend by project and SKU with rollups. + +source "$(dirname "$0")/artifact-billing-common.sh" + +REPORT_FILE="${REPORT_FILE:-artifact_spend_report.txt}" +JSON_FILE="${JSON_FILE:-artifact_spend_report.json}" +ISSUES_FILE="${ISSUES_FILE:-artifact_spend_analysis_issues.json}" + +init_issues_file "$ISSUES_FILE" + +if ! ensure_billing_context; then + cat "$ISSUES_FILE" + cp "$ISSUES_FILE" artifact_spend_analysis_output.json + exit 0 +fi + +lookback_start=$(echo "$DATE_RANGES" | jq -r '.lookback.start') +lookback_end=$(echo "$DATE_RANGES" | jq -r '.lookback.end') +week_start=$(echo "$DATE_RANGES" | jq -r '.weekly.start') +week_end=$(echo "$DATE_RANGES" | jq -r '.weekly.end') +month_start=$(echo "$DATE_RANGES" | jq -r '.monthly.start') +month_end=$(echo "$DATE_RANGES" | jq -r '.monthly.end') + +rows=$(query_artifact_cost_rows "$BILLING_TABLE" "$lookback_start" "$lookback_end" "$PROJECT_FILTER") + +if [[ "$(echo "$rows" | jq 'length')" -eq 0 ]]; then + log "No artifact-related billing rows found for lookback window" +fi + +aggregated=$(echo "$rows" | jq --argjson ranges "$DATE_RANGES" ' + group_by(.project_id + "|" + .sku_description) | + map({ + projectId: .[0].project_id, + projectName: (.[0].project_name // .[0].project_id), + service: .[0].service_name, + sku: .[0].sku_description, + totalCost: (map(.total_cost | tonumber) | add // 0), + daily: ($ranges.daily | map(. as $date | { + date: $date, + cost: ([.[] | select(.usage_date == $date) | .total_cost | tonumber] | add // 0) + })), + weeklyCost: ([.[] | select(.usage_date >= $ranges.weekly.start and .usage_date <= $ranges.weekly.end) | .total_cost | tonumber] | add // 0), + monthlyCost: ([.[] | select(.usage_date >= $ranges.monthly.start and .usage_date <= $ranges.monthly.end) | .total_cost | tonumber] | add // 0) + }) | sort_by(-.totalCost) +') + +total_cost=$(echo "$aggregated" | jq '[.[].totalCost] | add // 0') + +{ + echo "GCP Artifact Registry Spend Analysis" + echo "====================================" + echo "Projects: ${GCP_PROJECT_IDS:-auto-discovered}" + echo "Lookback: ${lookback_start} to ${lookback_end} (${LOOKBACK_DAYS} days)" + echo "Total artifact spend: \$$(printf "%.2f" "$total_cost")" + echo "" + echo "Top project/SKU contributors:" + echo "$aggregated" | jq -r '.[:15][] | "- \(.projectId) | \(.sku): $\(.totalCost | . * 100 | round / 100)"' + echo "" + echo "Rollups:" + echo " Weekly (${week_start} to ${week_end}): \$$(echo "$aggregated" | jq '[.[].weeklyCost] | add // 0')" + echo " Monthly (${month_start} to ${month_end}): \$$(echo "$aggregated" | jq '[.[].monthlyCost] | add // 0')" +} | tee "$REPORT_FILE" + +jq -n \ + --argjson rows "$aggregated" \ + --argjson ranges "$DATE_RANGES" \ + --arg total "$total_cost" \ + '{ + reportType: "artifact-registry-spend-analysis", + totalCost: ($total | tonumber), + currency: "USD", + dateRanges: $ranges, + contributors: $rows + }' > "$JSON_FILE" + +cp "$ISSUES_FILE" artifact_spend_analysis_output.json +echo "Analysis completed. Report: $REPORT_FILE" diff --git a/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-common.sh b/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-common.sh new file mode 100755 index 00000000..324b8743 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-common.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# Shared BigQuery billing helpers for GCP Artifact Registry spend analysis. + +set -euo pipefail + +log() { + echo "📦 [$(date '+%H:%M:%S')] $*" >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PROJECT_IDS="${GCP_PROJECT_IDS:-}" +PROJECT_IDS=$(echo "$PROJECT_IDS" | sed 's/^"//;s/"$//' | xargs) +LOOKBACK_DAYS="${COST_ANALYSIS_LOOKBACK_DAYS:-30}" +OUTPUT_FORMAT="${OUTPUT_FORMAT:-table}" +ARTIFACT_COST_SPIKE_MULTIPLIER="${ARTIFACT_COST_SPIKE_MULTIPLIER:-2}" +ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT="${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT:-25}" +ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT="${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT:-20}" +GCP_ORG_WIDE_REPORT="${GCP_ORG_WIDE_REPORT:-false}" + +if ! [[ "$LOOKBACK_DAYS" =~ ^[0-9]+$ ]] || [[ "$LOOKBACK_DAYS" -le 0 ]]; then + LOOKBACK_DAYS=30 +fi + +check_bq_available() { + command -v bq &>/dev/null && return 0 + local home_dir="${HOME:-/root}" + [[ -f "$home_dir/google-cloud-sdk/bin/bq" ]] || [[ -f "/usr/local/bin/bq" ]] || [[ -f "/opt/google-cloud-sdk/bin/bq" ]] +} + +check_python_bq_available() { + local python_cmd + python_cmd=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) + [[ -z "$python_cmd" ]] && return 1 + if "$python_cmd" -c "from google.cloud import bigquery" 2>/dev/null; then + echo "$python_cmd" + return 0 + fi + return 1 +} + +check_bigquery_access() { + if check_bq_available && { bq version &>/dev/null || bq --version &>/dev/null || bq help &>/dev/null; }; then + echo "bq" + return 0 + fi + local python_cmd + python_cmd=$(check_python_bq_available || true) + [[ -n "$python_cmd" ]] && { echo "python"; return 0; } + return 1 +} + +artifact_sku_filter_sql() { + cat <<'EOF' +( + LOWER(service.description) LIKE '%artifact registry%' + OR LOWER(service.description) LIKE '%container registry%' + OR LOWER(sku.description) LIKE '%artifact registry%' + OR LOWER(sku.description) LIKE '%container registry%' + OR LOWER(sku.description) LIKE '%container image%' + OR LOWER(sku.description) LIKE '%vulnerability scanning%' +) +EOF +} + +artifact_storage_sku_filter_sql() { + cat <<'EOF' +( + LOWER(sku.description) LIKE '%storage%' + OR LOWER(sku.description) LIKE '%stored%' +) +EOF +} + +artifact_transfer_sku_filter_sql() { + cat <<'EOF' +( + LOWER(sku.description) LIKE '%egress%' + OR LOWER(sku.description) LIKE '%download%' + OR LOWER(sku.description) LIKE '%transfer%' + OR LOWER(sku.description) LIKE '%pull%' + OR LOWER(sku.description) LIKE '%internet%' +) +EOF +} + +get_date_ranges() { + local end_date + end_date=$(date -u +"%Y-%m-%d") + local yesterday week_start month_start lookback_start + yesterday=$(date -u -d '1 day ago' +"%Y-%m-%d" 2>/dev/null || date -u -v-1d +"%Y-%m-%d" 2>/dev/null) + week_start=$(date -u -d '7 days ago' +"%Y-%m-%d" 2>/dev/null || date -u -v-7d +"%Y-%m-%d" 2>/dev/null) + month_start=$(date -u -d '30 days ago' +"%Y-%m-%d" 2>/dev/null || date -u -v-30d +"%Y-%m-%d" 2>/dev/null) + lookback_start=$(date -u -d "${LOOKBACK_DAYS} days ago" +"%Y-%m-%d" 2>/dev/null || date -u -v-${LOOKBACK_DAYS}d +"%Y-%m-%d" 2>/dev/null) + + declare -a daily_dates + local i days_ago + for i in {0..6}; do + days_ago=$((i + 1)) + daily_dates[$i]=$(date -u -d "${days_ago} days ago" +"%Y-%m-%d" 2>/dev/null || date -u -v-${days_ago}d +"%Y-%m-%d" 2>/dev/null) + done + + jq -n \ + --arg d0 "${daily_dates[0]}" \ + --arg d1 "${daily_dates[1]}" \ + --arg d2 "${daily_dates[2]}" \ + --arg d3 "${daily_dates[3]}" \ + --arg d4 "${daily_dates[4]}" \ + --arg d5 "${daily_dates[5]}" \ + --arg d6 "${daily_dates[6]}" \ + --arg week_start "$week_start" \ + --arg week_end "$yesterday" \ + --arg month_start "$month_start" \ + --arg month_end "$end_date" \ + --arg lookback_start "$lookback_start" \ + --arg lookback_end "$end_date" \ + --argjson lookback_days "$LOOKBACK_DAYS" \ + '{ + daily: [$d6, $d5, $d4, $d3, $d2, $d1, $d0], + weekly: {start: $week_start, end: $week_end}, + monthly: {start: $month_start, end: $month_end}, + lookback: {start: $lookback_start, end: $lookback_end, days: $lookback_days} + }' +} + +get_last_three_complete_months() { + local month1_start month1_end month2_start month2_end month3_start month3_end + month1_start=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +"%Y-%m-01" 2>/dev/null || date -u -v1d -v-1m +"%Y-%m-01" 2>/dev/null) + month1_end=$(date -u -d "$(date -u +%Y-%m-01) -1 day" +"%Y-%m-%d" 2>/dev/null || date -u -v1d -v-1m -v-1d +"%Y-%m-%d" 2>/dev/null) + month2_start=$(date -u -d "$month1_start -1 month" +"%Y-%m-01" 2>/dev/null || date -u -v1d -v-2m +"%Y-%m-01" 2>/dev/null) + month2_end=$(date -u -d "$month1_start -1 day" +"%Y-%m-%d" 2>/dev/null || date -u -v1d -v-1m -v-1d +"%Y-%m-%d" 2>/dev/null) + month3_start=$(date -u -d "$month2_start -1 month" +"%Y-%m-01" 2>/dev/null || date -u -v1d -v-3m +"%Y-%m-01" 2>/dev/null) + month3_end=$(date -u -d "$month2_start -1 day" +"%Y-%m-%d" 2>/dev/null || date -u -v1d -v-2m -v-1d +"%Y-%m-%d" 2>/dev/null) + + jq -n \ + --arg m1s "$month1_start" --arg m1e "$month1_end" \ + --arg m2s "$month2_start" --arg m2e "$month2_end" \ + --arg m3s "$month3_start" --arg m3e "$month3_end" \ + '{ + month1: {start: $m1s, end: $m1e, label: $m1s}, + month2: {start: $m2s, end: $m2e, label: $m2s}, + month3: {start: $m3s, end: $m3e, label: $m3s} + }' +} + +discover_billing_table() { + log "Discovering billing export table..." + local bq_method + bq_method=$(check_bigquery_access || true) + [[ -z "$bq_method" ]] && return 1 + + local search_projects=() + if [[ -n "$PROJECT_IDS" ]]; then + IFS=',' read -ra search_projects <<< "$PROJECT_IDS" + else + local current_project + current_project=$(gcloud config get-value project 2>/dev/null || true) + [[ -n "$current_project" ]] && search_projects=("$current_project") + fi + + local proj_id dataset tables table billing_table + for proj_id in "${search_projects[@]}"; do + proj_id=$(echo "$proj_id" | xargs) + [[ -z "$proj_id" ]] && continue + datasets=$(bq ls --format=json --project_id="$proj_id" 2>/dev/null | jq -r '.[].datasetReference.datasetId' 2>/dev/null || true) + while IFS= read -r dataset; do + [[ -z "$dataset" ]] && continue + tables=$(bq ls --format=json --project_id="$proj_id" "$dataset" 2>/dev/null | jq -r '.[].tableReference.tableId' 2>/dev/null || true) + while IFS= read -r table; do + [[ -z "$table" ]] && continue + if [[ "$table" =~ ^gcp_billing_export_v1_ ]]; then + billing_table="${proj_id}.${dataset}.${table}" + log "Found billing table: $billing_table" + echo "$billing_table" + return 0 + fi + done <<< "$tables" + done <<< "$datasets" + done + return 1 +} + +resolve_billing_table() { + local billing_table="${GCP_BILLING_EXPORT_TABLE:-}" + billing_table=$(echo "$billing_table" | sed 's/^"//;s/"$//' | xargs) + if [[ -z "$billing_table" ]]; then + billing_table=$(discover_billing_table || true) + fi + [[ -z "$billing_table" ]] && return 1 + echo "$billing_table" +} + +build_project_filter() { + if [[ -z "$PROJECT_IDS" ]] || [[ "${GCP_ORG_WIDE_REPORT,,}" == "true" ]]; then + echo "" + return 0 + fi + local project_list + project_list=$(echo "$PROJECT_IDS" | tr ',' '\n' | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;s/^/'/;s/$/'/" | paste -sd, -) + echo "AND project.id IN (${project_list})" +} + +run_bq_json_query() { + local billing_table="$1" + local query="$2" + local billing_project + billing_project="${billing_table%%.*}" + + local query_result json_result bq_method python_cmd + if check_bq_available; then + query_result=$(bq query --project_id="$billing_project" --use_legacy_sql=false --format=json --max_rows=100000 "$query" 2>&1) || { + log "BigQuery query failed: $query_result" + echo '[]' + return 1 + } + json_result=$(echo "$query_result" | grep -E '^\[' | head -1) + [[ -z "$json_result" ]] && json_result='[]' + echo "$json_result" + return 0 + fi + + python_cmd=$(check_python_bq_available || true) + if [[ -n "$python_cmd" ]]; then + QUERY="$query" BILLING_PROJECT="$billing_project" "$python_cmd" - <<'PY' +import json +import os +from google.cloud import bigquery + +client = bigquery.Client(project=os.environ["BILLING_PROJECT"]) +rows = [dict(row.items()) for row in client.query(os.environ["QUERY"]).result()] +print(json.dumps(rows, default=str)) +PY + return 0 + fi + + echo '[]' + return 1 +} + +write_access_issue() { + local title="$1" + local details="$2" + local output_file="${3:-artifact_access_issues.json}" + jq -n \ + --arg title "$title" \ + --arg details "$details" \ + --argjson severity 4 \ + '[{ + title: $title, + severity: $severity, + expected: "BigQuery billing export should be readable for artifact spend analysis", + actual: $details, + details: $details, + next_steps: "Verify gcp_credentials has BigQuery Data Viewer and Job User on the billing export project. Set GCP_BILLING_EXPORT_TABLE if auto-discovery fails." + }]' > "$output_file" +} + +init_issues_file() { + local file="$1" + echo '[]' > "$file" +} + +append_issue() { + local issues_file="$1" + local issue_json="$2" + local tmp + tmp=$(mktemp) + jq --argjson issue "$issue_json" '. + [$issue]' "$issues_file" > "$tmp" + mv "$tmp" "$issues_file" +} + +query_artifact_cost_rows() { + local billing_table="$1" + local start_date="$2" + local end_date="$3" + local project_filter="$4" + local sku_filter + sku_filter=$(artifact_sku_filter_sql) + + local query=" +SELECT + project.id AS project_id, + project.name AS project_name, + service.description AS service_name, + sku.description AS sku_description, + DATE(usage_start_time) AS usage_date, + SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost, + SUM(usage.amount_in_pricing_units) AS usage_amount, + usage.pricing_unit AS usage_unit +FROM \`${billing_table}\` +WHERE DATE(usage_start_time) >= '${start_date}' + AND DATE(usage_start_time) <= '${end_date}' + AND ${sku_filter} + ${project_filter} +GROUP BY project_id, project_name, service_name, sku_description, usage_date, usage_unit +ORDER BY usage_date DESC, total_cost DESC +" + run_bq_json_query "$billing_table" "$query" +} + +discover_projects_with_artifact_spend() { + local billing_table="$1" + local start_date="$2" + local end_date="$3" + local sku_filter project_filter query + sku_filter=$(artifact_sku_filter_sql) + project_filter=$(build_project_filter) + + query=" +SELECT DISTINCT project.id AS project_id +FROM \`${billing_table}\` +WHERE DATE(usage_start_time) >= '${start_date}' + AND DATE(usage_start_time) <= '${end_date}' + AND ${sku_filter} + ${project_filter} +ORDER BY project_id +" + run_bq_json_query "$billing_table" "$query" | jq -r '.[].project_id' | paste -sd, - +} + +ensure_billing_context() { + local billing_table project_filter date_ranges + billing_table=$(resolve_billing_table || true) + if [[ -z "$billing_table" ]]; then + write_access_issue "Cannot Access BigQuery Billing Export" "Billing export table not found. Set GCP_BILLING_EXPORT_TABLE or ensure billing export is configured." + return 1 + fi + + if [[ -z "$PROJECT_IDS" ]]; then + date_ranges=$(get_date_ranges) + local lookback_start lookback_end discovered + lookback_start=$(echo "$date_ranges" | jq -r '.lookback.start') + lookback_end=$(echo "$date_ranges" | jq -r '.lookback.end') + discovered=$(discover_projects_with_artifact_spend "$billing_table" "$lookback_start" "$lookback_end" || true) + if [[ -n "$discovered" ]]; then + PROJECT_IDS="$discovered" + log "Auto-discovered projects with artifact spend: $PROJECT_IDS" + fi + fi + + project_filter=$(build_project_filter) + date_ranges=$(get_date_ranges) + BILLING_TABLE="$billing_table" + PROJECT_FILTER="$project_filter" + DATE_RANGES="$date_ranges" + export BILLING_TABLE PROJECT_FILTER DATE_RANGES PROJECT_IDS + return 0 +} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/artifact-spend-sli-check.sh b/codebundles/gcp-artifact-registry-spend-analysis/artifact-spend-sli-check.sh new file mode 100755 index 00000000..93ff5258 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/artifact-spend-sli-check.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Lightweight SLI probe: artifact spend health dimensions for 0-1 scoring. + +source "$(dirname "$0")/artifact-billing-common.sh" + +OUTPUT_FILE="${OUTPUT_FILE:-artifact_sli_metrics.json}" + +default_metrics='{"anomaly_score":0,"mom_score":0,"share_score":0,"issue_count":1}' + +if ! ensure_billing_context; then + echo "$default_metrics" > "$OUTPUT_FILE" + exit 0 +fi + +lookback_start=$(echo "$DATE_RANGES" | jq -r '.lookback.start') +lookback_end=$(echo "$DATE_RANGES" | jq -r '.lookback.end') +rows=$(query_artifact_cost_rows "$BILLING_TABLE" "$lookback_start" "$lookback_end" "$PROJECT_FILTER") + +total_cost=$(echo "$rows" | jq '[.[].total_cost | tonumber] | add // 0') +anomaly_score=1 +mom_score=1 +share_score=1 +issue_count=0 + +# Project share check +threshold="${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT:-0}" +if [[ "$threshold" =~ ^[0-9]+$ ]] && [[ "$threshold" -gt 0 ]] && (( $(echo "$total_cost > 0" | bc -l) )); then + max_share=$(echo "$rows" | jq --arg total "$total_cost" ' + group_by(.project_id) | + map({share: ((map(.total_cost | tonumber) | add // 0) / ($total | tonumber) * 100)}) | + max_by(.share) | .share // 0 + ') + if (( $(echo "$max_share >= $threshold" | bc -l) )); then + share_score=0 + issue_count=$((issue_count + 1)) + fi +fi + +# MoM check (most recent two complete months) +months=$(get_last_three_complete_months) +m1_start=$(echo "$months" | jq -r '.month1.start') +m1_end=$(echo "$months" | jq -r '.month1.end') +m2_start=$(echo "$months" | jq -r '.month2.start') +m2_end=$(echo "$months" | jq -r '.month2.end') + +query_month_total() { + local start="$1" + local end="$2" + local sku_filter + sku_filter=$(artifact_sku_filter_sql) + local query=" +SELECT SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) >= '${start}' + AND DATE(usage_start_time) <= '${end}' + AND ${sku_filter} + ${PROJECT_FILTER} +" + run_bq_json_query "$BILLING_TABLE" "$query" | jq -r '.[0].total_cost // 0' +} + +m1_total=$(query_month_total "$m1_start" "$m1_end") +m2_total=$(query_month_total "$m2_start" "$m2_end") +mom_threshold="${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT:-25}" +if (( $(echo "$m2_total > 0" | bc -l) )); then + mom_pct=$(echo "scale=2; (($m1_total - $m2_total) / $m2_total) * 100" | bc -l) + if (( $(echo "$mom_pct >= $mom_threshold" | bc -l) )); then + mom_score=0 + issue_count=$((issue_count + 1)) + fi +fi + +# Daily spike check on storage SKUs (last 7 days only for speed) +storage_filter="AND $(artifact_storage_sku_filter_sql)" +sku_filter=$(artifact_sku_filter_sql) +week_start=$(echo "$DATE_RANGES" | jq -r '.weekly.start') +week_end=$(echo "$DATE_RANGES" | jq -r '.weekly.end') +query=" +SELECT DATE(usage_start_time) AS usage_date, + SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) >= '${week_start}' + AND DATE(usage_start_time) <= '${week_end}' + AND ${sku_filter} + ${storage_filter} + ${PROJECT_FILTER} +GROUP BY usage_date +" +daily_rows=$(run_bq_json_query "$BILLING_TABLE" "$query") +daily_costs=$(echo "$daily_rows" | jq -r '.[].total_cost') +avg_daily=$(echo "$daily_costs" | awk 'BEGIN{sum=0; count=0} $1>0{sum+=$1; count++} END{if(count>0) print sum/count; else print 0}') +spike_multiplier="${ARTIFACT_COST_SPIKE_MULTIPLIER:-2}" + +while IFS= read -r cost; do + [[ -z "$cost" ]] && continue + if (( $(echo "$cost > 0 && $avg_daily > 0" | bc -l) )); then + multiplier=$(echo "scale=2; $cost / $avg_daily" | bc -l) + if (( $(echo "$multiplier >= $spike_multiplier" | bc -l) )); then + anomaly_score=0 + issue_count=$((issue_count + 1)) + break + fi + fi +done <<< "$daily_costs" + +jq -n \ + --argjson anomaly_score "$anomaly_score" \ + --argjson mom_score "$mom_score" \ + --argjson share_score "$share_score" \ + --argjson issue_count "$issue_count" \ + '{ + anomaly_score: $anomaly_score, + mom_score: $mom_score, + share_score: $share_score, + issue_count: $issue_count + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/gcp-artifact-registry-spend-analysis/compare-artifact-spend-mom.sh b/codebundles/gcp-artifact-registry-spend-analysis/compare-artifact-spend-mom.sh new file mode 100755 index 00000000..fd7b0b8b --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/compare-artifact-spend-mom.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Compare artifact spend across the last three complete calendar months. + +source "$(dirname "$0")/artifact-billing-common.sh" + +REPORT_FILE="${REPORT_FILE:-artifact_mom_report.txt}" +ISSUES_FILE="${ISSUES_FILE:-artifact_mom_issues.json}" + +init_issues_file "$ISSUES_FILE" + +if ! ensure_billing_context; then + cp "$ISSUES_FILE" artifact_mom_output.json + cat "$ISSUES_FILE" + exit 0 +fi + +months=$(get_last_three_complete_months) +m1_start=$(echo "$months" | jq -r '.month1.start') +m1_end=$(echo "$months" | jq -r '.month1.end') +m2_start=$(echo "$months" | jq -r '.month2.start') +m2_end=$(echo "$months" | jq -r '.month2.end') +m3_start=$(echo "$months" | jq -r '.month3.start') +m3_end=$(echo "$months" | jq -r '.month3.end') + +query_month_total() { + local start="$1" + local end="$2" + local extra_filter="${3:-}" + local sku_filter + sku_filter=$(artifact_sku_filter_sql) + local query=" +SELECT + SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) >= '${start}' + AND DATE(usage_start_time) <= '${end}' + AND ${sku_filter} + ${extra_filter} + ${PROJECT_FILTER} +" + run_bq_json_query "$BILLING_TABLE" "$query" | jq -r '.[0].total_cost // 0' +} + +storage_filter="AND $(artifact_storage_sku_filter_sql)" +transfer_filter="AND $(artifact_transfer_sku_filter_sql)" + +m1_total=$(query_month_total "$m1_start" "$m1_end") +m2_total=$(query_month_total "$m2_start" "$m2_end") +m3_total=$(query_month_total "$m3_start" "$m3_end") + +m1_storage=$(query_month_total "$m1_start" "$m1_end" "$storage_filter") +m2_storage=$(query_month_total "$m2_start" "$m2_end" "$storage_filter") +m1_transfer=$(query_month_total "$m1_start" "$m1_end" "$transfer_filter") +m2_transfer=$(query_month_total "$m2_start" "$m2_end" "$transfer_filter") + +mom_pct=0 +if (( $(echo "$m2_total > 0" | bc -l) )); then + mom_pct=$(echo "scale=2; (($m1_total - $m2_total) / $m2_total) * 100" | bc -l) +fi + +storage_mom_pct=0 +if (( $(echo "$m2_storage > 0" | bc -l) )); then + storage_mom_pct=$(echo "scale=2; (($m1_storage - $m2_storage) / $m2_storage) * 100" | bc -l) +fi + +transfer_mom_pct=0 +if (( $(echo "$m2_transfer > 0" | bc -l) )); then + transfer_mom_pct=$(echo "scale=2; (($m1_transfer - $m2_transfer) / $m2_transfer) * 100" | bc -l) +fi + +{ + echo "Artifact Registry Month-over-Month Comparison" + echo "=============================================" + echo "Month 3 (${m3_start}): \$$(printf "%.2f" "$m3_total")" + echo "Month 2 (${m2_start}): \$$(printf "%.2f" "$m2_total")" + echo "Month 1 (${m1_start}, most recent complete): \$$(printf "%.2f" "$m1_total")" + echo "" + echo "MoM change (month1 vs month2): ${mom_pct}%" + echo "Storage MoM: ${storage_mom_pct}% (M1=\$${m1_storage}, M2=\$${m2_storage})" + echo "Transfer/pull MoM: ${transfer_mom_pct}% (M1=\$${m1_transfer}, M2=\$${m2_transfer})" + echo "Threshold: ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT}%" +} | tee "$REPORT_FILE" + +threshold="${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT:-25}" +if (( $(echo "$mom_pct >= $threshold" | bc -l) )); then + issue=$(jq -n \ + --arg title "Artifact Registry Spend Growth Exceeds MoM Threshold" \ + --argjson severity 2 \ + --arg mom "$mom_pct" \ + --arg threshold "$threshold" \ + --arg m1 "$m1_total" \ + --arg m2 "$m2_total" \ + '{ + title: $title, + severity: $severity, + expected: ("Artifact spend should not grow more than " + $threshold + "% month-over-month"), + actual: ("Spend grew " + $mom + "% from $" + $m2 + " to $" + $m1), + details: ("Month-over-month artifact spend increased " + $mom + "% (threshold " + $threshold + "%)."), + next_steps: "Identify new repositories or tags driving growth. Enable cleanup policies and retire unused legacy GCR images." + }') + append_issue "$ISSUES_FILE" "$issue" +fi + +if (( $(echo "$storage_mom_pct >= $threshold" | bc -l) )) && (( $(echo "$transfer_mom_pct < 10" | bc -l) )); then + issue=$(jq -n \ + --arg title "Artifact Storage Costs Rising Without Pull Activity" \ + --argjson severity 2 \ + --arg storage_mom "$storage_mom_pct" \ + --arg transfer_mom "$transfer_mom_pct" \ + '{ + title: $title, + severity: $severity, + expected: "Storage cost growth should correlate with artifact pull/transfer activity", + actual: ("Storage MoM " + $storage_mom + "% while transfer MoM " + $transfer_mom + "%"), + details: "Artifact storage spend is growing faster than egress/pull charges, suggesting stale image accumulation.", + next_steps: "Run gcp-artifact-registry-governance inventory tasks and enable cleanup policies for untagged or aged artifacts." + }') + append_issue "$ISSUES_FILE" "$issue" +fi + +cp "$ISSUES_FILE" artifact_mom_output.json +echo "Month-over-month comparison completed." diff --git a/codebundles/gcp-artifact-registry-spend-analysis/detect-artifact-cost-anomalies.sh b/codebundles/gcp-artifact-registry-spend-analysis/detect-artifact-cost-anomalies.sh new file mode 100755 index 00000000..7444320d --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/detect-artifact-cost-anomalies.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Detect daily artifact storage cost spikes and sustained weekly deviations. + +source "$(dirname "$0")/artifact-billing-common.sh" + +REPORT_FILE="${REPORT_FILE:-artifact_anomaly_report.txt}" +ISSUES_FILE="${ISSUES_FILE:-artifact_anomaly_issues.json}" + +init_issues_file "$ISSUES_FILE" + +if ! ensure_billing_context; then + cp "$ISSUES_FILE" artifact_anomaly_output.json + cat "$ISSUES_FILE" + exit 0 +fi + +lookback_start=$(echo "$DATE_RANGES" | jq -r '.lookback.start') +lookback_end=$(echo "$DATE_RANGES" | jq -r '.lookback.end') +week_start=$(echo "$DATE_RANGES" | jq -r '.weekly.start') +week_end=$(echo "$DATE_RANGES" | jq -r '.weekly.end') +month_start=$(echo "$DATE_RANGES" | jq -r '.monthly.start') +month_end=$(echo "$DATE_RANGES" | jq -r '.monthly.end') + +storage_filter="AND $(artifact_storage_sku_filter_sql)" +sku_filter=$(artifact_sku_filter_sql) + +query=" +SELECT + sku.description AS sku_description, + service.description AS service_name, + DATE(usage_start_time) AS usage_date, + SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) >= '${lookback_start}' + AND DATE(usage_start_time) <= '${lookback_end}' + AND ${sku_filter} + ${storage_filter} + ${PROJECT_FILTER} +GROUP BY sku_description, service_name, usage_date +ORDER BY usage_date DESC +" + +rows=$(run_bq_json_query "$BILLING_TABLE" "$query") + +aggregated=$(echo "$rows" | jq --argjson ranges "$DATE_RANGES" ' + group_by(.sku_description) | + map({ + sku: .[0].sku_description, + service: .[0].service_name, + daily: ($ranges.daily | map(. as $date | { + date: $date, + cost: ([.[] | select(.usage_date == $date) | .total_cost | tonumber] | add // 0) + })), + weeklyCost: ([.[] | select(.usage_date >= $ranges.weekly.start and .usage_date <= $ranges.weekly.end) | .total_cost | tonumber] | add // 0), + monthlyCost: ([.[] | select(.usage_date >= $ranges.monthly.start and .usage_date <= $ranges.monthly.end) | .total_cost | tonumber] | add // 0) + }) +') + +spike_multiplier="${ARTIFACT_COST_SPIKE_MULTIPLIER:-2}" +spike_count=0 +weekly_deviation_count=0 + +while IFS= read -r sku_data; do + [[ -z "$sku_data" ]] && continue + sku=$(echo "$sku_data" | jq -r '.sku') + service=$(echo "$sku_data" | jq -r '.service') + daily_costs=$(echo "$sku_data" | jq -r '.daily[].cost') + avg_daily=$(echo "$daily_costs" | awk 'BEGIN{sum=0; count=0} $1>0{sum+=$1; count++} END{if(count>0) print sum/count; else print 0}') + + while IFS= read -r day; do + [[ -z "$day" ]] && continue + date=$(echo "$day" | jq -r '.date') + cost=$(echo "$day" | jq -r '.cost') + if (( $(echo "$cost > 0 && $avg_daily > 0" | bc -l) )); then + multiplier=$(echo "scale=2; $cost / $avg_daily" | bc -l) + if (( $(echo "$multiplier >= $spike_multiplier" | bc -l) )); then + issue=$(jq -n \ + --arg title "Artifact Storage Cost Spike: \`${sku}\` on ${date}" \ + --argjson severity 2 \ + --arg sku "$sku" \ + --arg service "$service" \ + --arg date "$date" \ + --arg cost "$cost" \ + --arg avg "$avg_daily" \ + --arg multiplier "$multiplier" \ + '{ + title: $title, + severity: $severity, + expected: ("Daily artifact storage cost should stay near 7-day average ($" + $avg + ")"), + actual: ("Cost on " + $date + " was $" + $cost + " (" + $multiplier + "x average)"), + details: ("Service: " + $service + "\nSKU: " + $sku), + next_steps: "Check for bulk image pushes, failed cleanup jobs, or scanning charges on the spike date." + }') + append_issue "$ISSUES_FILE" "$issue" + spike_count=$((spike_count + 1)) + fi + fi + done < <(echo "$sku_data" | jq -c '.daily[]') + + weekly_cost=$(echo "$sku_data" | jq -r '.weeklyCost') + monthly_cost=$(echo "$sku_data" | jq -r '.monthlyCost') + if (( $(echo "$monthly_cost > 0 && $weekly_cost > 0" | bc -l) )); then + expected_weekly=$(echo "scale=2; $monthly_cost * 7 / 30" | bc -l) + weekly_ratio=$(echo "scale=2; $weekly_cost / $expected_weekly" | bc -l) + if (( $(echo "$weekly_ratio >= 1.5" | bc -l) )); then + increase_percent=$(echo "scale=1; ($weekly_ratio - 1) * 100" | bc -l) + issue=$(jq -n \ + --arg title "Sustained Artifact Storage Cost Deviation: \`${sku}\`" \ + --argjson severity 2 \ + --arg sku "$sku" \ + --arg weekly "$weekly_cost" \ + --arg expected "$expected_weekly" \ + --arg increase "$increase_percent" \ + '{ + title: $title, + severity: $severity, + expected: ("Weekly artifact storage should track 30-day trend (~$" + $expected + ")"), + actual: ("Last 7 days cost $" + $weekly + ", " + $increase + "% above trend"), + details: ("SKU `" + $sku + "` weekly spend deviates from 30-day baseline."), + next_steps: "Audit repository growth and duplicate tags. Correlate with governance bundle inventory output." + }') + append_issue "$ISSUES_FILE" "$issue" + weekly_deviation_count=$((weekly_deviation_count + 1)) + fi + fi +done < <(echo "$aggregated" | jq -c '.[]') + +{ + echo "Artifact Storage Cost Anomaly Detection" + echo "=======================================" + echo "Spike threshold: ${spike_multiplier}x 7-day average" + echo "Daily spikes detected: ${spike_count}" + echo "Weekly deviations detected: ${weekly_deviation_count}" + echo "Analysis window: ${lookback_start} to ${lookback_end}" +} | tee "$REPORT_FILE" + +cp "$ISSUES_FILE" artifact_anomaly_output.json +echo "Anomaly detection completed." diff --git a/codebundles/gcp-artifact-registry-spend-analysis/generate-artifact-spend-recommendations.sh b/codebundles/gcp-artifact-registry-spend-analysis/generate-artifact-spend-recommendations.sh new file mode 100755 index 00000000..c97ce545 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/generate-artifact-spend-recommendations.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Consolidate artifact spend findings into actionable optimization recommendations. + +source "$(dirname "$0")/artifact-billing-common.sh" + +REPORT_FILE="${REPORT_FILE:-artifact_recommendations_report.txt}" +ISSUES_FILE="${ISSUES_FILE:-artifact_recommendations_issues.json}" + +init_issues_file "$ISSUES_FILE" + +if ! ensure_billing_context; then + cp "$ISSUES_FILE" artifact_recommendations_output.json + cat "$ISSUES_FILE" + exit 0 +fi + +lookback_start=$(echo "$DATE_RANGES" | jq -r '.lookback.start') +lookback_end=$(echo "$DATE_RANGES" | jq -r '.lookback.end') +rows=$(query_artifact_cost_rows "$BILLING_TABLE" "$lookback_start" "$lookback_end" "$PROJECT_FILTER") + +total_cost=$(echo "$rows" | jq '[.[].total_cost | tonumber] | add // 0') +gcr_cost=$(echo "$rows" | jq '[.[] | select((.service_name // "") | test("Container Registry"; "i") or (.sku_description // "") | test("Container Registry"; "i")) | .total_cost | tonumber] | add // 0') +scan_cost=$(echo "$rows" | jq '[.[] | select((.sku_description // "") | test("scan"; "i")) | .total_cost | tonumber] | add // 0') +storage_cost=$(echo "$rows" | jq '[.[] | select((.sku_description // "") | test("storage|stored"; "i")) | .total_cost | tonumber] | add // 0') + +top_projects=$(echo "$rows" | jq ' + group_by(.project_id) | + map({projectId: .[0].project_id, cost: (map(.total_cost | tonumber) | add // 0)}) | + sort_by(-.cost) | .[:5] +') + +recommendations=() + +if (( $(echo "$gcr_cost > 0" | bc -l) )); then + gcr_share=$(echo "scale=1; 100 * $gcr_cost / ($total_cost + 0.0001)" | bc -l) + recommendations+=("Migrate legacy Container Registry (gcr.io) workloads to Artifact Registry; legacy GCR represents ~${gcr_share}% of artifact spend.") + issue=$(jq -n \ + --arg title "Retire Legacy Container Registry to Reduce Artifact Spend" \ + --argjson severity 3 \ + --arg gcr_cost "$gcr_cost" \ + --arg share "$gcr_share" \ + '{ + title: $title, + severity: $severity, + expected: "Production workloads should use Artifact Registry instead of legacy GCR", + actual: ("Legacy GCR accounts for $" + $gcr_cost + " (" + $share + "% of artifact spend)"), + details: "Legacy GCR SKUs remain in billing export and often indicate unmigrated or stale images.", + next_steps: "Migrate images to Artifact Registry, update CI/CD references, then delete unused gcr.io repositories." + }') + append_issue "$ISSUES_FILE" "$issue" +fi + +if (( $(echo "$storage_cost > 0" | bc -l) )) && (( $(echo "$storage_cost / ($total_cost + 0.0001) > 0.6" | bc -l) )); then + recommendations+=("Enable Artifact Registry cleanup policies to prune untagged or aged images; storage dominates artifact spend.") + issue=$(jq -n \ + --arg title "Enable Artifact Registry Cleanup Policies" \ + --argjson severity 3 \ + --arg storage_cost "$storage_cost" \ + '{ + title: $title, + severity: $severity, + expected: "Artifact storage spend should be controlled with lifecycle cleanup policies", + actual: ("Storage SKUs account for $" + $storage_cost + " of artifact spend"), + details: "High storage share typically indicates stale tags and missing cleanup policies.", + next_steps: "Configure cleanup policies per repository. Use gcp-artifact-registry-governance to find repositories without policies." + }') + append_issue "$ISSUES_FILE" "$issue" +fi + +if (( $(echo "$scan_cost > 0" | bc -l) )) && (( $(echo "$scan_cost / ($total_cost + 0.0001) > 0.15" | bc -l) )); then + recommendations+=("Right-size vulnerability scanning scope; scanning represents a material share of artifact spend.") + issue=$(jq -n \ + --arg title "Review Artifact Vulnerability Scanning Scope" \ + --argjson severity 4 \ + --arg scan_cost "$scan_cost" \ + '{ + title: $title, + severity: $severity, + expected: "Scanning spend should align with security requirements without scanning unnecessary tags", + actual: ("Scanning SKUs cost $" + $scan_cost + " in the lookback window"), + details: "Excessive scanning charges may indicate scanning all tags including ephemeral CI builds.", + next_steps: "Limit scanning to production tags or enable scanning only on push to protected repositories." + }') + append_issue "$ISSUES_FILE" "$issue" +fi + +if [[ "$(echo "$top_projects" | jq 'length')" -gt 0 ]]; then + high_cost_projects=$(echo "$top_projects" | jq -r '.[] | select(.cost > 0) | .projectId' | paste -sd, -) + if [[ -n "$high_cost_projects" ]]; then + recommendations+=("Follow up on high-spend projects (${high_cost_projects}) with gcp-artifact-registry-governance inventory tasks.") + issue=$(jq -n \ + --arg title "Cross-Reference High Artifact Spend Projects with Governance Bundle" \ + --argjson severity 4 \ + --arg projects "$high_cost_projects" \ + '{ + title: $title, + severity: $severity, + expected: "High artifact spend projects should be reviewed for stale artifacts and missing cleanup policies", + actual: ("Top artifact spend projects: " + $projects), + details: "BigQuery billing export does not expose repository names; correlate project-level spend with governance inventory.", + next_steps: "Run gcp-artifact-registry-governance against the listed projects to identify stale images and policy gaps." + }') + append_issue "$ISSUES_FILE" "$issue" + fi +fi + +recommendations+=("Reduce duplicate tags by enforcing immutable tags in CI/CD and pruning redundant build artifacts.") + +{ + echo "Artifact Registry Spend Optimization Summary" + echo "============================================" + echo "Total artifact spend (${lookback_start} to ${lookback_end}): \$$(printf "%.2f" "$total_cost")" + echo " Storage: \$$(printf "%.2f" "$storage_cost")" + echo " Legacy GCR: \$$(printf "%.2f" "$gcr_cost")" + echo " Scanning: \$$(printf "%.2f" "$scan_cost")" + echo "" + echo "Recommendations:" + idx=1 + for rec in "${recommendations[@]}"; do + echo " ${idx}. ${rec}" + idx=$((idx + 1)) + done +} | tee "$REPORT_FILE" + +cp "$ISSUES_FILE" artifact_recommendations_output.json +echo "Optimization summary completed." diff --git a/codebundles/gcp-artifact-registry-spend-analysis/report-top-artifact-cost-contributors.sh b/codebundles/gcp-artifact-registry-spend-analysis/report-top-artifact-cost-contributors.sh new file mode 100755 index 00000000..8bf27b05 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/report-top-artifact-cost-contributors.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +# Rank projects and SKUs by artifact storage/transfer spend. + +source "$(dirname "$0")/artifact-billing-common.sh" + +REPORT_FILE="${REPORT_FILE:-artifact_top_contributors_report.txt}" +ISSUES_FILE="${ISSUES_FILE:-artifact_top_contributors_issues.json}" + +init_issues_file "$ISSUES_FILE" + +if ! ensure_billing_context; then + cp "$ISSUES_FILE" artifact_top_contributors_output.json + cat "$ISSUES_FILE" + exit 0 +fi + +lookback_start=$(echo "$DATE_RANGES" | jq -r '.lookback.start') +lookback_end=$(echo "$DATE_RANGES" | jq -r '.lookback.end') +rows=$(query_artifact_cost_rows "$BILLING_TABLE" "$lookback_start" "$lookback_end" "$PROJECT_FILTER") + +by_project=$(echo "$rows" | jq ' + group_by(.project_id) | + map({ + projectId: .[0].project_id, + projectName: (.[0].project_name // .[0].project_id), + totalCost: (map(.total_cost | tonumber) | add // 0) + }) | sort_by(-.totalCost) +') + +by_sku=$(echo "$rows" | jq ' + group_by(.sku_description) | + map({ + sku: .[0].sku_description, + service: .[0].service_name, + totalCost: (map(.total_cost | tonumber) | add // 0) + }) | sort_by(-.totalCost) +') + +total_cost=$(echo "$by_project" | jq '[.[].totalCost] | add // 0') + +{ + echo "Top Artifact Registry Cost Contributors" + echo "=======================================" + echo "Total artifact spend: \$$(printf "%.2f" "$total_cost")" + echo "" + echo "Top projects:" + echo "$by_project" | jq -r '.[:10][] | "- \(.projectId): $\(.totalCost | . * 100 | round / 100)"' + echo "" + echo "Top SKUs:" + echo "$by_sku" | jq -r '.[:10][] | "- \(.sku): $\(.totalCost | . * 100 | round / 100)"' +} | tee "$REPORT_FILE" + +threshold="${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT:-0}" +if [[ "$threshold" =~ ^[0-9]+$ ]] && [[ "$threshold" -gt 0 ]] && (( $(echo "$total_cost > 0" | bc -l) )); then + while IFS= read -r project_row; do + [[ -z "$project_row" ]] && continue + proj_id=$(echo "$project_row" | jq -r '.projectId') + proj_cost=$(echo "$project_row" | jq -r '.totalCost') + share=$(echo "scale=2; 100 * $proj_cost / $total_cost" | bc -l) + if (( $(echo "$share >= $threshold" | bc -l) )); then + issue=$(jq -n \ + --arg title "Artifact Spend Concentrated in Project \`${proj_id}\`" \ + --argjson severity 3 \ + --arg proj "$proj_id" \ + --arg cost "$proj_cost" \ + --arg share "$share" \ + --arg threshold "$threshold" \ + '{ + title: $title, + severity: $severity, + expected: ("No single project should exceed " + $threshold + "% of total artifact spend"), + actual: ("Project " + $proj + " accounts for " + $share + "% ($" + $cost + ")"), + details: ("Project " + $proj + " represents " + $share + "% of artifact-related spend in the lookback window."), + next_steps: "Review artifact inventory and cleanup policies in this project. Cross-reference with gcp-artifact-registry-governance for stale images and missing lifecycle rules." + }') + append_issue "$ISSUES_FILE" "$issue" + fi + done < <(echo "$by_project" | jq -c '.[]') +fi + +while IFS= read -r sku_row; do + [[ -z "$sku_row" ]] && continue + sku=$(echo "$sku_row" | jq -r '.sku') + service=$(echo "$sku_row" | jq -r '.service') + sku_cost=$(echo "$sku_row" | jq -r '.totalCost') + if [[ "$service" == *"Container Registry"* ]] || [[ "$sku" == *"Container Registry"* ]]; then + if (( $(echo "$sku_cost > 0" | bc -l) )); then + issue=$(jq -n \ + --arg title "Legacy Container Registry Spend Detected" \ + --argjson severity 3 \ + --arg sku "$sku" \ + --arg cost "$sku_cost" \ + '{ + title: $title, + severity: $severity, + expected: "Artifact spend should primarily use Artifact Registry rather than legacy GCR", + actual: ("Legacy GCR SKU `" + $sku + "` cost $" + $cost + " in lookback window"), + details: ("Legacy Container Registry SKU `" + $sku + "` is a top artifact cost contributor."), + next_steps: "Plan migration from gcr.io to Artifact Registry and delete unused legacy images after migration." + }') + append_issue "$ISSUES_FILE" "$issue" + break + fi + fi +done < <(echo "$by_sku" | jq -c '.[:3][]') + +cp "$ISSUES_FILE" artifact_top_contributors_output.json +echo "Top contributors analysis completed." diff --git a/codebundles/gcp-artifact-registry-spend-analysis/runbook.robot b/codebundles/gcp-artifact-registry-spend-analysis/runbook.robot new file mode 100644 index 00000000..56c0ed22 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/runbook.robot @@ -0,0 +1,310 @@ +*** Settings *** +Documentation Analyze Google Cloud Artifact Registry and legacy Container Registry spend from BigQuery billing export to surface storage and egress trends, top contributors, anomalies, and optimization recommendations. +Metadata Author rw-codebundle-agent +Metadata Display Name GCP Artifact Registry Spend Analysis +Metadata Supports GCP Artifact Registry Container Registry Cost Analysis BigQuery FinOps +Force Tags GCP Artifact Registry Container Registry Cost Analysis BigQuery FinOps + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library OperatingSystem +Library Collections +Suite Setup Suite Initialization + + +*** Tasks *** +Analyze Artifact Registry Spend by Project and SKU for `${GCP_PROJECT_IDS}` + [Documentation] Query BigQuery billing export filtered to Artifact Registry and legacy GCR SKUs and produce per-project, per-SKU totals with daily, weekly, and monthly rollups for the configured lookback window. + [Tags] GCP Artifact Registry Cost Analysis access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=analyze-artifact-registry-spend.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./analyze-artifact-registry-spend.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_spend_analysis_output.json" ]; then cat artifact_spend_analysis_output.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for analyze task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Artifact Spend Analysis: + RW.Core.Add Pre To Report ${result.stdout} + +Report Top Artifact Registry Cost Contributors for `${GCP_PROJECT_IDS}` + [Documentation] Rank projects and SKUs by artifact storage and transfer spend and highlight contributors exceeding configurable share or absolute thresholds. + [Tags] GCP Artifact Registry Cost Analysis access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=report-top-artifact-cost-contributors.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./report-top-artifact-cost-contributors.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_top_contributors_output.json" ]; then cat artifact_top_contributors_output.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for top contributors task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Top Artifact Cost Contributors: + RW.Core.Add Pre To Report ${result.stdout} + +Compare Artifact Registry Spend Month-over-Month for `${GCP_PROJECT_IDS}` + [Documentation] Compare artifact-related costs across the last three complete calendar months and raise issues when month-over-month growth exceeds the configured threshold or storage grows without corresponding pull activity. + [Tags] GCP Artifact Registry Trend Analysis access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=compare-artifact-spend-mom.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./compare-artifact-spend-mom.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_mom_output.json" ]; then cat artifact_mom_output.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for MoM task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Artifact Spend Month-over-Month: + RW.Core.Add Pre To Report ${result.stdout} + +Detect Artifact Storage Cost Anomalies for `${GCP_PROJECT_IDS}` + [Documentation] Detect daily artifact storage cost spikes at multiples of the 7-day average and sustained weekly deviations from the 30-day trend for artifact SKUs only. + [Tags] GCP Artifact Registry Anomaly Detection access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=detect-artifact-cost-anomalies.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./detect-artifact-cost-anomalies.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_anomaly_output.json" ]; then cat artifact_anomaly_output.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for anomaly task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Artifact Cost Anomalies: + RW.Core.Add Pre To Report ${result.stdout} + +Generate Artifact Registry Spend Optimization Summary for `${GCP_PROJECT_IDS}` + [Documentation] Consolidate artifact spend findings into actionable recommendations for cleanup policies, legacy GCR retirement, duplicate tag reduction, and scanning right-sizing with governance bundle follow-up for high-cost projects. + [Tags] GCP Artifact Registry Cost Optimization access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=generate-artifact-spend-recommendations.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./generate-artifact-spend-recommendations.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_recommendations_output.json" ]; then cat artifact_recommendations_output.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for recommendations task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Artifact Spend Optimization Summary: + RW.Core.Add Pre To Report ${result.stdout} + + +*** Keywords *** +Suite Initialization + TRY + ${gcp_credentials}= RW.Core.Import Secret gcp_credentials + ... type=string + ... description=GCP service account JSON with BigQuery billing export read access. + ... pattern=\w* + Set Suite Variable ${gcp_credentials} ${gcp_credentials} + EXCEPT + Log gcp_credentials not found, tasks may fail without authentication WARN + ${gcp_credentials}= Set Variable ${EMPTY} + Set Suite Variable ${gcp_credentials} ${gcp_credentials} + END + + ${GCP_PROJECT_IDS}= RW.Core.Import User Variable GCP_PROJECT_IDS + ... type=string + ... description=Comma-separated GCP project IDs to analyze; blank auto-discovers from billing export. + ... pattern=[\w,-]* + ... default="" + ${GCP_BILLING_EXPORT_TABLE}= RW.Core.Import User Variable GCP_BILLING_EXPORT_TABLE + ... type=string + ... description=BigQuery billing export table path (auto-discovered if unset). + ... pattern=.* + ... default="" + ${COST_ANALYSIS_LOOKBACK_DAYS}= RW.Core.Import User Variable COST_ANALYSIS_LOOKBACK_DAYS + ... type=string + ... description=Days of billing history to analyze. + ... pattern=\d+ + ... default=30 + ${ARTIFACT_COST_SPIKE_MULTIPLIER}= RW.Core.Import User Variable ARTIFACT_COST_SPIKE_MULTIPLIER + ... type=string + ... description=Daily cost spike threshold as multiple of 7-day average. + ... pattern=[0-9.]+ + ... default=2 + ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT}= RW.Core.Import User Variable ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT + ... type=string + ... description=Month-over-month growth percentage that triggers an issue. + ... pattern=\d+ + ... default=25 + ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT}= RW.Core.Import User Variable ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT + ... type=string + ... description=Project share of total artifact spend that triggers an issue; 0 disables. + ... pattern=\d+ + ... default=20 + ${OUTPUT_FORMAT}= RW.Core.Import User Variable OUTPUT_FORMAT + ... type=string + ... description=Report format table, csv, json, or all. + ... pattern=\w+ + ... default=table + ${GCP_ORG_WIDE_REPORT}= RW.Core.Import User Variable GCP_ORG_WIDE_REPORT + ... type=string + ... description=When true, analyze org-wide artifact spend instead of filtering to GCP_PROJECT_IDS. + ... pattern=(true|false) + ... default=false + ${OS_PATH}= Get Environment Variable PATH + + Set Suite Variable ${GCP_PROJECT_IDS} ${GCP_PROJECT_IDS} + Set Suite Variable ${GCP_BILLING_EXPORT_TABLE} ${GCP_BILLING_EXPORT_TABLE} + Set Suite Variable ${COST_ANALYSIS_LOOKBACK_DAYS} ${COST_ANALYSIS_LOOKBACK_DAYS} + Set Suite Variable ${ARTIFACT_COST_SPIKE_MULTIPLIER} ${ARTIFACT_COST_SPIKE_MULTIPLIER} + Set Suite Variable ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT} ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT} + Set Suite Variable ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT} ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT} + Set Suite Variable ${OUTPUT_FORMAT} ${OUTPUT_FORMAT} + Set Suite Variable ${GCP_ORG_WIDE_REPORT} ${GCP_ORG_WIDE_REPORT} + + ${env_dict}= Create Dictionary + Set To Dictionary ${env_dict} GOOGLE_APPLICATION_CREDENTIALS ./${gcp_credentials.key} + Set To Dictionary ${env_dict} COST_ANALYSIS_LOOKBACK_DAYS ${COST_ANALYSIS_LOOKBACK_DAYS} + Set To Dictionary ${env_dict} ARTIFACT_COST_SPIKE_MULTIPLIER ${ARTIFACT_COST_SPIKE_MULTIPLIER} + Set To Dictionary ${env_dict} ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT} + Set To Dictionary ${env_dict} ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT} + Set To Dictionary ${env_dict} OUTPUT_FORMAT ${OUTPUT_FORMAT} + Set To Dictionary ${env_dict} GCP_ORG_WIDE_REPORT ${GCP_ORG_WIDE_REPORT} + Set To Dictionary ${env_dict} PATH ${OS_PATH} + IF $GCP_PROJECT_IDS != "" and $GCP_PROJECT_IDS != '""' + Set To Dictionary ${env_dict} GCP_PROJECT_IDS ${GCP_PROJECT_IDS} + END + IF $GCP_BILLING_EXPORT_TABLE != "" and $GCP_BILLING_EXPORT_TABLE != '""' + Set To Dictionary ${env_dict} GCP_BILLING_EXPORT_TABLE ${GCP_BILLING_EXPORT_TABLE} + END + Set Suite Variable ${env} ${env_dict} diff --git a/codebundles/gcp-artifact-registry-spend-analysis/sli.robot b/codebundles/gcp-artifact-registry-spend-analysis/sli.robot new file mode 100644 index 00000000..edca9316 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/sli.robot @@ -0,0 +1,129 @@ +*** Settings *** +Documentation Measures GCP Artifact Registry spend health by scoring anomaly signals, month-over-month growth, and project spend concentration. Produces a value between 0 (failing) and 1 (fully passing). +Metadata Author rw-codebundle-agent +Metadata Display Name GCP Artifact Registry Spend Analysis +Metadata Supports GCP Artifact Registry Cost Analysis BigQuery +Suite Setup Suite Initialization + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library OperatingSystem +Library Collections + + +*** Tasks *** +Check Artifact Spend Anomaly Signals for `${GCP_PROJECT_IDS}` + [Documentation] Scores whether recent artifact storage daily costs remain within the configured spike multiplier of the 7-day average. + [Tags] GCP Artifact Registry access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=artifact-spend-sli-check.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=60 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./artifact-spend-sli-check.sh + + TRY + ${metrics}= Evaluate json.loads(r'''${result.stdout}''') json + ${anomaly_score}= Set Variable ${metrics['anomaly_score']} + ${mom_score}= Set Variable ${metrics['mom_score']} + ${share_score}= Set Variable ${metrics['share_score']} + EXCEPT + Log Failed to parse SLI metrics, defaulting scores to 0. WARN + ${anomaly_score}= Set Variable ${0} + ${mom_score}= Set Variable ${0} + ${share_score}= Set Variable ${0} + END + + Set Suite Variable ${anomaly_score} + Set Suite Variable ${mom_score} + Set Suite Variable ${share_score} + RW.Core.Push Metric ${anomaly_score} sub_name=anomaly_signals + +Check Artifact Spend MoM Growth for `${GCP_PROJECT_IDS}` + [Documentation] Scores whether artifact spend month-over-month growth stays below the configured threshold. + [Tags] GCP Artifact Registry access:read-only data:metrics + + RW.Core.Push Metric ${mom_score} sub_name=mom_growth + +Check Artifact Spend Project Concentration for `${GCP_PROJECT_IDS}` + [Documentation] Scores whether any single project exceeds the configured share of total artifact spend. + [Tags] GCP Artifact Registry access:read-only data:metrics + + RW.Core.Push Metric ${share_score} sub_name=project_concentration + +Generate Artifact Registry Spend Health Score for `${GCP_PROJECT_IDS}` + [Documentation] Averages artifact spend sub-scores into the final 0-1 health metric. + [Tags] GCP Artifact Registry access:read-only data:metrics + + ${health_score}= Evaluate (${anomaly_score} + ${mom_score} + ${share_score}) / 3 + ${health_score}= Convert To Number ${health_score} 2 + RW.Core.Add to Report Artifact Registry Spend Health Score: ${health_score} + RW.Core.Push Metric ${health_score} + + +*** Keywords *** +Suite Initialization + ${gcp_credentials}= RW.Core.Import Secret gcp_credentials + ... type=string + ... description=GCP service account JSON with BigQuery billing export read access. + ... pattern=\w* + ${GCP_PROJECT_IDS}= RW.Core.Import User Variable GCP_PROJECT_IDS + ... type=string + ... description=Comma-separated GCP project IDs to analyze; blank auto-discovers from billing export. + ... pattern=[\w,-]* + ... default="" + ${GCP_BILLING_EXPORT_TABLE}= RW.Core.Import User Variable GCP_BILLING_EXPORT_TABLE + ... type=string + ... description=BigQuery billing export table path (auto-discovered if unset). + ... pattern=.* + ... default="" + ${COST_ANALYSIS_LOOKBACK_DAYS}= RW.Core.Import User Variable COST_ANALYSIS_LOOKBACK_DAYS + ... type=string + ... description=Days of billing history to analyze. + ... pattern=\d+ + ... default=30 + ${ARTIFACT_COST_SPIKE_MULTIPLIER}= RW.Core.Import User Variable ARTIFACT_COST_SPIKE_MULTIPLIER + ... type=string + ... description=Daily cost spike threshold as multiple of 7-day average. + ... pattern=[0-9.]+ + ... default=2 + ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT}= RW.Core.Import User Variable ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT + ... type=string + ... description=Month-over-month growth percentage that triggers an issue. + ... pattern=\d+ + ... default=25 + ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT}= RW.Core.Import User Variable ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT + ... type=string + ... description=Project share of total artifact spend that triggers an issue; 0 disables. + ... pattern=\d+ + ... default=20 + ${GCP_ORG_WIDE_REPORT}= RW.Core.Import User Variable GCP_ORG_WIDE_REPORT + ... type=string + ... description=When true, analyze org-wide artifact spend instead of filtering to GCP_PROJECT_IDS. + ... pattern=(true|false) + ... default=false + ${OS_PATH}= Get Environment Variable PATH + + Set Suite Variable ${GCP_PROJECT_IDS} ${GCP_PROJECT_IDS} + Set Suite Variable ${gcp_credentials} ${gcp_credentials} + + ${env_dict}= Create Dictionary + Set To Dictionary ${env_dict} GOOGLE_APPLICATION_CREDENTIALS ./${gcp_credentials.key} + Set To Dictionary ${env_dict} COST_ANALYSIS_LOOKBACK_DAYS ${COST_ANALYSIS_LOOKBACK_DAYS} + Set To Dictionary ${env_dict} ARTIFACT_COST_SPIKE_MULTIPLIER ${ARTIFACT_COST_SPIKE_MULTIPLIER} + Set To Dictionary ${env_dict} ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT ${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT} + Set To Dictionary ${env_dict} ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT ${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT} + Set To Dictionary ${env_dict} GCP_ORG_WIDE_REPORT ${GCP_ORG_WIDE_REPORT} + Set To Dictionary ${env_dict} PATH ${OS_PATH} + IF $GCP_PROJECT_IDS != "" and $GCP_PROJECT_IDS != '""' + Set To Dictionary ${env_dict} GCP_PROJECT_IDS ${GCP_PROJECT_IDS} + END + IF $GCP_BILLING_EXPORT_TABLE != "" and $GCP_BILLING_EXPORT_TABLE != '""' + Set To Dictionary ${env_dict} GCP_BILLING_EXPORT_TABLE ${GCP_BILLING_EXPORT_TABLE} + END + Set Suite Variable ${env} ${env_dict}