From 29bcc0eec82aa20a111862470b290ddc55256181 Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Tue, 30 Jun 2026 18:32:21 +0000 Subject: [PATCH] Add gcp-artifact-registry-spend-analysis CodeBundle Analyze Artifact Registry and legacy GCR spend from BigQuery billing export with MoM trends, top contributors, anomaly detection, and SLI. Co-authored-by: Cursor --- .../gcp-artifact-registry-spend-analysis.yaml | 23 ++ ...-artifact-registry-spend-analysis-sli.yaml | 50 +++ ...-artifact-registry-spend-analysis-slx.yaml | 31 ++ ...ifact-registry-spend-analysis-taskset.yaml | 45 +++ .../.test/README.md | 15 + .../.test/Taskfile.yaml | 17 ++ .../.test/validate-all-tests.sh | 28 ++ .../README.md | 68 +++++ .../analyze-artifact-registry-spend.sh | 97 ++++++ .../artifact-billing-helpers.sh | 177 +++++++++++ .../compare-artifact-spend-mom.sh | 129 ++++++++ .../detect-artifact-cost-anomalies.sh | 116 +++++++ ...generate-artifact-spend-recommendations.sh | 109 +++++++ .../report-top-artifact-cost-contributors.sh | 125 ++++++++ .../runbook.robot | 289 ++++++++++++++++++ .../sli-artifact-spend-health-score.sh | 93 ++++++ .../sli.robot | 119 ++++++++ 17 files changed, 1531 insertions(+) create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.test/README.md create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/.test/Taskfile.yaml create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/.test/validate-all-tests.sh create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/README.md create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/analyze-artifact-registry-spend.sh create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-helpers.sh create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/compare-artifact-spend-mom.sh create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/detect-artifact-cost-anomalies.sh create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/generate-artifact-spend-recommendations.sh create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/report-top-artifact-cost-contributors.sh create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/runbook.robot create mode 100755 codebundles/gcp-artifact-registry-spend-analysis/sli-artifact-spend-health-score.sh create mode 100644 codebundles/gcp-artifact-registry-spend-analysis/sli.robot 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..01e0f987 --- /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: + - gcp_projects + 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..5e64c659 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml @@ -0,0 +1,50 @@ +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 {{ match_resource.project_name }} from billing export access, MoM growth, and cost anomaly 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') }}" + 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..831e6ae7 --- /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 + asMeasuredBy: Artifact spend health based on billing export trends, MoM growth, and cost anomalies. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{ workspace.owner_email }} + statement: GCP Artifact Registry and legacy GCR spend should remain predictable with no disproportionate project concentration or unexplained 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..142b45db --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml @@ -0,0 +1,45 @@ +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') }}" + 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..bc6ff1c5 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/README.md @@ -0,0 +1,15 @@ +# Test Infrastructure + +Static structure validation only. Live BigQuery billing export integration requires GCP credentials and a configured billing export table. + +Run from this directory: + +```bash +task +``` + +Or: + +```bash +./validate-all-tests.sh +``` 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..0980025d --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/Taskfile.yaml @@ -0,0 +1,17 @@ +version: "3" + +tasks: + default: + desc: "Validate CodeBundle structure" + cmds: + - task: validate-structure + + validate-structure: + desc: "Run static checks for required files" + cmds: + - ./validate-all-tests.sh + + clean: + desc: "Remove local test outputs" + cmds: + - rm -f ../*.json ../*_report.txt 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..38fdfa8e --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/.test/validate-all-tests.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Static validation for gcp-artifact-registry-spend-analysis (no live GCP required). +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test -f "$ROOT/runbook.robot" +test -f "$ROOT/sli.robot" +test -f "$ROOT/README.md" +test -f "$ROOT/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml" +test -f "$ROOT/.runwhen/templates/gcp-artifact-registry-spend-analysis-slx.yaml" +test -f "$ROOT/.runwhen/templates/gcp-artifact-registry-spend-analysis-taskset.yaml" +test -f "$ROOT/.runwhen/templates/gcp-artifact-registry-spend-analysis-sli.yaml" +for f in \ + artifact-billing-helpers.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 \ + sli-artifact-spend-health-score.sh +do + test -f "$ROOT/$f" +done +grep -q 'severity' "$ROOT/analyze-artifact-registry-spend.sh" +grep -q 'next_steps' "$ROOT/detect-artifact-cost-anomalies.sh" +grep -q 'RW.Core.Add Issue' "$ROOT/runbook.robot" +grep -q 'RW.Core.Push Metric' "$ROOT/sli.robot" +grep -q 'type: sli' "$ROOT/.runwhen/generation-rules/gcp-artifact-registry-spend-analysis.yaml" +echo "gcp-artifact-registry-spend-analysis bundle 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..b8884d74 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/README.md @@ -0,0 +1,68 @@ +# 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 analysis**: Per-project, per-SKU artifact costs with daily, weekly, and monthly rollups from billing export +- **Top contributors**: Rank projects and SKUs; flag disproportionate project share and legacy GCR dominance +- **Month-over-month trends**: Compare the last three complete calendar months; detect storage growth without pull activity +- **Anomaly detection**: Daily cost spikes (2x 7-day average) and sustained weekly deviations from 30-day trend +- **Optimization summary**: Actionable recommendations for cleanup policies, GCR migration, and scanning right-sizing + +## Configuration + +### Required Variables + +None. When `GCP_PROJECT_IDS` is blank, projects with artifact-related spend are inferred from the billing 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 (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`) + +### Secrets + +- `gcp_credentials`: GCP service account JSON with BigQuery billing export read access (`roles/bigquery.dataViewer`, `roles/bigquery.jobUser` 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. Install `gcloud`, `bq`, and `jq` +3. Grant the service account read access to the billing export dataset + +## Tasks Overview + +### Analyze Artifact Registry Spend by Project and SKU + +Queries billing export for Artifact Registry and legacy GCR SKUs. Produces per-project, per-SKU totals with time rollups. May raise an issue when no artifact spend is found in the lookback window. + +### Report Top Artifact Registry Cost Contributors + +Ranks projects and SKUs by spend. Raises issues when a project exceeds `ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT` of total artifact spend or when legacy GCR SKUs dominate spend. + +### Compare Artifact Registry Spend Month-over-Month + +Compares the last three complete calendar months. Raises issues when MoM growth exceeds `ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT` or when storage costs grow without corresponding transfer/pull activity. + +### Detect Artifact Storage Cost Anomalies + +Detects daily spikes at `ARTIFACT_COST_SPIKE_MULTIPLIER` times the 7-day average and 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, and right-size vulnerability scanning. Cross-references high-cost projects for follow-up with `gcp-artifact-registry-governance`. + +## Related Bundles + +- **gcp-project-cost-health**: Organization-wide GCP cost reporting +- **gcp-artifact-registry-governance**: Operational inventory and cleanup-policy checks + +## Limitations + +BigQuery billing export does not expose individual repository names. Repository-level attribution requires correlating high-spend projects with the governance bundle's inventory output. 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..fa5cc724 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/analyze-artifact-registry-spend.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: (optional) GCP_PROJECT_IDS, GCP_BILLING_EXPORT_TABLE +# OPTIONAL: COST_ANALYSIS_LOOKBACK_DAYS, OUTPUT_FORMAT +# +# Query BigQuery billing export for Artifact Registry and legacy GCR SKUs. +# Produces per-project, per-SKU totals with daily/weekly/monthly rollups. +# Outputs artifact_spend_analysis_issues.json +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="artifact_spend_analysis_issues.json" +REPORT_FILE="artifact_spend_analysis_report.txt" +JSON_FILE="artifact_spend_analysis_report.json" +CSV_FILE="artifact_spend_analysis_report.csv" +OUTPUT_FORMAT="${OUTPUT_FORMAT:-table}" +issues_json='[]' + +read -r START_DATE END_DATE <<< "$(get_date_range)" +LOOKBACK=$(get_lookback_days) + +echo "Analyzing Artifact Registry spend from ${START_DATE} to ${END_DATE} (${LOOKBACK}-day lookback)" + +if ! BILLING_TABLE=$(ensure_billing_access); then + cat artifact_spend_analysis_issues.json + exit 0 +fi + +PROJECT_FILTER=$(build_project_filter_sql) + +QUERY=" +SELECT + project.id AS project_id, + project.name AS project_name, + sku.description AS sku_description, + ROUND(SUM(cost), 4) AS total_cost, + ROUND(SUM(IF(DATE(usage_start_time) >= DATE_SUB('${END_DATE}', INTERVAL 7 DAY), cost, 0)), 4) AS weekly_cost, + ROUND(SUM(IF(DATE(usage_start_time) >= DATE_SUB('${END_DATE}', INTERVAL 30 DAY), cost, 0)), 4) AS monthly_cost, + ROUND(SUM(IF(DATE(usage_start_time) >= DATE_SUB('${END_DATE}', INTERVAL ${LOOKBACK} DAY), cost, 0)), 4) AS lookback_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN '${START_DATE}' AND '${END_DATE}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY project_id, project_name, sku_description +HAVING total_cost > 0 +ORDER BY total_cost DESC +" + +if ! RESULT=$(run_bq_query_json "$BILLING_TABLE" "$QUERY" 50000); then + write_access_issue "Cannot Query Artifact Registry Spend for \`${GCP_PROJECT_IDS:-all projects}\`" "BigQuery query for artifact spend failed." "$OUTPUT_FILE" + exit 0 +fi + +ROW_COUNT=$(echo "$RESULT" | jq 'length') +TOTAL_COST=$(echo "$RESULT" | jq '[.[].total_cost | tonumber] | add // 0') + +{ + echo "GCP Artifact Registry Spend Analysis" + echo "Period: ${START_DATE} to ${END_DATE}" + echo "Total artifact-related spend: \$${TOTAL_COST}" + echo "Rows (project x SKU): ${ROW_COUNT}" + echo "" + echo "Top contributors:" + echo "$RESULT" | jq -r '.[:15][] | "\(.project_id)\t\(.sku_description)\t$\(.total_cost)"' 2>/dev/null || true +} > "$REPORT_FILE" + +echo "$RESULT" | jq '.' > "$JSON_FILE" + +if [[ "$OUTPUT_FORMAT" == "csv" || "$OUTPUT_FORMAT" == "all" ]]; then + echo "project_id,project_name,sku_description,total_cost,weekly_cost,monthly_cost,lookback_cost" > "$CSV_FILE" + echo "$RESULT" | jq -r '.[] | [.project_id, .project_name, .sku_description, .total_cost, .weekly_cost, .monthly_cost, .lookback_cost] | @csv' >> "$CSV_FILE" +fi + +if [[ "$ROW_COUNT" -eq 0 ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No Artifact Registry Spend Found for \`${GCP_PROJECT_IDS:-configured scope}\`" \ + --arg details "No billing rows matched Artifact Registry or legacy Container Registry SKUs in the lookback window." \ + --arg severity "3" \ + --arg next_steps "Confirm projects use Artifact Registry/GCR and billing export includes recent data. Run gcp-artifact-registry-governance for inventory." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Projects with active artifact storage should appear in billing export", + "actual": "Zero artifact-related SKU costs in the last '"${LOOKBACK}"' days", + "details": $details, + "next_steps": $next_steps + }]') +fi + +echo "$issues_json" > "$OUTPUT_FILE" +echo "Analysis completed. Report: $REPORT_FILE" +cat "$REPORT_FILE" diff --git a/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-helpers.sh b/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-helpers.sh new file mode 100755 index 00000000..43395d80 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/artifact-billing-helpers.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Shared helpers for GCP Artifact Registry spend analysis from BigQuery billing export. +set -euo pipefail + +log() { + echo "[artifact-spend $(date '+%H:%M:%S')] $*" >&2 +} + +ARTIFACT_SKU_FILTER="( + LOWER(COALESCE(service.description, '')) LIKE '%artifact registry%' + OR LOWER(COALESCE(sku.description, '')) LIKE '%artifact registry%' + OR LOWER(COALESCE(service.description, '')) LIKE '%container registry%' + OR LOWER(COALESCE(sku.description, '')) LIKE '%container registry%' + OR LOWER(COALESCE(sku.description, '')) LIKE '%gcr.io%' + OR LOWER(COALESCE(sku.description, '')) LIKE '%google container registry%' +)" + +check_bq_available() { + command -v bq &>/dev/null && bq version &>/dev/null +} + +run_bq_query_json() { + local billing_table="$1" + local query="$2" + local max_rows="${3:-10000}" + + IFS='.' read -r billing_project _ _ <<< "$billing_table" + local result + if ! result=$(bq query --project_id="$billing_project" --use_legacy_sql=false --format=json --max_rows="$max_rows" "$query" 2>&1); then + log "BigQuery query failed: $result" + return 1 + fi + if echo "$result" | grep -qiE '^Error|^Access Denied|Not found:'; then + log "BigQuery query error: $result" + return 1 + fi + echo "$result" +} + +discover_billing_table() { + log "Auto-discovering billing export table..." + if ! check_bq_available; then + log "bq CLI not available" + return 1 + fi + + local projects + projects=$(gcloud projects list --format='value(projectId)' 2>/dev/null | head -20 || true) + [[ -z "$projects" ]] && projects=$(gcloud config get-value project 2>/dev/null || true) + + for proj_id in $projects; do + [[ -z "$proj_id" ]] && continue + local datasets + datasets=$(bq ls --project_id="$proj_id" --format=json 2>/dev/null | jq -r '.[].datasetReference.datasetId' 2>/dev/null || true) + while IFS= read -r dataset; do + [[ -z "$dataset" ]] && continue + local tables + tables=$(bq ls --project_id="$proj_id" --format=json "$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 + local full_table="${proj_id}.${dataset}.${table}" + if bq show --format=json "$full_table" &>/dev/null; then + log "Found billing table: $full_table" + echo "$full_table" + return 0 + fi + fi + done <<< "$tables" + done <<< "$datasets" + done + return 1 +} + +resolve_billing_table() { + local table="${GCP_BILLING_EXPORT_TABLE:-}" + table=$(echo "$table" | sed 's/^"//;s/"$//' | xargs || true) + if [[ -n "$table" ]]; then + echo "$table" + return 0 + fi + discover_billing_table +} + +normalize_project_ids() { + local raw="${GCP_PROJECT_IDS:-}" + raw=$(echo "$raw" | sed 's/^"//;s/"$//' | xargs || true) + if [[ -z "$raw" || "$raw" == "All" || "$raw" == "all" ]]; then + echo "" + return 0 + fi + echo "$raw" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' | paste -sd, - +} + +build_project_filter_sql() { + local project_ids + project_ids=$(normalize_project_ids) + if [[ -z "$project_ids" ]]; then + echo "" + return 0 + fi + local clauses=() + IFS=',' read -ra ids <<< "$project_ids" + for id in "${ids[@]}"; do + id=$(echo "$id" | xargs) + [[ -z "$id" ]] && continue + clauses+=("project.id = '${id}'") + done + if [[ ${#clauses[@]} -eq 0 ]]; then + echo "" + return 0 + fi + local joined + joined=$(IFS=' OR '; echo "${clauses[*]}") + echo "AND ($joined)" +} + +get_lookback_days() { + echo "${COST_ANALYSIS_LOOKBACK_DAYS:-30}" +} + +get_date_range() { + local lookback + lookback=$(get_lookback_days) + local end_date + end_date=$(date -u +"%Y-%m-%d") + local start_date + start_date=$(date -u -d "${lookback} days ago" +"%Y-%m-%d" 2>/dev/null || date -u -v-"${lookback}"d +"%Y-%m-%d") + echo "$start_date $end_date" +} + +discover_projects_with_artifact_spend() { + local billing_table="$1" + local start_date="$2" + local end_date="$3" + local project_filter + project_filter=$(build_project_filter_sql) + + local query=" +SELECT project.id AS project_id, ROUND(SUM(cost), 2) AS total_cost +FROM \`${billing_table}\` +WHERE DATE(usage_start_time) BETWEEN '${start_date}' AND '${end_date}' + AND ${ARTIFACT_SKU_FILTER} + ${project_filter} +GROUP BY project.id +HAVING total_cost > 0 +ORDER BY total_cost DESC +" + run_bq_query_json "$billing_table" "$query" 500 +} + +write_access_issue() { + local title="$1" + local details="$2" + local output_file="${3:-artifact_spend_issues.json}" + jq -n \ + --arg title "$title" \ + --arg details "$details" \ + --arg next_steps "Verify gcp_credentials has BigQuery billing export read access and GCP_BILLING_EXPORT_TABLE is correct." \ + '[{ + title: $title, + severity: 4, + expected: "BigQuery billing export should be readable for artifact SKU analysis", + actual: $details, + details: $details, + next_steps: $next_steps + }]' > "$output_file" +} + +ensure_billing_access() { + local billing_table + if ! billing_table=$(resolve_billing_table); then + write_access_issue "Cannot Access BigQuery Billing Export" "Could not resolve or auto-discover GCP_BILLING_EXPORT_TABLE." + return 1 + fi + echo "$billing_table" +} 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..ddac4004 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/compare-artifact-spend-mom.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Compare artifact-related costs across the last three complete calendar months. +# Outputs artifact_spend_mom_issues.json +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="artifact_spend_mom_issues.json" +REPORT_FILE="artifact_spend_mom_report.txt" +GROWTH_THRESHOLD="${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT:-25}" +issues_json='[]' + +if ! BILLING_TABLE=$(ensure_billing_access); then + cat "$OUTPUT_FILE" + exit 0 +fi + +PROJECT_FILTER=$(build_project_filter_sql) + +# Last 3 complete calendar months +M3_START=$(date -u -d "$(date -u +%Y-%m-01) -3 months" +%Y-%m-%d 2>/dev/null || date -u -v-3m -v1d +%Y-%m-%d) +M3_END=$(date -u -d "$(date -u +%Y-%m-01) -1 day" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d) + +QUERY=" +SELECT + FORMAT_DATE('%Y-%m', DATE(usage_start_time)) AS month, + project.id AS project_id, + ROUND(SUM(cost), 4) AS total_cost, + ROUND(SUM(IF(LOWER(sku.description) LIKE '%storage%' OR LOWER(sku.description) LIKE '%stored%', cost, 0)), 4) AS storage_cost, + ROUND(SUM(IF(LOWER(sku.description) LIKE '%egress%' OR LOWER(sku.description) LIKE '%transfer%' OR LOWER(sku.description) LIKE '%network%', cost, 0)), 4) AS transfer_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN '${M3_START}' AND '${M3_END}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY month, project_id +ORDER BY month, total_cost DESC +" + +if ! RESULT=$(run_bq_query_json "$BILLING_TABLE" "$QUERY" 50000); then + write_access_issue "Cannot Compare Artifact Spend Month-over-Month" "MoM query failed." "$OUTPUT_FILE" + exit 0 +fi + +MONTHS=($(echo "$RESULT" | jq -r '[.[].month] | unique | sort | .[]')) +MONTH_COUNT=${#MONTHS[@]} + +{ + echo "Artifact Registry Month-over-Month Comparison" + echo "Months: ${MONTHS[*]:-none}" + echo "Growth threshold: ${GROWTH_THRESHOLD}%" + echo "" + for m in "${MONTHS[@]}"; do + month_total=$(echo "$RESULT" | jq --arg m "$m" '[.[] | select(.month == $m) | .total_cost | tonumber] | add // 0') + echo "${m}: \$${month_total}" + done +} > "$REPORT_FILE" + +if [[ "$MONTH_COUNT" -lt 2 ]]; then + echo "$issues_json" > "$OUTPUT_FILE" + cat "$REPORT_FILE" + exit 0 +fi + +PREV_MONTH="${MONTHS[$((MONTH_COUNT - 2))]}" +CURR_MONTH="${MONTHS[$((MONTH_COUNT - 1))]}" + +# Org-wide MoM +PREV_TOTAL=$(echo "$RESULT" | jq --arg m "$PREV_MONTH" '[.[] | select(.month == $m) | .total_cost | tonumber] | add // 0') +CURR_TOTAL=$(echo "$RESULT" | jq --arg m "$CURR_MONTH" '[.[] | select(.month == $m) | .total_cost | tonumber] | add // 0') + +if (( $(echo "$PREV_TOTAL > 0" | bc -l) )); then + GROWTH=$(echo "scale=1; 100 * ($CURR_TOTAL - $PREV_TOTAL) / $PREV_TOTAL" | bc -l) + if (( $(echo "$GROWTH >= $GROWTH_THRESHOLD" | bc -l) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Artifact Spend MoM Growth Exceeds ${GROWTH_THRESHOLD}%" \ + --arg details "Total artifact spend grew ${GROWTH}% from ${PREV_MONTH} (\$${PREV_TOTAL}) to ${CURR_MONTH} (\$${CURR_TOTAL})." \ + --arg severity "2" \ + --arg next_steps "Investigate top projects and SKUs. Cross-reference with gcp-artifact-registry-governance for stale images." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Artifact spend growth should stay below '"${GROWTH_THRESHOLD}"'% month-over-month", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + fi +fi + +# Storage growth without transfer growth (per project) +PROJECTS=$(echo "$RESULT" | jq -r '[.[].project_id] | unique | .[]') +while IFS= read -r proj; do + [[ -z "$proj" ]] && continue + prev_storage=$(echo "$RESULT" | jq --arg m "$PREV_MONTH" --arg p "$proj" '[.[] | select(.month == $m and .project_id == $p) | .storage_cost | tonumber] | add // 0') + curr_storage=$(echo "$RESULT" | jq --arg m "$CURR_MONTH" --arg p "$proj" '[.[] | select(.month == $m and .project_id == $p) | .storage_cost | tonumber] | add // 0') + prev_transfer=$(echo "$RESULT" | jq --arg m "$PREV_MONTH" --arg p "$proj" '[.[] | select(.month == $m and .project_id == $p) | .transfer_cost | tonumber] | add // 0') + curr_transfer=$(echo "$RESULT" | jq --arg m "$CURR_MONTH" --arg p "$proj" '[.[] | select(.month == $m and .project_id == $p) | .transfer_cost | tonumber] | add // 0') + + if (( $(echo "$prev_storage > 1 && $curr_storage > 0" | bc -l) )); then + storage_growth=$(echo "scale=1; 100 * ($curr_storage - $prev_storage) / $prev_storage" | bc -l) + transfer_growth=0 + if (( $(echo "$prev_transfer > 0.01" | bc -l) )); then + transfer_growth=$(echo "scale=1; 100 * ($curr_transfer - $prev_transfer) / $prev_transfer" | bc -l) + fi + if (( $(echo "$storage_growth >= $GROWTH_THRESHOLD && $transfer_growth < 10" | bc -l) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Rising Artifact Storage Without Pull Activity: \`${proj}\`" \ + --arg details "Storage cost grew ${storage_growth}% MoM while transfer grew ${transfer_growth}% for project ${proj}." \ + --arg severity "2" \ + --arg next_steps "Review stale images and enable cleanup policies. Run gcp-artifact-registry-governance inventory." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Storage cost increases should correlate with artifact pull/transfer activity", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + fi + fi +done <<< "$PROJECTS" + +echo "$issues_json" > "$OUTPUT_FILE" +cat "$REPORT_FILE" 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..274cda1e --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/detect-artifact-cost-anomalies.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Detect daily artifact storage cost spikes and sustained weekly deviations. +# Outputs artifact_cost_anomalies_issues.json +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="artifact_cost_anomalies_issues.json" +REPORT_FILE="artifact_cost_anomalies_report.txt" +SPIKE_MULTIPLIER="${ARTIFACT_COST_SPIKE_MULTIPLIER:-2}" +issues_json='[]' + +read -r START_DATE END_DATE <<< "$(get_date_range)" + +if ! BILLING_TABLE=$(ensure_billing_access); then + cat "$OUTPUT_FILE" + exit 0 +fi + +PROJECT_FILTER=$(build_project_filter_sql) + +DAILY_QUERY=" +SELECT + project.id AS project_id, + project.name AS project_name, + DATE(usage_start_time) AS usage_date, + ROUND(SUM(cost), 4) AS daily_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN DATE_SUB('${END_DATE}', INTERVAL 37 DAY) AND '${END_DATE}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY project_id, project_name, usage_date +HAVING daily_cost > 0 +ORDER BY project_id, usage_date +" + +if ! DAILY_RESULT=$(run_bq_query_json "$BILLING_TABLE" "$DAILY_QUERY" 100000); then + write_access_issue "Cannot Detect Artifact Cost Anomalies" "Daily cost query failed." "$OUTPUT_FILE" + exit 0 +fi + +{ + echo "Artifact Registry Cost Anomaly Detection" + echo "Spike multiplier threshold: ${SPIKE_MULTIPLIER}x 7-day average" + echo "" +} > "$REPORT_FILE" + +PROJECTS=$(echo "$DAILY_RESULT" | jq -r '[.[].project_id] | unique | .[]') +while IFS= read -r proj; do + [[ -z "$proj" ]] && continue + proj_data=$(echo "$DAILY_RESULT" | jq --arg p "$proj" '[.[] | select(.project_id == $p)] | sort_by(.usage_date)') + proj_name=$(echo "$proj_data" | jq -r '.[0].project_name // .[0].project_id') + + # Last 7 days for spike detection + last7=$(echo "$proj_data" | jq '[.[-7:][] | .daily_cost | tonumber]') + avg7=$(echo "$last7" | jq 'if length > 0 then (add / length) else 0 end') + + if (( $(echo "$avg7 > 0.01" | bc -l) )); then + day_count=$(echo "$proj_data" | jq '.[-7:] | length') + for ((d=0; d= $SPIKE_MULTIPLIER" | bc -l) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Artifact Cost Spike: \`${proj}\` on ${date}" \ + --arg details "Daily artifact cost \$${cost} is ${mult}x the 7-day average (\$${avg7}) for ${proj_name}." \ + --arg severity "2" \ + --arg next_steps "Check for bulk image pushes, scanning charges, or egress spikes on ${date}." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Daily artifact costs should stay within '"${SPIKE_MULTIPLIER}"'x the 7-day average", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + echo " SPIKE: ${proj} ${date} \$${cost} (${mult}x avg)" >> "$REPORT_FILE" + fi + done + fi + + # Weekly vs 30-day trend (last 7 vs prior 23 days average * 7) + weekly=$(echo "$proj_data" | jq '[.[-7:][] | .daily_cost | tonumber] | add // 0') + monthly=$(echo "$proj_data" | jq '[.[] | .daily_cost | tonumber] | add // 0') + if (( $(echo "$monthly > 0 && $weekly > 0" | bc -l) )); then + expected_weekly=$(echo "scale=4; $monthly * 7 / 30" | bc -l) + ratio=$(echo "scale=2; $weekly / $expected_weekly" | bc -l) + if (( $(echo "$ratio >= 1.5" | bc -l) )); then + pct=$(echo "scale=1; ($ratio - 1) * 100" | bc -l) + issues_json=$(echo "$issues_json" | jq \ + --arg title "Sustained Artifact Cost Deviation: \`${proj}\`" \ + --arg details "Last 7 days artifact spend \$${weekly} is ${pct}% above the 30-day trend (expected ~\$${expected_weekly})." \ + --arg severity "3" \ + --arg next_steps "Review recent repository growth and scanning settings. Align with gcp-artifact-registry-governance cleanup recommendations." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Weekly artifact spend should align with the 30-day trend", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + echo " DEVIATION: ${proj} weekly \$${weekly} vs expected \$${expected_weekly}" >> "$REPORT_FILE" + fi + fi +done <<< "$PROJECTS" + +echo "$issues_json" > "$OUTPUT_FILE" +cat "$REPORT_FILE" 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..9da4c205 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/generate-artifact-spend-recommendations.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Consolidate artifact spend findings into actionable recommendations. +# Reads prior analysis outputs when present; otherwise queries billing export. +# Outputs artifact_spend_recommendations_issues.json +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="artifact_spend_recommendations_issues.json" +REPORT_FILE="artifact_spend_recommendations_report.txt" +issues_json='[]' + +read -r START_DATE END_DATE <<< "$(get_date_range)" + +recommendations=() + +add_rec() { + recommendations+=("$1") +} + +if [[ -f artifact_spend_analysis_report.json ]]; then + TOTAL=$(jq '[.[].total_cost | tonumber] | add // 0' artifact_spend_analysis_report.json 2>/dev/null || echo 0) + TOP_PROJECT=$(jq -r 'group_by(.project_id) | map({id: .[0].project_id, cost: (map(.total_cost|tonumber)|add)}) | sort_by(-.cost) | .[0].id // "unknown"' artifact_spend_analysis_report.json 2>/dev/null || echo "unknown") + add_rec "Total artifact spend in lookback: \$${TOTAL}. Top project: ${TOP_PROJECT}." +else + if BILLING_TABLE=$(ensure_billing_access 2>/dev/null); then + PROJECT_FILTER=$(build_project_filter_sql) + QUERY="SELECT ROUND(SUM(cost),2) AS total FROM \`${BILLING_TABLE}\` WHERE DATE(usage_start_time) BETWEEN '${START_DATE}' AND '${END_DATE}' AND ${ARTIFACT_SKU_FILTER} ${PROJECT_FILTER}" + TOTAL=$(run_bq_query_json "$BILLING_TABLE" "$QUERY" 1 | jq -r '.[0].total // 0' 2>/dev/null || echo 0) + add_rec "Estimated artifact spend (\$${TOTAL}) from billing export." + fi +fi + +if [[ -f top_artifact_cost_contributors_report.txt ]]; then + if grep -qi "Legacy Container Registry" top_artifact_cost_contributors_report.txt 2>/dev/null || \ + jq -e '[.[] | select(.sku_description | test("container registry|gcr"; "i"))] | length > 0' artifact_spend_analysis_report.json &>/dev/null; then + add_rec "Migrate legacy gcr.io storage to Artifact Registry and delete unused GCR buckets." + fi +fi + +if [[ -f artifact_spend_mom_report.txt ]]; then + if grep -qi "growth" artifact_spend_mom_report.txt 2>/dev/null; then + add_rec "Enable Artifact Registry cleanup policies on repositories with rising storage MoM." + fi +fi + +# Standard recommendations +add_rec "Enable cleanup policies to delete untagged images older than 30-90 days." +add_rec "Deduplicate tags and remove stale version pins across CI pipelines." +add_rec "Right-size vulnerability scanning: disable scanning on dev repos or use on-push only." +add_rec "Cross-reference high-cost projects with gcp-artifact-registry-governance inventory." + +{ + echo "Artifact Registry Spend Optimization Summary" + echo "Scope: ${GCP_PROJECT_IDS:-auto-discovered projects}" + echo "" + for rec in "${recommendations[@]}"; do + echo " - $rec" + done +} > "$REPORT_FILE" + +# High spend projects for follow-up +if [[ -f artifact_spend_analysis_report.json ]]; then + while IFS= read -r row; do + [[ -z "$row" ]] && continue + pid=$(echo "$row" | jq -r '.id') + pcost=$(echo "$row" | jq -r '.cost') + if (( $(echo "$pcost >= 100" | bc -l 2>/dev/null || echo 0) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Artifact Spend Optimization Follow-up: \`${pid}\`" \ + --arg details "Project ${pid} has \$${pcost} in artifact-related spend. Review cleanup policies and stale images." \ + --arg severity "4" \ + --arg next_steps "Run gcp-artifact-registry-governance on ${pid}. Enable cleanup policies and retire duplicate tags." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Artifact spend should align with actively used images and repositories", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + fi + done < <(jq -c 'group_by(.project_id) | map({id: .[0].project_id, cost: (map(.total_cost|tonumber)|add)}) | sort_by(-.cost) | .[:5][]' artifact_spend_analysis_report.json 2>/dev/null || true) +fi + +if [[ $(echo "$issues_json" | jq 'length') -eq 0 ]]; then + rec_text=$(printf '%s\n' "${recommendations[@]}") + issues_json=$(echo "$issues_json" | jq \ + --arg title "Artifact Registry Spend Optimization Recommendations" \ + --arg details "$rec_text" \ + --arg severity "4" \ + --arg next_steps "Apply cleanup policies, migrate legacy GCR, and coordinate with gcp-artifact-registry-governance on high-spend projects." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Artifact spend should be optimized with cleanup policies and legacy GCR retirement", + "actual": "Consolidated recommendations generated from billing analysis", + "details": $details, + "next_steps": $next_steps + }]') +fi + +echo "$issues_json" > "$OUTPUT_FILE" +cat "$REPORT_FILE" 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..17f1fd14 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/report-top-artifact-cost-contributors.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Rank projects and SKUs by artifact storage and transfer spend. +# Raises issues when share or absolute thresholds are exceeded. +# Outputs top_artifact_cost_contributors_issues.json +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="top_artifact_cost_contributors_issues.json" +REPORT_FILE="top_artifact_cost_contributors_report.txt" +THRESHOLD_PERCENT="${ARTIFACT_PROJECT_COST_THRESHOLD_PERCENT:-20}" +issues_json='[]' + +read -r START_DATE END_DATE <<< "$(get_date_range)" + +echo "Reporting top Artifact Registry cost contributors (${START_DATE} to ${END_DATE})" + +if ! BILLING_TABLE=$(ensure_billing_access); then + cat "$OUTPUT_FILE" + exit 0 +fi + +PROJECT_FILTER=$(build_project_filter_sql) + +PROJECT_QUERY=" +SELECT project.id AS project_id, project.name AS project_name, ROUND(SUM(cost), 4) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN '${START_DATE}' AND '${END_DATE}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY project_id, project_name +HAVING total_cost > 0 +ORDER BY total_cost DESC +" + +SKU_QUERY=" +SELECT sku.description AS sku_description, ROUND(SUM(cost), 4) AS total_cost +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN '${START_DATE}' AND '${END_DATE}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY sku_description +HAVING total_cost > 0 +ORDER BY total_cost DESC +" + +if ! PROJECT_RESULT=$(run_bq_query_json "$BILLING_TABLE" "$PROJECT_QUERY" 1000); then + write_access_issue "Cannot Rank Artifact Cost Contributors" "Project ranking query failed." "$OUTPUT_FILE" + exit 0 +fi + +if ! SKU_RESULT=$(run_bq_query_json "$BILLING_TABLE" "$SKU_QUERY" 500); then + write_access_issue "Cannot Rank Artifact SKU Contributors" "SKU ranking query failed." "$OUTPUT_FILE" + exit 0 +fi + +TOTAL=$(echo "$PROJECT_RESULT" | jq '[.[].total_cost | tonumber] | add // 0') +THRESH=$(echo "$THRESHOLD_PERCENT" | awk '{print $1+0}') + +{ + echo "Top Artifact Registry Cost Contributors" + echo "Total spend: \$${TOTAL}" + echo "" + echo "By project:" + echo "$PROJECT_RESULT" | jq -r '.[] | " \(.project_id): $\(.total_cost)"' + echo "" + echo "By SKU:" + echo "$SKU_RESULT" | jq -r '.[] | " \(.sku_description): $\(.total_cost)"' +} > "$REPORT_FILE" + +if (( $(echo "$TOTAL > 0" | bc -l) )) && (( $(echo "$THRESH > 0" | bc -l) )); then + while IFS= read -r row; do + [[ -z "$row" ]] && continue + proj_id=$(echo "$row" | jq -r '.project_id') + proj_name=$(echo "$row" | jq -r '.project_name') + cost=$(echo "$row" | jq -r '.total_cost') + pct=$(echo "scale=1; 100 * $cost / $TOTAL" | bc -l) + if (( $(echo "$pct >= $THRESH" | bc -l) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Project \`${proj_id}\` Exceeds ${THRESH}% of Artifact Spend" \ + --arg details "Project ${proj_name} (${proj_id}) accounts for ${pct}% of artifact spend (\$${cost} of \$${TOTAL})." \ + --arg severity "3" \ + --arg next_steps "Review artifact inventory with gcp-artifact-registry-governance. Enable cleanup policies and retire unused images." \ + --arg expected "No single project should exceed ${THRESH}% of total artifact spend without justification" \ + --arg actual "${pct}% share (\$${cost})" \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": $expected, + "actual": $actual, + "details": $details, + "next_steps": $next_steps + }]') + fi + done < <(echo "$PROJECT_RESULT" | jq -c '.[]') +fi + +# Legacy GCR dominant spend +LEGACY_COST=$(echo "$SKU_RESULT" | jq '[.[] | select(.sku_description | test("container registry|gcr"; "i")) | .total_cost | tonumber] | add // 0') +if (( $(echo "$TOTAL > 0 && $LEGACY_COST > 0" | bc -l) )); then + LEGACY_PCT=$(echo "scale=1; 100 * $LEGACY_COST / $TOTAL" | bc -l) + if (( $(echo "$LEGACY_PCT >= 30" | bc -l) )); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Legacy Container Registry Dominates Artifact Spend" \ + --arg details "Legacy GCR SKUs account for ${LEGACY_PCT}% (\$${LEGACY_COST}) of artifact-related spend." \ + --arg severity "3" \ + --arg next_steps "Migrate remaining gcr.io images to Artifact Registry and disable legacy GCR buckets to reduce storage costs." \ + '. += [{ + "title": $title, + "severity": ($severity | tonumber), + "expected": "Artifact spend should primarily use Artifact Registry rather than legacy GCR", + "actual": $details, + "details": $details, + "next_steps": $next_steps + }]') + fi +fi + +echo "$issues_json" > "$OUTPUT_FILE" +cat "$REPORT_FILE" 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..fd3eaa64 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/runbook.robot @@ -0,0 +1,289 @@ +*** 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 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. + [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 + + RW.Core.Add Pre To Report Analysis Results: + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_spend_analysis_issues.json" ]; then cat artifact_spend_analysis_issues.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + + 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 + +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 + + RW.Core.Add Pre To Report Top Contributors: + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "top_artifact_cost_contributors_issues.json" ]; then cat top_artifact_cost_contributors_issues.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + + 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 + +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 threshold or storage grows without 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 + ... cmd_override=./compare-artifact-spend-mom.sh + + RW.Core.Add Pre To Report Month-over-Month Comparison: + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_spend_mom_issues.json" ]; then cat artifact_spend_mom_issues.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + + 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 + +Detect Artifact Storage Cost Anomalies for `${GCP_PROJECT_IDS}` + [Documentation] Detect daily artifact storage cost spikes at 2x 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 + ... cmd_override=./detect-artifact-cost-anomalies.sh + + RW.Core.Add Pre To Report Anomaly Detection: + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_cost_anomalies_issues.json" ]; then cat artifact_cost_anomalies_issues.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + + 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 + +Generate Artifact Registry Spend Optimization Summary for `${GCP_PROJECT_IDS}` + [Documentation] Consolidate spend findings into actionable recommendations including cleanup policies, legacy GCR retirement, duplicate tag reduction, and scanning right-sizing. + [Tags] GCP Artifact Registry Recommendations 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 + ... cmd_override=./generate-artifact-spend-recommendations.sh + + RW.Core.Add Pre To Report Optimization Summary: + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=if [ -f "artifact_spend_recommendations_issues.json" ]; then cat artifact_spend_recommendations_issues.json; else echo "[]"; fi + ... env=${env} + ... timeout_seconds=30 + + 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 + + +*** 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 (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=[\d.]+ + ... 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 + ${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_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} OUTPUT_FORMAT ${OUTPUT_FORMAT} + 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-artifact-spend-health-score.sh b/codebundles/gcp-artifact-registry-spend-analysis/sli-artifact-spend-health-score.sh new file mode 100755 index 00000000..3ed63700 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/sli-artifact-spend-health-score.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# Lightweight SLI health check: artifact spend MoM growth and anomaly signals. +# Outputs sli_artifact_health.json with issue_count and health dimensions. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=artifact-billing-helpers.sh +source "${SCRIPT_DIR}/artifact-billing-helpers.sh" + +OUTPUT_FILE="sli_artifact_health.json" +GROWTH_THRESHOLD="${ARTIFACT_MOM_GROWTH_THRESHOLD_PERCENT:-25}" +SPIKE_MULTIPLIER="${ARTIFACT_COST_SPIKE_MULTIPLIER:-2}" + +mom_score=1 +anomaly_score=1 +access_score=1 + +if ! BILLING_TABLE=$(ensure_billing_access 2>/dev/null); then + access_score=0 + jq -n \ + --argjson mom "$mom_score" \ + --argjson anomaly "$anomaly_score" \ + --argjson access "$access_score" \ + --argjson health "0" \ + '{mom_score: $mom, anomaly_score: $anomaly, access_score: $access, health_score: $health, issue_count: 1}' > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +read -r START_DATE END_DATE <<< "$(get_date_range)" +PROJECT_FILTER=$(build_project_filter_sql) + +# Quick 14-day daily totals for spike check +SPIKE_QUERY=" +SELECT DATE(usage_start_time) AS d, ROUND(SUM(cost), 4) AS c +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN DATE_SUB('${END_DATE}', INTERVAL 14 DAY) AND '${END_DATE}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY d ORDER BY d +" +if SPIKE_DATA=$(run_bq_query_json "$BILLING_TABLE" "$SPIKE_QUERY" 20 2>/dev/null); then + costs=$(echo "$SPIKE_DATA" | jq '[.[].c | tonumber]') + avg=$(echo "$costs" | jq 'if length > 0 then (add/length) else 0 end') + max=$(echo "$costs" | jq 'if length > 0 then max else 0 end') + if (( $(echo "$avg > 0" | bc -l) )); then + mult=$(echo "scale=2; $max / $avg" | bc -l) + if (( $(echo "$mult >= $SPIKE_MULTIPLIER" | bc -l) )); then + anomaly_score=0 + fi + fi +else + access_score=0 +fi + +# Quick 2-month MoM +M2_START=$(date -u -d "$(date -u +%Y-%m-01) -2 months" +%Y-%m-%d 2>/dev/null || date -u -v-2m -v1d +%Y-%m-%d) +M2_END=$(date -u -d "$(date -u +%Y-%m-01) -1 day" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d) +MOM_QUERY=" +SELECT FORMAT_DATE('%Y-%m', DATE(usage_start_time)) AS m, ROUND(SUM(cost), 2) AS t +FROM \`${BILLING_TABLE}\` +WHERE DATE(usage_start_time) BETWEEN '${M2_START}' AND '${M2_END}' + AND ${ARTIFACT_SKU_FILTER} + ${PROJECT_FILTER} +GROUP BY m ORDER BY m +" +if MOM_DATA=$(run_bq_query_json "$BILLING_TABLE" "$MOM_QUERY" 5 2>/dev/null); then + cnt=$(echo "$MOM_DATA" | jq 'length') + if [[ "$cnt" -ge 2 ]]; then + prev=$(echo "$MOM_DATA" | jq -r '.[-2].t') + curr=$(echo "$MOM_DATA" | jq -r '.[-1].t') + if (( $(echo "$prev > 0" | bc -l) )); then + growth=$(echo "scale=1; 100 * ($curr - $prev) / $prev" | bc -l) + if (( $(echo "$growth >= $GROWTH_THRESHOLD" | bc -l) )); then + mom_score=0 + fi + fi + fi +fi + +health=$(echo "scale=2; ($mom_score + $anomaly_score + $access_score) / 3" | bc -l) +issue_count=$((3 - mom_score - anomaly_score - access_score)) + +jq -n \ + --argjson mom "$mom_score" \ + --argjson anomaly "$anomaly_score" \ + --argjson access "$access_score" \ + --argjson health "$health" \ + --argjson issue_count "$issue_count" \ + '{mom_score: $mom, anomaly_score: $anomaly, access_score: $access, health_score: ($health|tonumber), issue_count: $issue_count}' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" 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..215381d5 --- /dev/null +++ b/codebundles/gcp-artifact-registry-spend-analysis/sli.robot @@ -0,0 +1,119 @@ +*** Settings *** +Documentation Measures GCP Artifact Registry spend health by scoring billing access, month-over-month growth, and daily cost anomaly signals. Produces a value between 0 (failing) and 1 (fully passing). +Metadata Author rw-codebundle-agent +Metadata Display Name GCP Artifact Registry Spend Analysis SLI +Metadata Supports GCP Artifact Registry Cost Analysis BigQuery + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library OperatingSystem + +Suite Setup Suite Initialization + + +*** Tasks *** +Check Artifact Billing Access and Score + [Documentation] Verifies BigQuery billing export access for artifact SKU queries and produces a binary access score. + [Tags] GCP Artifact Registry access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=sli-artifact-spend-health-score.sh + ... env=${env} + ... secret_file__gcp_credentials=${gcp_credentials} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${sli_data}= RW.CLI.Run Cli + ... cmd=cat sli_artifact_health.json + ... env=${env} + ... timeout_seconds=15 + ${parsed}= Evaluate json.loads(r'''${sli_data.stdout}''') json + ${access_score}= Set Variable ${parsed['access_score']} + ${mom_score}= Set Variable ${parsed['mom_score']} + ${anomaly_score}= Set Variable ${parsed['anomaly_score']} + EXCEPT + Log SLI JSON parse failed, defaulting scores to 0. WARN + ${access_score}= Set Variable 0 + ${mom_score}= Set Variable 0 + ${anomaly_score}= Set Variable 0 + END + + Set Suite Variable ${access_score} + Set Suite Variable ${mom_score} + Set Suite Variable ${anomaly_score} + RW.Core.Push Metric ${access_score} sub_name=billing_access + +Check Artifact MoM Growth and Score + [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 Cost Anomalies and Score + [Documentation] Scores whether daily artifact costs stay within the configured spike multiplier of the 7-day average. + [Tags] GCP Artifact Registry access:read-only data:metrics + + RW.Core.Push Metric ${anomaly_score} sub_name=cost_anomalies + +Generate Artifact Spend Health Score + [Documentation] Averages sub-scores into the final 0-1 artifact spend health metric. + [Tags] GCP Artifact Registry access:read-only data:metrics + + ${health_score}= Evaluate (${access_score} + ${mom_score} + ${anomaly_score}) / 3 + ${health_score}= Convert To Number ${health_score} 2 + RW.Core.Add to Report Artifact 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. + ... pattern=[\w,-]* + ... default="" + ${GCP_BILLING_EXPORT_TABLE}= RW.Core.Import User Variable GCP_BILLING_EXPORT_TABLE + ... type=string + ... description=BigQuery billing export table (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=[\d.]+ + ... 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 + ${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} 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}