diff --git a/scripts/cloudzero/README.md b/scripts/cloudzero/README.md new file mode 100644 index 0000000..24635f5 --- /dev/null +++ b/scripts/cloudzero/README.md @@ -0,0 +1,73 @@ +# CloudZero to OpsLevel Cost Integration + +This repository contains a sample Python script to integrate cost data from CloudZero into your OpsLevel service catalog. By leveraging the CloudZero and OpsLevel APIs, this integration provides visibility into infrastructure cloud costs associated with services in your catalog. + +## How it Works + +The integration follows these steps: +1. **Fetch Cost Data:** The script calls the CloudZero Billing Costs API (`/v2/billing/costs`) to retrieve cost data for a specified date range. +2. **Group by Tag:** It groups the cost data using a specific tag, such as "Tag:Name", which helps associate infrastructure resources with services in your catalog. The example script specifically ignores costs associated with "__NULL_PARTITION_VALUE__". +3. **Aggregate Costs:** For each unique tag name, the script aggregates the total cost. +4. **Update OpsLevel Properties:** It then calls the OpsLevel GraphQL API using the `propertyAssign` mutation to update a custom property on the corresponding service in your OpsLevel catalog. The tag name from CloudZero is used as the service alias (owner alias) in OpsLevel, and the aggregated cost is assigned as the property value. + +## Prerequisites + +To use this integration, you need: +* **A CloudZero Account:** With an API key enabled. +* **Integrated Cloud Infrastructure:** Your cloud infrastructure (e.g., AWS) should be integrated with CloudZero. +* **Cost Allocation Tags:** **Cost allocation tags must be enabled and used on your cloud resources**. The integration leverages these tags, particularly the "Name" tag in the example, to relate infrastructure costs to services in OpsLevel. +* **An OpsLevel Account:** With an API token. +* **OpsLevel Custom Property:** A custom property definition must be created in OpsLevel to hold the cost data, for example, a JSON property named "aws_cost". +* **Python Environment:** A Python environment with the `requests` library installed. + +## Setup + +1. **Configure Environment Variables:** + * Set the `CLOUDZERO_API_KEY` environment variable with your CloudZero API key. + * Set the `OPSLEVEL_API_TOKEN` environment variable with your OpsLevel API token. + * If these variables are not set, the script will print an error and exit. + +2. **Define OpsLevel Custom Property:** + * Ensure you have a custom property defined in OpsLevel. The example script uses the alias `"aws_cost"`. This property will store the cost value for each service. The property value is stored as a JSON string in OpsLevel. + +3. **Configure Script Parameters:** + * Adjust the config dictionary within the main() function to match your requirements: + + ``` + config = { + 'opslevel_api_url': 'https://api.opslevel.com/graphql', # Your OpsLevel GraphQL API URL + 'opslevel_token_env_var': "OPSLEVEL_API_TOKEN", # Environment variable for OpsLevel API token + 'opslevel_definition_alias': "aws_cost", # Alias of the OpsLevel property definition (e.g., "aws_cost") + 'cloudzero_api_key_env_var': "CLOUDZERO_API_KEY", # Environment variable for CloudZero API key + 'cloudzero_start_date': "2025-05-01T00:00:00Z", # Start date for CloudZero costs (ISO 8601 format) + 'cloudzero_end_date': "2025-05-09T00:00:00Z", # End date for CloudZero costs (ISO 8601 format) + 'cloudzero_granularity': "daily", # Granularity for CloudZero costs ("daily", "monthly") + 'cloudzero_cost_type': "real_cost" # Cost type for CloudZero costs ("real_cost", "amortized_cost") + } + ``` + +## Script Usage + +1. **Install Dependencies:** + ```bash + pip install requests + ``` +2. **Run the Script:** + ```bash + python get_cloudzero_billing_costs.py + ``` + +The script will fetch data from CloudZero, process it, and attempt to update the specified custom property on services in OpsLevel whose aliases match the "Tag:Name" value from CloudZero. + +The script includes error handling for API calls and JSON decoding. It will print status updates and results. + +## Visualizing Costs in OpsLevel + +Once the script has populated the custom property, you can view the cost information within OpsLevel: + +* **Service Details Page:** The cost will appear as a custom property on the individual service's page. +* **Team Dashboard Widget:** You can add a custom widget to a team's dashboard to display a breakdown of costs for services owned by that team. This widget uses a GraphQL query to fetch the custom property value for all services within the team. The example shows this as a pie chart widget providing a cost breakdown by service. + +## Extensibility + +While this example specifically integrates with CloudZero and uses "Tag:Name" for grouping, the approach of fetching cost data and pushing it to OpsLevel custom properties can be extended. You could use other data sources like the AWS Cost Explorer APIs or APIs from different infrastructure cost management tools. \ No newline at end of file diff --git a/scripts/cloudzero/get_cloudzero_billing_costs.py b/scripts/cloudzero/get_cloudzero_billing_costs.py new file mode 100644 index 0000000..8c32277 --- /dev/null +++ b/scripts/cloudzero/get_cloudzero_billing_costs.py @@ -0,0 +1,188 @@ +import requests +import json +import os # Import the os module + +def update_property(api_url, opslevel_token, alias, definition_alias, value): + """ + Calls the update_property mutation. + + Args: + api_url: The URL of the GraphQL API. + opslevel_token: The OpsLevel API token. + alias: The alias of the owner. + definition_alias: The alias of the property definition. + value: The new value for the property. + + Returns: + The JSON response from the mutation, or None if an error occurs. + """ + + mutation = """ + mutation update_property($alias:String, $definition_alias:String, $value:JsonString!){ + propertyAssign(input: {owner: {alias: $alias}, definition: {alias: $definition_alias}, + value: $value, runValidation: false}) { + property{ + value + owner{ + ...on Service{ + name + } + } + } + errors{ + message + path + } + } + } + """ + + variables = { + "alias": alias, + "definition_alias": definition_alias, + "value": json.dumps(value) # Important: Convert value to JSON string + } + + headers = { + "Authorization": f"Bearer {opslevel_token}", + "Content-Type": "application/json", + } + + payload = { + "query": mutation, + "variables": json.dumps(variables) + } + + try: + response = requests.post(api_url, json=payload, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error calling update_property mutation: {e}") + return None + except json.JSONDecodeError as e: + print(f"Error decoding JSON response: {e}. Response text: {response.text}") + return None + +def get_cloudzero_billing_costs(start_date, end_date, api_key, granularity="daily", cost_type="real_cost"): + """ + Calls the CloudZero Billing Costs API to retrieve cost data. + + Args: + start_date (str): The start date for the cost data in ISO 8601 format (e.g., "2025-01-01T00:00:00Z"). + end_date (str): The end date for the cost data in ISO 8601 format (e.g., "2025-01-31T23:59:59Z"). + api_key (str): The CloudZero API key. + granularity (str, optional): The granularity of the cost data (e.g., "daily", "monthly"). Defaults to "daily". + cost_type (str, optional): The type of cost to retrieve (e.g., "real_cost", "amortized_cost"). Defaults to "real_cost". + + Returns: + dict: The JSON response from the API if the request is successful, None otherwise. + """ + if not api_key: + print("Error: CloudZero API key is missing.") + return None + + url = "https://api.cloudzero.com/v2/billing/costs" + headers = { + "accept": "application/json", + "Authorization": api_key # Assuming API key is passed as a header + } + params = { + "start_date": start_date, + "end_date": end_date, + "granularity": granularity, + "cost_type": cost_type, + "group_by": ["Tag:Name"] + } + + try: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() # Raise an exception for bad status codes + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error calling CloudZero API: {e}") + if response is not None: + print(f"Response status code: {response.status_code}") + try: + print(f"Response body: {response.json()}") + except: + print(f"Response body: {response.text}") + return None + +def main(): + """ + Main function to retrieve CloudZero billing costs, process them, + and update OpsLevel properties. + """ + # --- Configuration Variables Block --- + # Set all configurable parameters here for easy modification. + config = { + 'opslevel_api_url': 'https://api.opslevel.com/graphql', # OpsLevel GraphQL API URL + 'opslevel_token_env_var': "OPSLEVEL_API_TOKEN", # Environment variable for OpsLevel API token + 'opslevel_definition_alias': "aws_cost", # Alias of the OpsLevel property definition + 'cloudzero_api_key_env_var': "CLOUDZERO_API_KEY", # Environment variable for CloudZero API key + 'cloudzero_start_date': "2025-05-01T00:00:00Z", # Start date for CloudZero costs + 'cloudzero_end_date': "2025-05-09T00:00:00Z", # End date for CloudZero costs + 'cloudzero_granularity': "daily", # Granularity for CloudZero costs + 'cloudzero_cost_type': "real_cost" # Cost type for CloudZero costs + } + # --- End Configuration Variables Block --- + + opslevel_token = os.environ.get(config['opslevel_token_env_var']) + if not opslevel_token: + print(f"Error: {config['opslevel_token_env_var']} environment variable not set.") + return + + cloudzero_api_key = os.environ.get(config['cloudzero_api_key_env_var']) + if not cloudzero_api_key: + print(f"Error: {config['cloudzero_api_key_env_var']} environment variable not set.") + return + + print("Retrieving CloudZero billing costs...") + billing_data = get_cloudzero_billing_costs( + config['cloudzero_start_date'], + config['cloudzero_end_date'], + cloudzero_api_key, # Pass the API key here + config['cloudzero_granularity'], + config['cloudzero_cost_type'] + ) + + if billing_data: + print("CloudZero Billing Costs:") + # Group data by tag and sum costs, ignoring "__NULL_PARTITION_VALUE__" + grouped_data = {} + for item in billing_data['costs']: + tag_name = item.get("Tag:Name") + cost = item.get("cost") + if tag_name and tag_name != "__NULL_PARTITION_VALUE__" and cost is not None: + if tag_name in grouped_data: + grouped_data[tag_name]["cost"] += cost + grouped_data[tag_name]["usage_dates"].append(item.get("usage_date")) + else: + grouped_data[tag_name] = {"cost": cost, "usage_dates": [item.get("usage_date")]} + + # Convert the grouped data to the desired JSON format + result = [{"Tag_Name": tag, "Cost": data["cost"], "Usage_Dates": data["usage_dates"]} for tag, data in grouped_data.items()] + + print("\n--- Processed CloudZero Costs ---") + print(json.dumps(result, indent=2)) + + print("\nUpdating OpsLevel properties...") + for item in result: + print(f" Updating property for service '{item['Tag_Name']}' with cost: {item['Cost']}") + ol_prop_update_result = update_property( + config['opslevel_api_url'], + opslevel_token, + item["Tag_Name"], + config['opslevel_definition_alias'], + item["Cost"] + ) + if ol_prop_update_result: + print(" Update Result:", json.dumps(ol_prop_update_result, indent=2)) + else: + print(f" Property update failed for service '{item['Tag_Name']}'.") + else: + print("Failed to retrieve CloudZero billing costs. No OpsLevel properties updated.") + +if __name__ == "__main__": + main() \ No newline at end of file