diff --git a/codebundles/atlassian-org-license-utilization/.runwhen/generation-rules/atlassian-org-license-utilization.yaml b/codebundles/atlassian-org-license-utilization/.runwhen/generation-rules/atlassian-org-license-utilization.yaml new file mode 100644 index 00000000..25aacb74 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.runwhen/generation-rules/atlassian-org-license-utilization.yaml @@ -0,0 +1,23 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: atlassian + generationRules: + - resourceTypes: + - atlassian_organization + matchRules: + - type: pattern + pattern: ".+" + properties: ["name"] + mode: substring + slxs: + - baseName: atlassian-org-license-utilization + qualifiers: ["ATLASSIAN_ORG_ID", "ATLASSIAN_ORG_NAME"] + baseTemplateName: atlassian-org-license-utilization + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + templateName: atlassian-org-license-utilization-sli.yaml + - type: runbook + templateName: atlassian-org-license-utilization-taskset.yaml diff --git a/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-sli.yaml b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-sli.yaml new file mode 100644 index 00000000..b1c7e773 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-sli.yaml @@ -0,0 +1,56 @@ +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 Atlassian organization license health via API reachability, tier headroom, and utilization thresholds for {{ match_resource.name }}. + 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/atlassian-org-license-utilization/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 300 + configProvided: + - name: ATLASSIAN_ORG_ID + value: "{{ match_resource.org_id | default(match_resource.id) }}" + - name: ATLASSIAN_ORG_NAME + value: "{{ match_resource.name }}" + - name: ATLASSIAN_DIRECTORY_ID + value: "{{ custom.atlassian_directory_id | default('') }}" + - name: LICENSE_UTILIZATION_MIN_PERCENT + value: "{{ custom.license_utilization_min_percent | default('70') }}" + - name: USER_TIER_PROXIMITY_PERCENT + value: "{{ custom.user_tier_proximity_percent | default('80') }}" + - name: INACTIVE_DAYS_THRESHOLD + value: "{{ custom.inactive_days_threshold | default('90') }}" + - name: PRODUCTS + value: "{{ custom.products | default('All') }}" + - name: SLI_MAX_USER_PAGES + value: "{{ custom.sli_max_user_pages | default('10') }}" + secretsProvided: + {% if wb_version %} + {% include "atlassian-auth.yaml" ignore missing %} + {% else %} + - name: atlassian_org_api_key + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-slx.yaml b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-slx.yaml new file mode 100644 index 00000000..ae323925 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-slx.yaml @@ -0,0 +1,33 @@ +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/atlassian/atlassian.svg + alias: {{ match_resource.name }} Atlassian License Utilization + asMeasuredBy: Active/billable license utilization and tier headroom across entitled Atlassian products. 1=healthy utilization and tier headroom, 0=failing thresholds. + configProvided: + - name: ATLASSIAN_ORG_ID + value: "{{ match_resource.org_id | default(match_resource.id) }}" + - name: ATLASSIAN_ORG_NAME + value: "{{ match_resource.name }}" + owners: + - {{ workspace.owner_email }} + statement: Atlassian organization {{ match_resource.name }} license utilization should remain above configured thresholds with tier headroom before renewal. + additionalContext: + {% include "atlassian-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "atlassian-tags.yaml" ignore missing %} + - name: cloud + value: saas + - name: service + value: atlassian-admin + - name: scope + value: organization + - name: access + value: read-only diff --git a/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-taskset.yaml b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-taskset.yaml new file mode 100644 index 00000000..f1981846 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.runwhen/templates/atlassian-org-license-utilization-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: Monitor Atlassian organization {{ match_resource.name }} license utilization, tier proximity, and active-user trends across entitled products. + 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/atlassian-org-license-utilization/runbook.robot + configProvided: + - name: ATLASSIAN_ORG_ID + value: "{{ match_resource.org_id | default(match_resource.id) }}" + - name: ATLASSIAN_ORG_NAME + value: "{{ match_resource.name }}" + - name: ATLASSIAN_DIRECTORY_ID + value: "{{ custom.atlassian_directory_id | default('') }}" + - name: LICENSE_UTILIZATION_MIN_PERCENT + value: "{{ custom.license_utilization_min_percent | default('70') }}" + - name: USER_TIER_PROXIMITY_PERCENT + value: "{{ custom.user_tier_proximity_percent | default('80') }}" + - name: INACTIVE_DAYS_THRESHOLD + value: "{{ custom.inactive_days_threshold | default('90') }}" + - name: PRODUCTS + value: "{{ custom.products | default('All') }}" + - name: TIMEOUT_SECONDS + value: "{{ custom.timeout_seconds | default('600') }}" + secretsProvided: + {% if wb_version %} + {% include "atlassian-auth.yaml" ignore missing %} + {% else %} + - name: atlassian_org_api_key + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/atlassian-org-license-utilization/.test/README.md b/codebundles/atlassian-org-license-utilization/.test/README.md new file mode 100644 index 00000000..03836fbb --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/README.md @@ -0,0 +1,12 @@ +# Test Infrastructure + +Static validation only — Atlassian Cloud organizations cannot be provisioned via Terraform in this test harness. + +## Run validation + +```bash +cd .test +task +``` + +This verifies required bundle files, templates, generation rules, and shell scripts exist. diff --git a/codebundles/atlassian-org-license-utilization/.test/Taskfile.yaml b/codebundles/atlassian-org-license-utilization/.test/Taskfile.yaml new file mode 100644 index 00000000..39c1701d --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/Taskfile.yaml @@ -0,0 +1,17 @@ +version: "3" + +tasks: + default: + desc: "Validate CodeBundle structure and generation artifacts" + cmds: + - task: validate-structure + + validate-structure: + desc: "Run static checks for required files" + cmds: + - ./validate-atlassian-bundle-structure.sh + + clean: + desc: "Remove local test outputs" + cmds: + - rm -rf output workspaceInfo.yaml diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/backend.tf b/codebundles/atlassian-org-license-utilization/.test/terraform/backend.tf new file mode 100644 index 00000000..f966bbb9 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "local" {} +} diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/main.tf b/codebundles/atlassian-org-license-utilization/.test/terraform/main.tf new file mode 100644 index 00000000..6e79865f --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/main.tf @@ -0,0 +1,6 @@ +# Atlassian organizations are SaaS resources registered manually in RunWhen. +# This placeholder satisfies the standard test-infra scaffold; no cloud resources are created. + +output "note" { + value = "Atlassian org license utilization bundle uses static validation only." +} diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/outputs.tf b/codebundles/atlassian-org-license-utilization/.test/terraform/outputs.tf new file mode 100644 index 00000000..8c2cc08a --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/outputs.tf @@ -0,0 +1 @@ +# Outputs defined in main.tf for scaffold completeness. diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/providers.tf b/codebundles/atlassian-org-license-utilization/.test/terraform/providers.tf new file mode 100644 index 00000000..7117131f --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/providers.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.0" +} diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/terraform.tfvars b/codebundles/atlassian-org-license-utilization/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..abee5d2d --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/terraform.tfvars @@ -0,0 +1 @@ +# No Terraform values required for static validation scaffold. diff --git a/codebundles/atlassian-org-license-utilization/.test/terraform/variables.tf b/codebundles/atlassian-org-license-utilization/.test/terraform/variables.tf new file mode 100644 index 00000000..0e820910 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/terraform/variables.tf @@ -0,0 +1 @@ +# No Terraform variables required for static validation scaffold. diff --git a/codebundles/atlassian-org-license-utilization/.test/validate-atlassian-bundle-structure.sh b/codebundles/atlassian-org-license-utilization/.test/validate-atlassian-bundle-structure.sh new file mode 100755 index 00000000..10844e1d --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/.test/validate-atlassian-bundle-structure.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Static validation for atlassian-org-license-utilization (no live Atlassian org 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/atlassian-org-license-utilization.yaml" +test -f "$ROOT/.runwhen/templates/atlassian-org-license-utilization-slx.yaml" +test -f "$ROOT/.runwhen/templates/atlassian-org-license-utilization-taskset.yaml" +test -f "$ROOT/.runwhen/templates/atlassian-org-license-utilization-sli.yaml" +for f in \ + atlassian-api-helpers.sh \ + generate-atlassian-license-utilization-report.sh \ + analyze-atlassian-tier-proximity.sh \ + evaluate-atlassian-utilization-thresholds.sh \ + report-atlassian-active-user-trends.sh \ + sli-atlassian-org-license-score.sh +do + test -f "$ROOT/$f" +done +echo "atlassian-org-license-utilization bundle structure OK" diff --git a/codebundles/atlassian-org-license-utilization/README.md b/codebundles/atlassian-org-license-utilization/README.md new file mode 100644 index 00000000..37d17adf --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/README.md @@ -0,0 +1,76 @@ +# Atlassian Organization License Utilization Report + +Monitors Atlassian Cloud organization license utilization across Jira, Confluence, Jira Service Management, Loom, and other entitled products. Computes active-user versus billable-user ratios, tracks proximity to purchased user-tier limits, and raises issues when utilization falls below operator thresholds. + +## Overview + +This CodeBundle provides read-only SaaS license utilization reporting for Atlassian organizations: + +- **License utilization report**: Per-product billable, active, and utilization percentages from managed accounts +- **Tier proximity analysis**: Billable seat fill versus purchased tier using workspaces `usage`/`capacity` +- **Utilization threshold evaluation**: Flags products below `LICENSE_UTILIZATION_MIN_PERCENT` +- **Active user trends**: Highlights declining active-user share versus billable seats for renewal planning + +Last-active timestamps from the Organizations API may lag up to 24 hours. This bundle does not call suspend, revoke, or remove endpoints. + +## Configuration + +### Required Variables + +- `ATLASSIAN_ORG_ID`: Atlassian Cloud organization UUID from Atlassian Administration +- `ATLASSIAN_ORG_NAME`: Human-readable organization name for reports and task titles + +### Optional Variables + +- `ATLASSIAN_DIRECTORY_ID`: Primary user directory ID when the org has multiple directories (default: discover first directory) +- `LICENSE_UTILIZATION_MIN_PERCENT`: Minimum acceptable active/billable utilization percentage per product before raising an issue (default: `70`) +- `USER_TIER_PROXIMITY_PERCENT`: Billable-user count as a percentage of purchased tier that triggers proximity alerts (default: `80`) +- `INACTIVE_DAYS_THRESHOLD`: Days without product activity before a user is treated as inactive for utilization math (default: `90`) +- `PRODUCTS`: Comma-separated product keys to include (e.g. `jira-software,confluence,loom`) or `All` (default: `All`) +- `TIMEOUT_SECONDS`: Per-task timeout; orgs with large user bases may need higher values (default: `600`) +- `SLI_MAX_USER_PAGES`: Maximum managed-account pages fetched during SLI scoring to cap runtime (default: `10`) + +### Secrets + +- `atlassian_org_api_key`: Organization Admin API key used as Bearer token for the [Organizations REST API](https://developer.atlassian.com/cloud/admin/organization/rest/intro/). Plain text API key string. + +### Prerequisites + +- Organization Admin role on the target Atlassian Cloud organization +- At least one paid subscription for full managed-accounts API access +- `curl` and `jq` available in the execution environment + +## Tasks Overview + +### Generate Atlassian License Utilization Report + +Queries `GET /v1/orgs/{orgId}/users` (managed accounts) and aggregates per-product billable users, recently active users, and utilization percentage. Produces organization-wide summary tables suitable for finance and IT admin review. Raises issues on API access failures or empty directories. + +### Analyze Billable User Counts Versus Tier Limits + +Correlates billable counts with workspace `usage` and `capacity` from `POST /v2/orgs/{orgId}/workspaces`. Flags products at or above `USER_TIER_PROXIMITY_PERCENT` and overage conditions. Degrades gracefully when tier quantities are unavailable. + +### Evaluate License Utilization Thresholds + +Compares per-product active/billable ratios against `LICENSE_UTILIZATION_MIN_PERCENT`. Emits structured issues with expected versus actual utilization and remediation hints (review inactive users, suspend access, right-size tier). + +### Report Active User Trends + +Summarizes unique active users per product using `product_access.last_active` from managed accounts. Highlights products with declining active-user share versus billable seats to guide renewal decisions. + +## API Notes + +- **Managed accounts**: `GET https://api.atlassian.com/admin/v1/orgs/{orgId}/users` — includes `access_billable`, `product_access`, and `last_active` +- **Workspaces**: `POST https://api.atlassian.com/admin/v2/orgs/{orgId}/workspaces` — includes `usage` and `capacity` for tier proximity +- **Directories**: `GET https://api.atlassian.com/admin/v2/orgs/{orgId}/directories` — used for directory discovery and SLI auth checks +- **Rate limits**: HTTP 429 responses trigger exponential backoff; paginate with `cursor` from `links.next` + +## Test Scenarios + +| Scenario | Description | Expected issues | +|---|---|---| +| `healthy_high_utilization` | Org with >80% active/billable ratio and tier headroom | 0 | +| `low_utilization_jira` | Jira Software billable high but active below threshold | 2 (severity 3–4) | +| `tier_proximity_confluence` | Confluence billable at 85% of purchased tier | 1 (severity 3) | + +Local static validation is available under `.test/` (no live Atlassian org required for structure checks). diff --git a/codebundles/atlassian-org-license-utilization/analyze-atlassian-tier-proximity.sh b/codebundles/atlassian-org-license-utilization/analyze-atlassian-tier-proximity.sh new file mode 100755 index 00000000..e3039722 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/analyze-atlassian-tier-proximity.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=atlassian-api-helpers.sh +source "${SCRIPT_DIR}/atlassian-api-helpers.sh" + +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" + +OUTPUT_FILE="atlassian_tier_proximity_issues.json" +REPORT_FILE="atlassian_tier_proximity_report.txt" +issues_json='[]' +tier_data_available=false + +trap atlassian_cleanup EXIT +atlassian_init_cache + +echo "Analyzing billable user counts versus tier limits for organization: ${ATLASSIAN_ORG_NAME}" + +if workspaces_json="$(atlassian_fetch_workspaces 2>/dev/null)"; then + tier_data_available=true + { + echo "Atlassian Tier Proximity Analysis (workspaces API)" + echo "Organization: ${ATLASSIAN_ORG_NAME}" + echo "Proximity threshold: ${USER_TIER_PROXIMITY_PERCENT}%" + echo "" + printf "%-28s %10s %10s %10s %s\n" "Workspace" "Usage" "Capacity" "Fill %" "Status" + printf "%-28s %10s %10s %10s %s\n" "----------------------------" "----------" "----------" "----------" "------" + } > "${REPORT_FILE}" + + while IFS= read -r ws; do + name="$(echo "${ws}" | jq -r '.attributes.name // .id')" + type_key="$(echo "${ws}" | jq -r '.attributes.typeKey // .attributes.type // "unknown"')" + product_key="$(atlassian_normalize_product_key "${type_key}")" + usage="$(echo "${ws}" | jq -r '.attributes.usage // 0')" + capacity="$(echo "${ws}" | jq -r '.attributes.capacity // 0')" + status="$(echo "${ws}" | jq -r '.attributes.status // "unknown"')" + + if ! atlassian_product_allowed "${product_key}"; then + continue + fi + + fill_pct=0 + ws_status="ok" + if [[ "${capacity}" -gt 0 ]]; then + fill_pct=$((usage * 100 / capacity)) + if [[ "${usage}" -gt "${capacity}" ]]; then + ws_status="OVERAGE" + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Billable Users Exceed Purchased Tier for \`${product_key}\` in Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Workspace '${name}' (${product_key}) has ${usage} billable users versus purchased capacity ${capacity} (${fill_pct}% fill). Last-active data may lag up to 24 hours." \ + "3" \ + "Review inactive users for reclamation, suspend unused accounts, or upgrade the ${product_key} subscription tier before renewal.")" + elif [[ "${fill_pct}" -ge "${USER_TIER_PROXIMITY_PERCENT}" ]]; then + ws_status="PROXIMITY" + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Tier Proximity Alert for \`${product_key}\` in Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Workspace '${name}' is at ${fill_pct}% of purchased tier (${usage}/${capacity} billable seats). Threshold: ${USER_TIER_PROXIMITY_PERCENT}%." \ + "3" \ + "Plan a tier upgrade before the next renewal or reclaim inactive licenses using the companion optimization bundle.")" + fi + else + ws_status="no-capacity" + fi + + printf "%-28s %10s %10s %10s %s\n" "${name}" "${usage}" "${capacity}" "${fill_pct}" "${ws_status}" >> "${REPORT_FILE}" + done < <(echo "${workspaces_json}" | jq -c '.[]') + + echo "" >> "${REPORT_FILE}" + echo "Tier data source: Organizations workspaces API (usage/capacity fields)." >> "${REPORT_FILE}" +else + echo "Workspaces API unavailable; attempting managed-accounts billable counts only." > "${REPORT_FILE}" + if users_json="$(atlassian_fetch_managed_accounts 0 2>/dev/null)"; then + stats_json="$(atlassian_build_product_stats "${users_json}" "${INACTIVE_DAYS_THRESHOLD}")" + echo "" >> "${REPORT_FILE}" + echo "Per-product billable counts (tier quantities unavailable):" >> "${REPORT_FILE}" + while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + if atlassian_product_allowed "${product}"; then + billable="$(echo "${row}" | jq -r '.billable_users')" + echo " ${product}: ${billable} billable users" >> "${REPORT_FILE}" + fi + done < <(echo "${stats_json}" | jq -c '.[]') + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Tier Quantities Unavailable for Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Workspaces API did not return usage/capacity data. Tier-proximity analysis skipped; billable counts reported only. Commerce/contracts APIs may require additional scopes." \ + "2" \ + "Grant read:workspaces:admin scope to the Organization Admin API key or supply tier quantities manually for proximity alerts.")" + else + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Cannot Analyze Tier Proximity for Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Both workspaces and managed-accounts APIs failed. Unable to correlate billable counts with tier limits." \ + "4" \ + "Verify Organization Admin API key, ATLASSIAN_ORG_ID, and API rate limits.") + fi +fi + +cat "${REPORT_FILE}" +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Tier proximity analysis completed. Issues saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-utilization/atlassian-api-helpers.sh b/codebundles/atlassian-org-license-utilization/atlassian-api-helpers.sh new file mode 100755 index 00000000..c1e843d0 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/atlassian-api-helpers.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# Shared helpers for Atlassian Organizations REST API (read-only). +set -euo pipefail + +ATLASSIAN_API_BASE="${ATLASSIAN_API_BASE:-https://api.atlassian.com/admin}" + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" + +ATLASSIAN_ORG_API_KEY="${ATLASSIAN_ORG_API_KEY:-${atlassian_org_api_key:-}}" +: "${ATLASSIAN_ORG_API_KEY:?Must set ATLASSIAN_ORG_API_KEY or atlassian_org_api_key secret}" + +LICENSE_UTILIZATION_MIN_PERCENT="${LICENSE_UTILIZATION_MIN_PERCENT:-70}" +USER_TIER_PROXIMITY_PERCENT="${USER_TIER_PROXIMITY_PERCENT:-80}" +INACTIVE_DAYS_THRESHOLD="${INACTIVE_DAYS_THRESHOLD:-90}" +PRODUCTS="${PRODUCTS:-All}" +MAX_API_RETRIES="${MAX_API_RETRIES:-5}" +API_BACKOFF_SECONDS="${API_BACKOFF_SECONDS:-2}" + +_atlassian_tmp_dir="" +_atlassian_managed_accounts_file="" +_atlassian_workspaces_file="" + +atlassian_cleanup() { + if [[ -n "${_atlassian_tmp_dir}" && -d "${_atlassian_tmp_dir}" ]]; then + rm -rf "${_atlassian_tmp_dir}" + fi +} + +atlassian_init_cache() { + _atlassian_tmp_dir="$(mktemp -d)" + _atlassian_managed_accounts_file="${_atlassian_tmp_dir}/managed_accounts.json" + _atlassian_workspaces_file="${_atlassian_tmp_dir}/workspaces.json" + echo "[]" > "${_atlassian_managed_accounts_file}" + echo "[]" > "${_atlassian_workspaces_file}" +} + +atlassian_product_allowed() { + local key="$1" + if [[ "${PRODUCTS}" == "All" || -z "${PRODUCTS}" ]]; then + return 0 + fi + local normalized + normalized="$(echo "${PRODUCTS}" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -Fx "${key}" || true)" + [[ -n "${normalized}" ]] +} + +atlassian_normalize_product_key() { + local raw="$1" + case "${raw}" in + jira-core|jira_core) echo "jira-core" ;; + jira-software|jira_software) echo "jira-software" ;; + jira-servicedesk|jira_service_management|jira-service-management) echo "jira-servicedesk" ;; + confluence) echo "confluence" ;; + loom) echo "loom" ;; + *) echo "${raw}" ;; + esac +} + +atlassian_is_active_date() { + local last_active="$1" + local threshold_days="$2" + if [[ -z "${last_active}" || "${last_active}" == "null" ]]; then + return 1 + fi + local active_epoch now_epoch cutoff_epoch + active_epoch="$(date -d "${last_active}" +%s 2>/dev/null || date -j -f "%Y-%m-%d" "${last_active}" +%s 2>/dev/null || echo 0)" + if [[ "${active_epoch}" == "0" ]]; then + active_epoch="$(date -d "${last_active}T00:00:00Z" +%s 2>/dev/null || echo 0)" + fi + if [[ "${active_epoch}" == "0" ]]; then + return 1 + fi + now_epoch="$(date -u +%s)" + cutoff_epoch=$((now_epoch - threshold_days * 86400)) + [[ "${active_epoch}" -ge "${cutoff_epoch}" ]] +} + +atlassian_http_request() { + local method="$1" + local url="$2" + local body="${3:-}" + local attempt=0 + local response http_code + local err_file + err_file="$(mktemp)" + + while (( attempt < MAX_API_RETRIES )); do + if [[ "${method}" == "GET" ]]; then + response="$(curl -sS -w $'\n%{http_code}' -X GET "${url}" \ + -H "Authorization: Bearer ${ATLASSIAN_ORG_API_KEY}" \ + -H "Accept: application/json" 2>"${err_file}")" || { + cat "${err_file}" >&2 + rm -f "${err_file}" + return 1 + } + else + response="$(curl -sS -w $'\n%{http_code}' -X POST "${url}" \ + -H "Authorization: Bearer ${ATLASSIAN_ORG_API_KEY}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "${body}" 2>"${err_file}")" || { + cat "${err_file}" >&2 + rm -f "${err_file}" + return 1 + } + fi + + http_code="${response##*$'\n'}" + response="${response%$'\n'*}" + + if [[ "${http_code}" == "429" ]]; then + local reset_wait="${API_BACKOFF_SECONDS}" + if [[ -n "${response}" ]]; then + reset_wait="${API_BACKOFF_SECONDS}" + fi + sleep "${reset_wait}" + attempt=$((attempt + 1)) + continue + fi + + rm -f "${err_file}" + printf '%s\n' "${http_code}" + printf '%s' "${response}" + return 0 + done + + rm -f "${err_file}" + return 1 +} + +atlassian_discover_directory_id() { + if [[ -n "${ATLASSIAN_DIRECTORY_ID:-}" ]]; then + echo "${ATLASSIAN_DIRECTORY_ID}" + return 0 + fi + + local url="${ATLASSIAN_API_BASE}/v2/orgs/${ATLASSIAN_ORG_ID}/directories?limit=1" + local raw http_code body + raw="$(atlassian_http_request GET "${url}")" || return 1 + http_code="$(echo "${raw}" | head -n1)" + body="$(echo "${raw}" | tail -n +2)" + if [[ "${http_code}" != "200" ]]; then + echo "Failed to list directories (HTTP ${http_code}): ${body}" >&2 + return 1 + fi + local dir_id + dir_id="$(echo "${body}" | jq -r '.data[0].directoryId // empty')" + if [[ -z "${dir_id}" ]]; then + echo "No directories discovered for organization ${ATLASSIAN_ORG_ID}" >&2 + return 1 + fi + echo "${dir_id}" +} + +atlassian_fetch_managed_accounts() { + local max_pages="${1:-0}" + local page=0 + local cursor="" + local url + local all_users="[]" + local raw http_code body next_cursor + + while :; do + page=$((page + 1)) + if [[ -n "${cursor}" ]]; then + url="${ATLASSIAN_API_BASE}/v1/orgs/${ATLASSIAN_ORG_ID}/users?cursor=${cursor}" + else + url="${ATLASSIAN_API_BASE}/v1/orgs/${ATLASSIAN_ORG_ID}/users" + fi + + raw="$(atlassian_http_request GET "${url}")" || return 1 + http_code="$(echo "${raw}" | head -n1)" + body="$(echo "${raw}" | tail -n +2)" + + if [[ "${http_code}" != "200" ]]; then + echo "Managed accounts API failed (HTTP ${http_code}): ${body}" >&2 + return 1 + fi + + all_users="$(jq -s '.[0] as $acc | .[1].data as $page | ($acc + $page)' <<< "${all_users} ${body}")" + next_cursor="$(echo "${body}" | jq -r '.links.next // empty')" + if [[ -z "${next_cursor}" || "${next_cursor}" == "null" ]]; then + break + fi + cursor="${next_cursor}" + if [[ "${max_pages}" -gt 0 && "${page}" -ge "${max_pages}" ]]; then + break + fi + done + + echo "${all_users}" > "${_atlassian_managed_accounts_file}" + printf '%s' "${all_users}" +} + +atlassian_fetch_workspaces() { + local cursor="" + local page_body='{}' + local all_ws='[]' + local raw http_code body next_cursor + + while :; do + if [[ -n "${cursor}" ]]; then + page_body="$(jq -n --arg c "${cursor}" '{cursor: $c}')" + else + page_body='{}' + fi + + raw="$(atlassian_http_request POST "${ATLASSIAN_API_BASE}/v2/orgs/${ATLASSIAN_ORG_ID}/workspaces" "${page_body}")" || return 1 + http_code="$(echo "${raw}" | head -n1)" + body="$(echo "${raw}" | tail -n +2)" + + if [[ "${http_code}" != "200" ]]; then + echo "Workspaces API failed (HTTP ${http_code}): ${body}" >&2 + return 1 + fi + + all_ws="$(jq -s '.[0] as $acc | .[1].data as $page | ($acc + $page)' <<< "${all_ws} ${body}")" + next_cursor="$(echo "${body}" | jq -r '.links.next // empty')" + if [[ -z "${next_cursor}" || "${next_cursor}" == "null" ]]; then + break + fi + cursor="${next_cursor}" + done + + echo "${all_ws}" > "${_atlassian_workspaces_file}" + printf '%s' "${all_ws}" +} + +atlassian_build_product_stats() { + local users_json="$1" + local inactive_days="$2" + jq --argjson inactive "${inactive_days}" ' + def active_date(d): (d != null and d != "" and d != "null"); + [ .[] | select(.account_status == "active" or .account_status == null) | + . as $user | + (.product_access // [])[] | + select(.key != null) | + { + product: .key, + billable: 1, + active: (if active_date(.last_active) then + ( .last_active | split("T")[0] ) as $d | + (now - ($d + "T00:00:00Z" | fromdateiso8601)) / 86400 <= $inactive + else false end) + } + ] | + group_by(.product) | + map({ + product: .[0].product, + billable_users: length, + active_users: ([.[] | select(.active)] | length), + utilization_percent: (if length == 0 then 0 else (([.[] | select(.active)] | length) * 100 / length) end) + }) | + sort_by(.product) + ' <<< "${users_json}" +} + +atlassian_append_issue() { + local issues_json="$1" + local title="$2" + local details="$3" + local severity="$4" + local next_steps="$5" + jq \ + --arg title "${title}" \ + --arg details "${details}" \ + --arg severity "${severity}" \ + --arg next_steps "${next_steps}" \ + '. += [{ + title: $title, + details: $details, + severity: ($severity | tonumber), + next_steps: $next_steps + }]' <<< "${issues_json}" +} + +atlassian_api_auth_check() { + local url="${ATLASSIAN_API_BASE}/v2/orgs/${ATLASSIAN_ORG_ID}/directories?limit=1" + local raw http_code + raw="$(atlassian_http_request GET "${url}")" || return 1 + http_code="$(echo "${raw}" | head -n1)" + [[ "${http_code}" == "200" ]] +} diff --git a/codebundles/atlassian-org-license-utilization/evaluate-atlassian-utilization-thresholds.sh b/codebundles/atlassian-org-license-utilization/evaluate-atlassian-utilization-thresholds.sh new file mode 100755 index 00000000..32343135 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/evaluate-atlassian-utilization-thresholds.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=atlassian-api-helpers.sh +source "${SCRIPT_DIR}/atlassian-api-helpers.sh" + +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" + +OUTPUT_FILE="atlassian_utilization_threshold_issues.json" +REPORT_FILE="atlassian_utilization_threshold_report.txt" +issues_json='[]' + +trap atlassian_cleanup EXIT +atlassian_init_cache + +echo "Evaluating license utilization thresholds for organization: ${ATLASSIAN_ORG_NAME}" +echo "Minimum acceptable utilization: ${LICENSE_UTILIZATION_MIN_PERCENT}% (active/billable within ${INACTIVE_DAYS_THRESHOLD} days)" + +if ! users_json="$(atlassian_fetch_managed_accounts 0)"; then + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Cannot Evaluate Utilization for Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Managed accounts API call failed while evaluating utilization thresholds." \ + "4" \ + "Verify Organization Admin API key and ATLASSIAN_ORG_ID; retry after rate-limit backoff.")" + echo "${issues_json}" > "${OUTPUT_FILE}" + exit 0 +fi + +stats_json="$(atlassian_build_product_stats "${users_json}" "${INACTIVE_DAYS_THRESHOLD}")" + +{ + echo "Atlassian License Utilization Threshold Evaluation" + echo "Organization: ${ATLASSIAN_ORG_NAME}" + echo "Threshold: ${LICENSE_UTILIZATION_MIN_PERCENT}% active/billable" + echo "Inactive window: ${INACTIVE_DAYS_THRESHOLD} days" + echo "" + printf "%-24s %8s %8s %8s %s\n" "Product" "Billable" "Active" "Util %" "Result" + printf "%-24s %8s %8s %8s %s\n" "------------------------" "--------" "--------" "--------" "------" +} > "${REPORT_FILE}" + +while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + if ! atlassian_product_allowed "${product}"; then + continue + fi + billable="$(echo "${row}" | jq -r '.billable_users')" + active="$(echo "${row}" | jq -r '.active_users')" + util="$(echo "${row}" | jq -r '.utilization_percent')" + result="PASS" + + if [[ "${billable}" -eq 0 ]]; then + result="SKIP" + elif [[ "${util}" -lt "${LICENSE_UTILIZATION_MIN_PERCENT}" ]]; then + result="BELOW" + severity="3" + if [[ "${util}" -lt $((LICENSE_UTILIZATION_MIN_PERCENT / 2)) ]]; then + severity="4" + fi + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Low License Utilization for \`${product}\` in Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Product ${product}: ${active}/${billable} active/billable users (${util}% utilization). Expected at least ${LICENSE_UTILIZATION_MIN_PERCENT}%. Last-active data may lag up to 24 hours." \ + "${severity}" \ + "Review inactive users in ${product}; suspend or remove access for long-idle accounts; consider right-sizing the subscription tier at renewal.")" + fi + + printf "%-24s %8s %8s %8s %s\n" "${product}" "${billable}" "${active}" "${util}" "${result}" >> "${REPORT_FILE}" +done < <(echo "${stats_json}" | jq -c '.[]') + +cat "${REPORT_FILE}" +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Utilization threshold evaluation completed. Issues saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-utilization/generate-atlassian-license-utilization-report.sh b/codebundles/atlassian-org-license-utilization/generate-atlassian-license-utilization-report.sh new file mode 100755 index 00000000..84dd30d1 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/generate-atlassian-license-utilization-report.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=atlassian-api-helpers.sh +source "${SCRIPT_DIR}/atlassian-api-helpers.sh" + +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" + +OUTPUT_FILE="atlassian_utilization_report_issues.json" +REPORT_FILE="atlassian_utilization_report.txt" +issues_json='[]' + +trap atlassian_cleanup EXIT +atlassian_init_cache + +echo "Generating Atlassian license utilization report for organization: ${ATLASSIAN_ORG_NAME} (${ATLASSIAN_ORG_ID})" +echo "Note: last-active timestamps may lag up to 24 hours per Atlassian API documentation." + +if ! users_json="$(atlassian_fetch_managed_accounts 0)"; then + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Cannot Access Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Managed accounts API call failed. Verify Organization Admin API key and ATLASSIAN_ORG_ID." \ + "4" \ + "Confirm the API key has Organization Admin role and read access; verify ATLASSIAN_ORG_ID in Atlassian Administration.")" + echo "${issues_json}" > "${OUTPUT_FILE}" + exit 0 +fi + +total_users="$(echo "${users_json}" | jq 'length')" +stats_json="$(atlassian_build_product_stats "${users_json}" "${INACTIVE_DAYS_THRESHOLD}")" + +filtered_stats='[]' +while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + if atlassian_product_allowed "${product}"; then + filtered_stats="$(jq -s '.[0] + [.[1]]' <<< "${filtered_stats} ${row}")" + fi +done < <(echo "${stats_json}" | jq -c '.[]') + +{ + echo "Atlassian Organization License Utilization Report" + echo "Organization: ${ATLASSIAN_ORG_NAME} (${ATLASSIAN_ORG_ID})" + echo "Inactive threshold: ${INACTIVE_DAYS_THRESHOLD} days" + echo "Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "" + echo "Managed accounts scanned: ${total_users}" + echo "" + printf "%-24s %12s %12s %12s\n" "Product" "Billable" "Active" "Util %" + printf "%-24s %12s %12s %12s\n" "------------------------" "------------" "------------" "------------" +} > "${REPORT_FILE}" + +org_billable=0 +org_active=0 + +while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + billable="$(echo "${row}" | jq -r '.billable_users')" + active="$(echo "${row}" | jq -r '.active_users')" + util="$(echo "${row}" | jq -r '.utilization_percent')" + printf "%-24s %12s %12s %12s\n" "${product}" "${billable}" "${active}" "${util}" >> "${REPORT_FILE}" + org_billable=$((org_billable + billable)) + org_active=$((org_active + active)) +done < <(echo "${filtered_stats}" | jq -c '.[]') + +org_util=0 +if [[ "${org_billable}" -gt 0 ]]; then + org_util=$((org_active * 100 / org_billable)) +fi + +{ + echo "" + echo "Organization-wide summary:" + echo " Total billable seats (monitored products): ${org_billable}" + echo " Total active users (within ${INACTIVE_DAYS_THRESHOLD}d): ${org_active}" + echo " Weighted utilization: ${org_util}%" +} >> "${REPORT_FILE}" + +echo "${filtered_stats}" | jq '.' > atlassian_utilization_report.json +cat "${REPORT_FILE}" + +if [[ "${total_users}" -eq 0 ]]; then + issues_json="$(atlassian_append_issue "${issues_json}" \ + "No Managed Accounts Found for Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "The managed accounts API returned zero users for organization ${ATLASSIAN_ORG_ID}." \ + "2" \ + "Verify the organization has managed accounts and the API key has Organization Admin permissions.")" +fi + +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Analysis completed. Report saved to ${REPORT_FILE}; issues in ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-utilization/report-atlassian-active-user-trends.sh b/codebundles/atlassian-org-license-utilization/report-atlassian-active-user-trends.sh new file mode 100755 index 00000000..896e09c6 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/report-atlassian-active-user-trends.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=atlassian-api-helpers.sh +source "${SCRIPT_DIR}/atlassian-api-helpers.sh" + +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" + +OUTPUT_FILE="atlassian_active_trend_issues.json" +REPORT_FILE="atlassian_active_trend_report.txt" +issues_json='[]' + +trap atlassian_cleanup EXIT +atlassian_init_cache + +echo "Reporting active user trends for organization: ${ATLASSIAN_ORG_NAME}" + +if ! users_json="$(atlassian_fetch_managed_accounts 0)"; then + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Cannot Report Active User Trends for Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Managed accounts API call failed while building active-user trend summary." \ + "4" \ + "Verify Organization Admin API key and ATLASSIAN_ORG_ID.")" + echo "${issues_json}" > "${OUTPUT_FILE}" + exit 0 +fi + +stats_json="$(atlassian_build_product_stats "${users_json}" "${INACTIVE_DAYS_THRESHOLD}")" + +# Secondary window for trend comparison (half of inactive threshold, min 30 days) +recent_days=$((INACTIVE_DAYS_THRESHOLD / 2)) +if [[ "${recent_days}" -lt 30 ]]; then + recent_days=30 +fi +recent_stats_json="$(atlassian_build_product_stats "${users_json}" "${recent_days}")" + +{ + echo "Atlassian Active User Trends" + echo "Organization: ${ATLASSIAN_ORG_NAME}" + echo "Primary active window: ${INACTIVE_DAYS_THRESHOLD} days" + echo "Recent active window: ${recent_days} days (for share comparison)" + echo "" + printf "%-22s %8s %8s %8s %8s %s\n" "Product" "Billable" "Active" "Recent" "Share %" "Trend" + printf "%-22s %8s %8s %8s %8s %s\n" "----------------------" "--------" "--------" "--------" "--------" "-----" +} > "${REPORT_FILE}" + +while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + if ! atlassian_product_allowed "${product}"; then + continue + fi + billable="$(echo "${row}" | jq -r '.billable_users')" + active="$(echo "${row}" | jq -r '.active_users')" + util="$(echo "${row}" | jq -r '.utilization_percent')" + recent_active="$(echo "${recent_stats_json}" | jq -r --arg p "${product}" '[.[] | select(.product == $p)][0].active_users // 0')" + share_pct="${util}" + trend="stable" + + if [[ "${billable}" -gt 0 && "${recent_active}" -lt "${active}" ]]; then + trend="declining-recent" + elif [[ "${billable}" -gt 0 && "${util}" -lt "${LICENSE_UTILIZATION_MIN_PERCENT}" ]]; then + trend="low-share" + elif [[ "${billable}" -gt 0 && "${util}" -ge 80 ]]; then + trend="healthy" + fi + + printf "%-22s %8s %8s %8s %8s %s\n" "${product}" "${billable}" "${active}" "${recent_active}" "${share_pct}" "${trend}" >> "${REPORT_FILE}" + + if [[ "${trend}" == "declining-recent" || "${trend}" == "low-share" ]]; then + severity="2" + if [[ "${util}" -lt "${LICENSE_UTILIZATION_MIN_PERCENT}" ]]; then + severity="3" + fi + issues_json="$(atlassian_append_issue "${issues_json}" \ + "Declining Active User Share for \`${product}\` in Organization \`${ATLASSIAN_ORG_NAME}\`" \ + "Product ${product}: ${active}/${billable} active within ${INACTIVE_DAYS_THRESHOLD}d (${share_pct}% share) but only ${recent_active} active within ${recent_days}d. Indicates declining engagement versus billable seats." \ + "${severity}" \ + "Review renewal sizing for ${product}; audit inactive users; coordinate with finance on right-sizing before contract renewal.")" + fi +done < <(echo "${stats_json}" | jq -c '.[]') + +{ + echo "" + echo "Note: Trend analysis uses last_active from managed-accounts product_access." + echo "Last-active data may lag up to 24 hours per Atlassian API documentation." +} >> "${REPORT_FILE}" + +cat "${REPORT_FILE}" +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Active user trend report completed. Issues saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-utilization/runbook.robot b/codebundles/atlassian-org-license-utilization/runbook.robot new file mode 100644 index 00000000..9023c9ce --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/runbook.robot @@ -0,0 +1,257 @@ +*** Settings *** +Documentation Monitors Atlassian Cloud organization license utilization across entitled products, comparing active versus billable users and proximity to purchased tier limits. +Metadata Author rw-codebundle-agent +Metadata Display Name Atlassian Organization License Utilization Report +Metadata Supports Atlassian Organization License Utilization SaaS FinOps +Force Tags Atlassian Organization License Utilization FinOps + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library Collections + +Suite Setup Suite Initialization + + +*** Tasks *** +Generate Atlassian License Utilization Report for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Queries the Atlassian Organizations REST API to build a per-product breakdown of billable users, recently active users, and utilization percentage for finance and IT admin review. + [Tags] Atlassian Organization Reporting License access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=generate-atlassian-license-utilization-report.sh + ... env=${env} + ... secret__atlassian_org_api_key=${atlassian_org_api_key} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./generate-atlassian-license-utilization-report.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_utilization_report_issues.json 2>/dev/null || echo "[]" + ... 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 utilization report 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=Atlassian organization API should return managed accounts for license reporting + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +Analyze Billable User Counts Versus Tier Limits for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Correlates billable user counts with workspace usage/capacity (purchased tier) and flags products at or above the tier-proximity threshold or in overage. + [Tags] Atlassian Organization Tier License access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=analyze-atlassian-tier-proximity.sh + ... env=${env} + ... secret__atlassian_org_api_key=${atlassian_org_api_key} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./analyze-atlassian-tier-proximity.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_tier_proximity_issues.json 2>/dev/null || echo "[]" + ... 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 tier proximity 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=Billable user counts should remain below purchased tier limits with headroom before renewal + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +Evaluate License Utilization Thresholds for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Compares per-product active/billable utilization ratios against LICENSE_UTILIZATION_MIN_PERCENT and emits structured issues with remediation hints. + [Tags] Atlassian Organization Utilization License access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=evaluate-atlassian-utilization-thresholds.sh + ... env=${env} + ... secret__atlassian_org_api_key=${atlassian_org_api_key} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./evaluate-atlassian-utilization-thresholds.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_utilization_threshold_issues.json 2>/dev/null || echo "[]" + ... 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 utilization threshold 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=Active/billable utilization should meet or exceed ${LICENSE_UTILIZATION_MIN_PERCENT}% per monitored product + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +Report Active User Trends Across Atlassian Products for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Summarizes unique active users per product using last_active timestamps and highlights products with declining active-user share versus billable seats. + [Tags] Atlassian Organization Trends License access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=report-atlassian-active-user-trends.sh + ... env=${env} + ... secret__atlassian_org_api_key=${atlassian_org_api_key} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./report-atlassian-active-user-trends.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_active_trend_issues.json 2>/dev/null || echo "[]" + ... 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 active user trends 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=Active user share should remain stable relative to billable seats across entitled products + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +*** Keywords *** +Suite Initialization + TRY + ${atlassian_org_api_key}= RW.Core.Import Secret + ... atlassian_org_api_key + ... type=string + ... description=Organization Admin API key used as Bearer token for Organizations REST API + ... pattern=\w* + Set Suite Variable ${atlassian_org_api_key} ${atlassian_org_api_key} + EXCEPT + Log atlassian_org_api_key secret not found WARN + Set Suite Variable ${atlassian_org_api_key} ${EMPTY} + END + + ${ATLASSIAN_ORG_ID}= RW.Core.Import User Variable ATLASSIAN_ORG_ID + ... type=string + ... description=Atlassian Cloud organization UUID from Atlassian Administration + ... pattern=\w* + ${ATLASSIAN_ORG_NAME}= RW.Core.Import User Variable ATLASSIAN_ORG_NAME + ... type=string + ... description=Human-readable organization name for reports and task titles + ... pattern=.* + ${ATLASSIAN_DIRECTORY_ID}= RW.Core.Import User Variable ATLASSIAN_DIRECTORY_ID + ... type=string + ... description=Primary user directory ID when the org has multiple directories (default: discover first directory) + ... pattern=^[\w-]*$ + ... default= + ${LICENSE_UTILIZATION_MIN_PERCENT}= RW.Core.Import User Variable LICENSE_UTILIZATION_MIN_PERCENT + ... type=string + ... description=Minimum acceptable active/billable utilization percentage per product before raising an issue + ... pattern=\d+ + ... default=70 + ${USER_TIER_PROXIMITY_PERCENT}= RW.Core.Import User Variable USER_TIER_PROXIMITY_PERCENT + ... type=string + ... description=Billable-user count as a percentage of purchased tier that triggers proximity alerts + ... pattern=\d+ + ... default=80 + ${INACTIVE_DAYS_THRESHOLD}= RW.Core.Import User Variable INACTIVE_DAYS_THRESHOLD + ... type=string + ... description=Days without product activity before a user is treated as inactive for utilization math + ... pattern=\d+ + ... default=90 + ${PRODUCTS}= RW.Core.Import User Variable PRODUCTS + ... type=string + ... description=Comma-separated product keys to include (e.g. jira-software,confluence,loom) or All + ... pattern=.* + ... default=All + ${TIMEOUT_SECONDS}= RW.Core.Import User Variable TIMEOUT_SECONDS + ... type=string + ... description=Per-task timeout; orgs with large user bases may need higher values + ... pattern=\d+ + ... default=600 + + Set Suite Variable ${ATLASSIAN_ORG_ID} ${ATLASSIAN_ORG_ID} + Set Suite Variable ${ATLASSIAN_ORG_NAME} ${ATLASSIAN_ORG_NAME} + Set Suite Variable ${ATLASSIAN_DIRECTORY_ID} ${ATLASSIAN_DIRECTORY_ID} + Set Suite Variable ${LICENSE_UTILIZATION_MIN_PERCENT} ${LICENSE_UTILIZATION_MIN_PERCENT} + Set Suite Variable ${USER_TIER_PROXIMITY_PERCENT} ${USER_TIER_PROXIMITY_PERCENT} + Set Suite Variable ${INACTIVE_DAYS_THRESHOLD} ${INACTIVE_DAYS_THRESHOLD} + Set Suite Variable ${PRODUCTS} ${PRODUCTS} + Set Suite Variable ${TIMEOUT_SECONDS} ${TIMEOUT_SECONDS} + + ${env}= Create Dictionary + ... ATLASSIAN_ORG_ID=${ATLASSIAN_ORG_ID} + ... ATLASSIAN_ORG_NAME=${ATLASSIAN_ORG_NAME} + ... ATLASSIAN_DIRECTORY_ID=${ATLASSIAN_DIRECTORY_ID} + ... LICENSE_UTILIZATION_MIN_PERCENT=${LICENSE_UTILIZATION_MIN_PERCENT} + ... USER_TIER_PROXIMITY_PERCENT=${USER_TIER_PROXIMITY_PERCENT} + ... INACTIVE_DAYS_THRESHOLD=${INACTIVE_DAYS_THRESHOLD} + ... PRODUCTS=${PRODUCTS} + Set Suite Variable ${env} ${env} diff --git a/codebundles/atlassian-org-license-utilization/sli-atlassian-org-license-score.sh b/codebundles/atlassian-org-license-utilization/sli-atlassian-org-license-score.sh new file mode 100755 index 00000000..352097a7 --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/sli-atlassian-org-license-score.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Lightweight SLI scorer: API reachability, tier headroom, utilization health. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=atlassian-api-helpers.sh +source "${SCRIPT_DIR}/atlassian-api-helpers.sh" + +SLI_MAX_USER_PAGES="${SLI_MAX_USER_PAGES:-10}" + +trap atlassian_cleanup EXIT +atlassian_init_cache + +api_ok=0 +tier_ok=1 +util_ok=1 +details='{}' + +if atlassian_api_auth_check; then + api_ok=1 +else + api_ok=0 +fi + +if [[ "${api_ok}" -eq 1 ]]; then + if workspaces_json="$(atlassian_fetch_workspaces 2>/dev/null)"; then + tier_violations=0 + while IFS= read -r ws; do + type_key="$(echo "${ws}" | jq -r '.attributes.typeKey // .attributes.type // "unknown"')" + product_key="$(atlassian_normalize_product_key "${type_key}")" + if ! atlassian_product_allowed "${product_key}"; then + continue + fi + usage="$(echo "${ws}" | jq -r '.attributes.usage // 0')" + capacity="$(echo "${ws}" | jq -r '.attributes.capacity // 0')" + if [[ "${capacity}" -gt 0 ]]; then + fill_pct=$((usage * 100 / capacity)) + if [[ "${usage}" -gt "${capacity}" || "${fill_pct}" -ge "${USER_TIER_PROXIMITY_PERCENT}" ]]; then + tier_violations=$((tier_violations + 1)) + fi + fi + done < <(echo "${workspaces_json}" | jq -c '.[]') + if [[ "${tier_violations}" -gt 0 ]]; then + tier_ok=0 + fi + fi + + if users_json="$(atlassian_fetch_managed_accounts "${SLI_MAX_USER_PAGES}" 2>/dev/null)"; then + stats_json="$(atlassian_build_product_stats "${users_json}" "${INACTIVE_DAYS_THRESHOLD}")" + below=0 + monitored=0 + while IFS= read -r row; do + product="$(echo "${row}" | jq -r '.product')" + if ! atlassian_product_allowed "${product}"; then + continue + fi + billable="$(echo "${row}" | jq -r '.billable_users')" + util="$(echo "${row}" | jq -r '.utilization_percent')" + if [[ "${billable}" -gt 0 ]]; then + monitored=$((monitored + 1)) + if [[ "${util}" -lt "${LICENSE_UTILIZATION_MIN_PERCENT}" ]]; then + below=$((below + 1)) + fi + fi + done < <(echo "${stats_json}" | jq -c '.[]') + if [[ "${monitored}" -gt 0 && "${below}" -gt 0 ]]; then + util_ok=0 + fi + details="$(jq -n \ + --argjson api "${api_ok}" \ + --argjson tier "${tier_ok}" \ + --argjson util "${util_ok}" \ + --argjson monitored "${monitored}" \ + --argjson below "${below}" \ + --argjson max_pages "${SLI_MAX_USER_PAGES}" \ + '{api_reachable: $api, tier_headroom_ok: $tier, utilization_ok: $util, monitored_products: $monitored, below_threshold_products: $below, sli_user_pages_cap: $max_pages}')" + else + util_ok=0 + details='{"utilization_ok": 0, "reason": "managed-accounts fetch failed"}' + fi +else + tier_ok=0 + util_ok=0 + details='{"api_reachable": 0}' +fi + +jq -n \ + --argjson api_reachable "${api_ok}" \ + --argjson tier_headroom_ok "${tier_ok}" \ + --argjson utilization_ok "${util_ok}" \ + --argjson details "${details:-{}}" \ + '{ + api_reachable: $api_reachable, + tier_headroom_ok: $tier_headroom_ok, + utilization_ok: $utilization_ok, + details: $details + }' diff --git a/codebundles/atlassian-org-license-utilization/sli.robot b/codebundles/atlassian-org-license-utilization/sli.robot new file mode 100644 index 00000000..ba7391aa --- /dev/null +++ b/codebundles/atlassian-org-license-utilization/sli.robot @@ -0,0 +1,131 @@ +*** Settings *** +Documentation Measures Atlassian organization license health by scoring API reachability, tier headroom, and utilization against configured thresholds. Produces a value between 0 (failing) and 1 (fully passing). +Metadata Author rw-codebundle-agent +Metadata Display Name Atlassian Organization License Utilization SLI +Metadata Supports Atlassian Organization License Utilization SaaS +Suite Setup Suite Initialization + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library Collections + + +*** Keywords *** +Suite Initialization + TRY + ${atlassian_org_api_key}= RW.Core.Import Secret + ... atlassian_org_api_key + ... type=string + ... description=Organization Admin API key used as Bearer token for Organizations REST API + ... pattern=\w* + Set Suite Variable ${atlassian_org_api_key} ${atlassian_org_api_key} + EXCEPT + Log atlassian_org_api_key secret not found WARN + Set Suite Variable ${atlassian_org_api_key} ${EMPTY} + END + + ${ATLASSIAN_ORG_ID}= RW.Core.Import User Variable ATLASSIAN_ORG_ID + ... type=string + ... description=Atlassian Cloud organization UUID from Atlassian Administration + ... pattern=\w* + ${ATLASSIAN_ORG_NAME}= RW.Core.Import User Variable ATLASSIAN_ORG_NAME + ... type=string + ... description=Human-readable organization name for reports and task titles + ... pattern=.* + ${ATLASSIAN_DIRECTORY_ID}= RW.Core.Import User Variable ATLASSIAN_DIRECTORY_ID + ... type=string + ... description=Primary user directory ID when the org has multiple directories + ... pattern=^[\w-]*$ + ... default= + ${LICENSE_UTILIZATION_MIN_PERCENT}= RW.Core.Import User Variable LICENSE_UTILIZATION_MIN_PERCENT + ... type=string + ... description=Minimum acceptable active/billable utilization percentage per product + ... pattern=\d+ + ... default=70 + ${USER_TIER_PROXIMITY_PERCENT}= RW.Core.Import User Variable USER_TIER_PROXIMITY_PERCENT + ... type=string + ... description=Billable-user percentage of purchased tier that triggers proximity alerts + ... pattern=\d+ + ... default=80 + ${INACTIVE_DAYS_THRESHOLD}= RW.Core.Import User Variable INACTIVE_DAYS_THRESHOLD + ... type=string + ... description=Days without product activity before a user is treated as inactive + ... pattern=\d+ + ... default=90 + ${PRODUCTS}= RW.Core.Import User Variable PRODUCTS + ... type=string + ... description=Comma-separated product keys to include or All + ... pattern=.* + ... default=All + ${SLI_MAX_USER_PAGES}= RW.Core.Import User Variable SLI_MAX_USER_PAGES + ... type=string + ... description=Maximum managed-account pages fetched during SLI scoring (caps runtime for large orgs) + ... pattern=\d+ + ... default=10 + + Set Suite Variable ${ATLASSIAN_ORG_ID} ${ATLASSIAN_ORG_ID} + Set Suite Variable ${ATLASSIAN_ORG_NAME} ${ATLASSIAN_ORG_NAME} + Set Suite Variable ${ATLASSIAN_DIRECTORY_ID} ${ATLASSIAN_DIRECTORY_ID} + Set Suite Variable ${LICENSE_UTILIZATION_MIN_PERCENT} ${LICENSE_UTILIZATION_MIN_PERCENT} + Set Suite Variable ${USER_TIER_PROXIMITY_PERCENT} ${USER_TIER_PROXIMITY_PERCENT} + Set Suite Variable ${INACTIVE_DAYS_THRESHOLD} ${INACTIVE_DAYS_THRESHOLD} + Set Suite Variable ${PRODUCTS} ${PRODUCTS} + Set Suite Variable ${SLI_MAX_USER_PAGES} ${SLI_MAX_USER_PAGES} + Set Suite Variable ${score_api} 0 + Set Suite Variable ${score_tier} 0 + Set Suite Variable ${score_util} 0 + + ${env}= Create Dictionary + ... ATLASSIAN_ORG_ID=${ATLASSIAN_ORG_ID} + ... ATLASSIAN_ORG_NAME=${ATLASSIAN_ORG_NAME} + ... ATLASSIAN_DIRECTORY_ID=${ATLASSIAN_DIRECTORY_ID} + ... LICENSE_UTILIZATION_MIN_PERCENT=${LICENSE_UTILIZATION_MIN_PERCENT} + ... USER_TIER_PROXIMITY_PERCENT=${USER_TIER_PROXIMITY_PERCENT} + ... INACTIVE_DAYS_THRESHOLD=${INACTIVE_DAYS_THRESHOLD} + ... PRODUCTS=${PRODUCTS} + ... SLI_MAX_USER_PAGES=${SLI_MAX_USER_PAGES} + Set Suite Variable ${env} ${env} + + +*** Tasks *** +Score Atlassian API Reachability and License Dimensions + [Documentation] Runs a lightweight scorer that checks Organizations API auth, tier headroom from workspaces usage/capacity, and utilization against LICENSE_UTILIZATION_MIN_PERCENT. + [Tags] Atlassian sli access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=sli-atlassian-org-license-score.sh + ... env=${env} + ... secret__atlassian_org_api_key=${atlassian_org_api_key} + ... timeout_seconds=30 + ... include_in_history=false + ... cmd_override=./sli-atlassian-org-license-score.sh + + TRY + ${data}= Evaluate json.loads(r'''${result.stdout}''') json + EXCEPT + Log Failed to parse SLI JSON, defaulting to failing scores. WARN + ${data}= Create Dictionary api_reachable=0 tier_headroom_ok=0 utilization_ok=0 + END + + ${score_api}= Evaluate int(${data}.get('api_reachable', 0)) + ${score_tier}= Evaluate int(${data}.get('tier_headroom_ok', 0)) + ${score_util}= Evaluate int(${data}.get('utilization_ok', 0)) + Set Suite Variable ${score_api} ${score_api} + Set Suite Variable ${score_tier} ${score_tier} + Set Suite Variable ${score_util} ${score_util} + + RW.Core.Push Metric ${score_api} sub_name=api_reachable + RW.Core.Push Metric ${score_tier} sub_name=tier_headroom_ok + RW.Core.Push Metric ${score_util} sub_name=utilization_ok + + +Generate Aggregate Atlassian License Health Score + [Documentation] Averages API reachability, tier headroom, and utilization sub-scores into the primary 0-1 SLI metric. + [Tags] Atlassian sli access:read-only data:metrics + + ${health_score}= Evaluate (int(${score_api}) + int(${score_tier}) + int(${score_util})) / 3.0 + ${health_score}= Convert To Number ${health_score} 2 + RW.Core.Add To Report Atlassian license health score for `${ATLASSIAN_ORG_NAME}`: ${health_score} (api=${score_api}, tier=${score_tier}, util=${score_util}) + RW.Core.Push Metric ${health_score}