├── README.md ├── config_sample.ini ├── get_balance.py ├── oci-prices.py ├── oci-resources.py ├── psm-resources.py └── usage_cost_total.py /README.md: -------------------------------------------------------------------------------- 1 | # oracle-cloud 2 | Scripts for managing and monitoring Oracle Cloud accounts 3 | 4 | `get_balance.py` Very simple script to get current account balances from multiple Oracle cloud accounts 5 | 6 | `usage_cost_total.py` Get total cost between two given dates (useful to monitor monthly spend) 7 | 8 | `oci-resources.py` List all current instances of all OCI services in a given tenancy 9 | 10 | `psm-resources.py` List instances of all PSM based services in a given tenancy (profile) 11 | 12 | `oci-prices.py` Use pricing API to return all UC services and prices 13 | -------------------------------------------------------------------------------- /config_sample.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | username = myname@something.com 3 | password = nobodyKnows@44 4 | 5 | [tenancy1] 6 | username = admin@something.com 7 | password = somethingElse#77 8 | domain = cacct-8lfcde8lfcde8lfcde8lfcde8lfcde8l 9 | idcs_guid = idcs-21bca21bca21bca21bca21bca21bca21 10 | 11 | [tenancy44] 12 | domain = cacct-8lfcde8lfcde8lfcde8lfcde8lfcde8l 13 | idcs_guid = idcs-21bca21bca21bca21bca21bca21bca21 14 | -------------------------------------------------------------------------------- /get_balance.py: -------------------------------------------------------------------------------- 1 | # get_balance.py 2 | # martin.bridge@oracle.com 3 | # 4 | # Simple listing of account balance using the Oracle Cloud Account Metering API 5 | # See: https://docs.oracle.com/en/cloud/get-started/subscriptions-cloud/meter 6 | # 7 | # Cloud credentials are read from a config file in which multiple tenancies can be stored 8 | # Note: This script assumes there is only one purchase to create the tenancy (multiple purchase entries 9 | # may do strange things!) 10 | # 11 | # Parameters: 12 | # profile_name 13 | # 14 | # Other parameters picked up from config file using profile name 15 | # username 16 | # password 17 | # idcs_id (idcs-4656dbcafeb47777d3efabcdef12345) 18 | # domain_id (cacct-8b4b0c9b4c40173264564750985ff6b34 19 | # 20 | # Output 21 | # stdout, readable column format 22 | # 23 | # Output format 24 | # Time Tenant Currency Purchased Balance Running Consumed 25 | # 17/12/2018 17:16:17 mytenant1 GBP 1800.00 1132.44 1132.44 667.56 26 | # 17/12/2018 17:16:17 tenant2 GBP 12000.00 11709.24 11709.24 290.76 27 | # 28 | # 17-dec-2018 1.0 mbridge Created 29 | # 30 | 31 | import configparser 32 | import datetime 33 | import json 34 | import os 35 | import sys 36 | import requests 37 | 38 | debug: bool = False 39 | configfile = '~/.oci/config.ini' 40 | 41 | 42 | # Print headings 43 | def print_balance_header(): 44 | print("{:24s}{:30s}{:10s}{:>11s}{:>11s}{:>12s}".format( 45 | 'Time', 'Tenant', 'Currency', 'Purchased', 'Balance', 'Consumed')) 46 | 47 | 48 | # Use the Oracle REST API to get the account balance for the given tenancy 49 | def get_account_balance(report_time, tenancy_name, username, password, cloud_acct, idcs_guid): 50 | 51 | resp = requests.get( 52 | 'https://itra.oraclecloud.com/metering/api/v1/cloudbucks/' + cloud_acct, 53 | auth=(username, password), 54 | headers={'X-ID-TENANT-NAME': idcs_guid, 'accept-encoding': '*'} 55 | ) 56 | 57 | if resp.status_code != 200: 58 | # This means something went wrong 59 | msg = json.loads(resp.text)['errorMessage'] 60 | 61 | print('Error in GET: {} ({}) on tenancy {}'.format(resp.status_code, resp.reason, tenancy_name), file=sys.stderr) 62 | print(' {}'.format(msg), file=sys.stderr) 63 | 64 | else: 65 | i = resp.json() 66 | for item in resp.json()['items']: 67 | 68 | # Calculate amt consumed so far 69 | consumed = item['purchase'][0]['purchasedResources'][0]['value'] - \ 70 | item['balance'][0]['purchasedResources'][0]['value'] 71 | 72 | print("{:24s}{:30s}{:10s}{:>11.2f}{:>11.2f}{:12.2f}".format( 73 | report_time.strftime('%d/%m/%Y %H:%M:%S'), 74 | tenancy_name, 75 | item['purchase'][0]['purchasedResources'][0]['unit'], 76 | item['purchase'][0]['purchasedResources'][0]['value'], 77 | item['balance'][0]['purchasedResources'][0]['value'], 78 | consumed 79 | ) 80 | ) 81 | 82 | 83 | if __name__ == "__main__": 84 | 85 | # In case we use the tilde (~) home directory character 86 | configfile = os.path.expanduser(configfile) 87 | 88 | if not os.path.isfile(configfile): 89 | print('Error: Config file not found ({})'.format(configfile), file=sys.stderr) 90 | sys.exit(0) 91 | 92 | config = configparser.ConfigParser() 93 | config.read(configfile) 94 | 95 | # Timestamp 96 | report_time = datetime.datetime.now() 97 | 98 | # Print headings 99 | print_balance_header() 100 | 101 | # For each tenant in the config file 102 | for tenant in config.sections(): 103 | 104 | ini_data = config[tenant] 105 | 106 | username = ini_data['username'] 107 | password = ini_data['password'] 108 | cloud_acct = ini_data['domain'] 109 | idcs_guid = ini_data['idcs_guid'] 110 | 111 | if debug: 112 | print('User:Pass = {}:{} Domain, IDCSID = {}:{}'.format( 113 | username, "*" * len(password), cloud_acct, idcs_guid)) 114 | 115 | get_account_balance(report_time, tenant, username, password, cloud_acct, idcs_guid) 116 | -------------------------------------------------------------------------------- /oci-prices.py: -------------------------------------------------------------------------------- 1 | # oci-prices.py 2 | # 3 | # List all OCI Universal Credit service prices 4 | # 5 | # API only returns current SKUs - if a product SKU has been retired it will not be returned by the API, 6 | # so if using this API in conjunction with older metering/billing data, you may find some that do not match. 7 | # 8 | # See: https://oc-blog.com/2020/01/22/undocumented-oci-pricelist-api/ 9 | # 10 | # 20-jan-2020 Martin Bridge Created 11 | # 13-apr-2021 Martin Bridge Output currency code 12 | 13 | import requests 14 | 15 | 16 | def print_price_list(currency_code): 17 | 18 | # Example requests 19 | # https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products/10089 20 | # https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?parentProductPartNumber=B88206&limit=500 21 | # https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?partNumber=B91128 22 | 23 | url = "https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=500" 24 | http_header = {'X-Oracle-Accept-CurrencyCode': currency_code} 25 | resp = requests.get(url, headers=http_header) 26 | 27 | # Columns headings 28 | print("PartNum|Category|Name|Metric|PAYG_price|Month_price|Currency}") 29 | 30 | nitems = 0 31 | items = resp.json()['items'] 32 | for item in items: 33 | nitems += 1 34 | # print(f"{item['displayName']:160} - {item['prices']}") 35 | 36 | # Some items do not have serviceCategoryDisplayName 37 | part_num = item['partNumber'] 38 | name = item['shortDisplayName'] 39 | currency = item['currencyCode'] 40 | try: 41 | category = item['serviceCategoryDisplayName'] 42 | except KeyError: 43 | category = "None" 44 | try: 45 | metric = item['metricDisplayName'] 46 | except KeyError: 47 | metric = "None" 48 | 49 | # Some items have a banded price, free up to a limit, then a charge beyond 50 | # For simplicity, use the lowest price 51 | payg_price = 1000000 52 | month_price = 1000000 53 | # Some items are free and have no price (e.g. 'B94418 - Oracle Cloud Program - ... - Research Cloud Starter') 54 | if 'prices' in item: 55 | for price in item['prices']: 56 | if price['model'] == 'PAY_AS_YOU_GO': 57 | if float(price['value']) < payg_price: 58 | payg_price = float(price['value']) 59 | elif price['model'] == 'MONTHLY_COMMIT': 60 | if float(price['value']) < month_price: 61 | month_price = float(price['value']) 62 | else: 63 | payg_price = 'n/a' 64 | month_price = 'n/a' 65 | 66 | print(f"{part_num}|{category}|{name}|{metric}|{payg_price}|{month_price}|{currency}") 67 | 68 | print(f"{nitems} SKUs found") 69 | 70 | 71 | if __name__ == "__main__": 72 | 73 | print_price_list("GBP") 74 | -------------------------------------------------------------------------------- /oci-resources.py: -------------------------------------------------------------------------------- 1 | # List resources in an OCI tenancy 2 | # 3 | # Parameters: 4 | # profile_name 5 | # (credentials are then picked up from the config file) 6 | # -c - only show resources within this compartment and any subcompartments 7 | # 8 | # Output 9 | # stdout, readable column format 10 | # csv file 11 | # 12 | # 16-nov-2018 Martin Bridge Created 13 | # 06-sep-2019 Martin Bridge Added detection of Non-BYOL database instances 14 | # 09-jan-2020 Martin Bridge Use Node status to indicate real availability of database 15 | # 24-mar-2020 Mohamed Elkayal Added check for unattached volumes 16 | # 26-mar-2020 Mohamed Elkayal Added check for unattached boot volumes 17 | # 20-apr-2020 Mohamed Elkayal Fix for multiple attached volumes 18 | # 02-nov-2020 Martin Bridge Added analytics, integration, ODA (new gen2 services) 19 | # 17-nov-2020 Martin Bridge Added Obj store and File system storage (GBytes) 20 | # Added resource OCID 21 | # Calculate object storage size 22 | # 12-apr-2021 Martin Bridge Add compartment_id command line option 23 | # 14-jan-2022 Martin Bridge FIXED: Limit of 500 resources reported per region. Pagination for resource search added 24 | # 25 | 26 | import argparse 27 | import csv 28 | import re 29 | import sys 30 | import time 31 | from string import Formatter 32 | 33 | import oci 34 | 35 | # Enable debug logging 36 | # import logging 37 | # logging.basicConfig() 38 | # logging.getLogger('oci').setLevel(logging.DEBUG) 39 | 40 | 41 | ################################################################################################ 42 | debug = False 43 | output_dir = "./log" 44 | ################################################################################################ 45 | 46 | # Output formats for readable, columns style output and csv files 47 | field_names = ['Tenancy', 'Region', 'Compartment', 'Type', 'Name', 'State', 'DB', 48 | 'Shape', 'OCPU', 'GBytes', 'BYOLstatus', 'VolAttached', 'Created', 'CreatedBy', 'OCID'] 49 | print_format = '{Tenancy:24s} {Region:14s} {Compartment:54s} {Type:26s} {Name:54.54s} {State:18s} {DB:4s} ' \ 50 | '{Shape:20s} {OCPU:4d} {GBytes:>8.3f} {BYOLstatus:10s} {VolAttached:12s} {Created:32s} {CreatedBy:32s} {OCID:120}' 51 | 52 | # Header format removes the named placeholders 53 | header_format = re.sub('{[A-Z,a-z]*', '{', print_format) # Remove names 54 | header_format = re.sub('\.[0-9]*', '', header_format) # Remove decimal 55 | header_format = re.sub('f}', 's}', header_format) # replace float w. string 56 | header_format = re.sub('d}', 's}', header_format) # replace decimal w. string 57 | 58 | # Fixed strings 59 | BYOL = "BYOL" 60 | NONBYOL = "*NON-BYOL*" 61 | 62 | 63 | def debug_out(out_str): 64 | if debug: 65 | print(out_str) 66 | 67 | 68 | # Get compartment full name (path) from the compartment list dictionary 69 | def get_compartment_name(compartment_id, compartment_list): 70 | for comp in compartment_list: 71 | if comp['id'] == compartment_id: 72 | return comp['path'] 73 | return 'Not Found' 74 | 75 | 76 | def list_tenancy_resources(compartment_list, base_compartment_id): 77 | global tenancy_name 78 | global regions 79 | global config 80 | 81 | # Headings 82 | vformat = Formatter().vformat 83 | print(vformat(header_format, field_names, '')) 84 | 85 | # CSV output 86 | csv_writer = csv_open(f"oci-{profile_name}") 87 | 88 | # Search all resources 89 | # for region in (r for r in regions if r.region_name == 'eu-frankfurt-1'): 90 | for region in regions: 91 | 92 | config['region'] = region.region_name 93 | resource_search_client = oci.resource_search.ResourceSearchClient(config) 94 | db_client = oci.database.DatabaseClient(config) 95 | compute_client = oci.core.ComputeClient(config) 96 | analytics_client = oci.analytics.AnalyticsClient(config) 97 | integration_client = oci.integration.IntegrationInstanceClient(config) 98 | block_storage_client = oci.core.BlockstorageClient(config) 99 | object_store_client = oci.object_storage.ObjectStorageClient(config) 100 | file_storage_client = oci.file_storage.FileStorageClient(config) 101 | attached_volumes = [] 102 | 103 | # When the base compartment is not the tenancy root, filter on list of 104 | # supplied compartment_ids from compartment_list 105 | # This builds up the where clause for the query string 106 | compartment_filter = '' 107 | if base_compartment_id is not None: 108 | first = True 109 | for c in compartment_list: 110 | if not first: 111 | compartment_filter += ' || ' 112 | else: 113 | first = False 114 | compartment_filter += f" compartmentId = '{c['id']}'" 115 | 116 | try: 117 | 118 | # Get a list of all instances and the volumes attached to them so we can later spot volumes that are unattached 119 | instance_search_spec = oci.resource_search.models.StructuredSearchDetails() 120 | query_string = 'query Instance resources' 121 | if compartment_filter != '': 122 | query_string += ' where ' + compartment_filter 123 | 124 | instance_search_spec.query = query_string 125 | instances = resource_search_client.search_resources(search_details=instance_search_spec).data 126 | 127 | for instance in instances.items: 128 | compartment_id = instance.compartment_id 129 | instance_id = instance.identifier 130 | availability_domain = instance.availability_domain 131 | 132 | # Find all volumes attached to instances 133 | volume_attachments = oci.pagination.list_call_get_all_results( 134 | compute_client.list_volume_attachments, 135 | compartment_id=compartment_id, 136 | instance_id=instance_id 137 | ).data 138 | 139 | # Find all boot volumes attached 140 | boot_volume_attachments = oci.pagination.list_call_get_all_results( 141 | compute_client.list_boot_volume_attachments, 142 | compartment_id=compartment_id, 143 | instance_id=instance_id, 144 | availability_domain=availability_domain 145 | ).data 146 | 147 | # looping through all the volumes/bootVol attached and add it to the list 148 | for volume in volume_attachments: 149 | attached_volumes.append(volume.volume_id) 150 | 151 | for bootVolume in boot_volume_attachments: 152 | attached_volumes.append(bootVolume.boot_volume_id) 153 | 154 | # TODO: ADD: 155 | # ApiGateway 1M msgs/month 156 | # OKE 157 | # DataSafePrivateEndpoint (endpoints per month) 158 | # bastion 159 | 160 | resource_types = [ 161 | 'autonomousdatabase', 'autonomouscontainerdatabase', 'analyticsinstance', 162 | 'bootvolume', 'bootvolumebackup', 'bucket', 'database', 'dbsystem', 163 | 'datasafeprivateendpoint', 'loadbalancer', 'volumegroup', 164 | 'apigateway', 'apideployment', 165 | 'datasciencemodel', 'datasciencenotebooksession', 'datascienceproject', 166 | 'filesystem', 'functionsapplication', 'functionsfunction', 167 | 'image', 'instance', 'integrationinstance', 168 | 'mounttarget', 'oceinstance', 169 | 'odainstance', 'vault', 'vaultsecret', 'volume', 'volumegroup', 'volumebackup', 'volumegroupbackup' 170 | ] 171 | # resource_types = ['all'] 172 | 173 | # Some regions don't have all resource types, and query fails, so excelude certain types 174 | try: 175 | if region.region_name == 'us-sanjose-1': 176 | resource_types.remove('oceinstance') 177 | # resource_types.remove('datasciencemodel') 178 | # resource_types.remove('datasciencenotebooksession') 179 | # resource_types.remove('datascienceproject') 180 | elif region.region_name == 'eu-milan-1': 181 | resource_types.remove('oceinstance') 182 | elif region.region_name == 'eu-stockholm-1': 183 | resource_types.remove('oceinstance') 184 | except ValueError: 185 | pass # ignore value errors 186 | 187 | resource_type_list = ', '.join(resource_types) # To comma sep string 188 | 189 | # Not interested in terminated resources 190 | query_filter = "where lifecycleState != 'DELETED' " 191 | query_filter += "&& lifecycleState != 'TERMINATED' " 192 | query_filter += "&& lifecycleState != 'Terminated' " 193 | if compartment_filter != "": 194 | query_filter += " && (" + compartment_filter + ") " 195 | query_filter += "sorted by compartmentId asc" 196 | 197 | search_spec = oci.resource_search.models.StructuredSearchDetails() 198 | search_spec.query = f"query {resource_type_list} resources {query_filter}" 199 | 200 | resources = oci.pagination.list_call_get_all_results( 201 | resource_search_client.search_resources, 202 | search_details=search_spec 203 | ).data 204 | 205 | # Skip compartments as a resource type (OCI where clause doesn't seem to support this filter) 206 | exclude_types = ['Compartment', 'User'] 207 | resource_generator = (r for r in resources if r.resource_type not in exclude_types) 208 | for resource in resource_generator: 209 | 210 | debug_out(f'ID: {resource.identifier}, Type: {resource.resource_type}') 211 | 212 | # Some items do not have a display name (eg. Tag Namespace) 213 | resource_name = '-' if resource.display_name is None else resource.display_name 214 | 215 | db_workload = '' 216 | shape = '' 217 | cpu_core_count = 0 218 | storage_gbs = 0.0 219 | byol_flag = '' 220 | volume_attachment_flag = '' 221 | 222 | # Dynamic tag used to identify creator, missing on some resources 223 | created_by = '' 224 | try: 225 | # Only interested in tracking down the creator (person), so strip off the 226 | # oracleidentitycloudservice/ before the username 227 | created_by = resource.defined_tags['Owner']['Creator'].replace('oracleidentitycloudservice/', '') 228 | except: 229 | # Ignore all errors such as tag missing 230 | pass 231 | 232 | # Some items do not return a lifecycle state (eg. Tags) 233 | state = '-' if resource.lifecycle_state is None else resource.lifecycle_state 234 | 235 | compartment_name = get_compartment_name(resource.compartment_id, compartment_list) 236 | 237 | if resource.resource_type == 'Instance': 238 | resource_detail = compute_client.get_instance(resource.identifier).data 239 | shape = resource_detail.shape 240 | cpu_core_count = int(resource_detail.shape_config.ocpus) 241 | 242 | if resource.resource_type == 'Bucket': 243 | namespace = object_store_client.get_namespace().data 244 | fields = ['approximateCount', 'approximateSize'] 245 | resource_detail = object_store_client.get_bucket(namespace, resource.display_name, fields=fields).data 246 | storage_gbs = resource_detail.approximate_size / 1e9 # Bytes to Gigabytes 247 | 248 | if resource.resource_type == 'FileSystem': 249 | resource_detail = file_storage_client.get_file_system(resource.identifier).data 250 | storage_gbs = resource_detail.metered_bytes / 1e9 # Bytes to Gigabytes 251 | 252 | elif resource.resource_type == 'AutonomousDatabase': 253 | resource_detail = db_client.get_autonomous_database(resource.identifier).data 254 | db_workload = resource_detail.db_workload 255 | cpu_core_count = resource_detail.cpu_core_count 256 | storage_gbs = resource_detail.data_storage_size_in_tbs * 1024.0 257 | byol_flag = BYOL if resource_detail.license_model == "BRING_YOUR_OWN_LICENSE" else NONBYOL 258 | 259 | elif resource.resource_type == 'Database': 260 | resource_detail = db_client.get_database(resource.identifier).data 261 | resource_name = resource_detail.db_name 262 | 263 | elif resource.resource_type == 'DbSystem': 264 | resource_detail = db_client.get_db_system(resource.identifier).data 265 | shape = resource_detail.shape 266 | storage_gbs = float(resource_detail.data_storage_size_in_gbs) 267 | cpu_core_count = resource_detail.cpu_core_count 268 | node_count = resource_detail.node_count 269 | 270 | # Get status of DB Node instead of the dbsystem 271 | # This more accurately reflects the status of the DB Server 272 | node_list = db_client.list_db_nodes(resource.compartment_id, db_system_id=resource.identifier) 273 | 274 | state = 'STOPPED (NODE)' 275 | for node in node_list.data: 276 | if node.lifecycle_state == 'AVAILABLE': 277 | state = 'AVAILABLE(NODE)' 278 | 279 | if node_count is not None and node_count > 1: 280 | shape = shape + '(x' + str(node_count) + ')' 281 | 282 | byol_flag = BYOL if resource_detail.license_model == "BRING_YOUR_OWN_LICENSE" else NONBYOL 283 | 284 | elif resource.resource_type == 'Volume': 285 | resource_detail = block_storage_client.get_volume(resource.identifier).data 286 | storage_gbs = float(resource_detail.size_in_gbs) 287 | 288 | elif resource.resource_type == 'BootVolume': 289 | resource_detail = block_storage_client.get_boot_volume(resource.identifier).data 290 | storage_gbs = float(resource_detail.size_in_gbs) 291 | 292 | elif resource.resource_type == 'BootVolumeBackup': 293 | resource_detail = block_storage_client.get_boot_volume_backup(resource.identifier).data 294 | storage_gbs = float(resource_detail.size_in_gbs) 295 | 296 | elif resource.resource_type == 'AnalyticsInstance': 297 | resource_detail = analytics_client.get_analytics_instance(resource.identifier).data 298 | if resource_detail.capacity.capacity_type == 'OLPU_COUNT': 299 | cpu_core_count = int(resource_detail.capacity.capacity_value) 300 | byol_flag = BYOL if resource_detail.license_type == "BRING_YOUR_OWN_LICENSE" else NONBYOL 301 | 302 | elif resource.resource_type == 'IntegrationInstance': 303 | resource_detail = integration_client.get_integration_instance(resource.identifier).data 304 | byol_flag = BYOL if resource_detail.is_byol else NONBYOL 305 | 306 | # Check if volumes are in use 307 | if resource.resource_type == 'Volume' or resource.resource_type == 'BootVolume': 308 | volume_attachment_flag = "Attached" if resource.identifier in attached_volumes else "Not Attached" 309 | 310 | output_dict = { 311 | 'Tenancy': tenancy_name, 312 | 'Region': region.region_name, 313 | 'Compartment': compartment_name, 314 | 'Type': resource.resource_type, 315 | 'Name': resource_name, 316 | 'State': state, 317 | 'DB': db_workload, 318 | 'Shape': shape, 319 | 'OCPU': cpu_core_count, 320 | 'GBytes': storage_gbs, 321 | 'BYOLstatus': byol_flag, 322 | 'VolAttached': volume_attachment_flag, 323 | 'Created': resource.time_created.strftime("%Y-%m-%d %H:%M:%S"), 324 | 'CreatedBy': created_by, 325 | 'OCID': resource.identifier 326 | } 327 | 328 | format_output(csv_writer, output_dict) 329 | 330 | except oci.exceptions.ServiceError as e: 331 | print(f"Error: {e.code}, {e.message} (region={region.region_name})", file=sys.stderr) 332 | 333 | except Exception as error: 334 | print(f'Error: {error}', file=sys.stderr) 335 | 336 | return 337 | 338 | 339 | # Traverse the compartment list to build the full compartment path 340 | def traverse(compartments, parent_id, parent_path, compartment_list): 341 | next_level_compartments = [c for c in compartments if c.compartment_id == parent_id] 342 | 343 | for compartment in next_level_compartments: 344 | # Skip the CASB compartment as it's only a proxy and throws an error 345 | # CASB compartment does not show up in the OCI console 346 | # Only look at ACTIVE compartments (deleted ones are still returned and throw permission errors) 347 | if compartment.name[0:17] != 'casb_compartment.' and compartment.lifecycle_state == 'ACTIVE': 348 | path = parent_path + '/' + compartment.name 349 | compartment_list.append( 350 | dict(id=compartment.id, name=compartment.name, path=path, state=compartment.lifecycle_state) 351 | ) 352 | traverse(compartments, parent_id=compartment.id, parent_path=path, compartment_list=compartment_list) 353 | return compartment_list 354 | 355 | 356 | def get_compartment_list(profile, base_compartment_id): 357 | global tenancy_name 358 | global regions 359 | global ADs 360 | global config 361 | 362 | # Load config data from ~/.oci/config 363 | config = oci.config.from_file(profile_name=profile) 364 | tenancy_id = config['tenancy'] 365 | 366 | identity = oci.identity.IdentityClient(config) 367 | tenancy_name = identity.get_tenancy(tenancy_id).data.name 368 | 369 | # Get Regions 370 | regions = identity.list_region_subscriptions(tenancy_id).data 371 | 372 | if base_compartment_id is None: 373 | base_compartment_id = tenancy_id 374 | 375 | # Get list of all compartments in tenancy 376 | compartments = oci.pagination.list_call_get_all_results( 377 | identity.list_compartments, tenancy_id, 378 | compartment_id_in_subtree=True).data 379 | 380 | comp = identity.get_compartment(base_compartment_id).data 381 | base_compartment_name = comp.name 382 | base_path = '/' + base_compartment_name 383 | 384 | # Got the flat list of compartments, now construct full path of each which makes it much easier to locate resources 385 | # Start with base compartment in dictionary 386 | compartment_path_list = [dict(id=base_compartment_id, name=base_compartment_name, path=base_path, state='Root')] 387 | 388 | # Recurse through all compartments starting at the required root to produce a sub-tree 389 | # with a path field like: /root/comp1/sub-comp1 etc. 390 | compartment_path_list = traverse(compartments, base_compartment_id, base_path, compartment_path_list) 391 | compartment_path_list = sorted(compartment_path_list, key=lambda c: c['path'].lower()) 392 | 393 | return compartment_path_list 394 | 395 | 396 | def csv_open(filename): 397 | csv_path = f'{output_dir}/{filename}.csv' 398 | 399 | csv_file = open(csv_path, 'wt') 400 | 401 | if debug: 402 | print('CSV File : ' + csv_path) 403 | 404 | csv_writer = csv.DictWriter( 405 | csv_file, 406 | lineterminator='\n', 407 | fieldnames=field_names, delimiter=',', 408 | dialect='excel', 409 | quotechar='"', quoting=csv.QUOTE_MINIMAL) 410 | 411 | csv_writer.writeheader() 412 | 413 | return csv_writer 414 | 415 | 416 | # Output a line for each cloud resource (output_dict should be a dictionary) 417 | def format_output(csv_writer, output_dict): 418 | 419 | try: 420 | # Readable format to stdout 421 | print(print_format.format(**output_dict)) 422 | 423 | # CSV to file 424 | csv_writer.writerow(output_dict) 425 | except csv.Error as error: 426 | print(f'Error {error} writing [{output_dict}]', file=sys.stderr) 427 | 428 | 429 | # Globals at tenancy level Regions & Compartments 430 | tenancy_name = '' 431 | config = {} 432 | regions = {} 433 | ADs = {} 434 | 435 | # Execute only if run as a script 436 | if __name__ == '__main__': 437 | 438 | # Get profile from command line 439 | parser = argparse.ArgumentParser(description='OCI Resources') 440 | 441 | # Positional, required, tenancy (profile) name 442 | parser.add_argument('profile_name', help="Name of OCI tenancy (config profile name)") 443 | # Optional compartment id 444 | parser.add_argument('-c', '--compartment-id', dest='compartment_id', action='store', 445 | metavar='', 446 | help='Compartment OCID', required=False) 447 | 448 | args = parser.parse_args() 449 | 450 | profile_name = args.profile_name 451 | compartment_id = args.compartment_id 452 | 453 | # Get list of compartments 454 | compartment_list = get_compartment_list(profile_name, compartment_id) 455 | 456 | start = time.time() 457 | # List all the resources in each compartment 458 | list_tenancy_resources(compartment_list, compartment_id) 459 | 460 | if debug: 461 | print(f'TIME TAKEN: {(time.time() - start):6.2f}') 462 | -------------------------------------------------------------------------------- /psm-resources.py: -------------------------------------------------------------------------------- 1 | # List all PSM Resources 2 | # 3 | # Parameters: 4 | # profile_name 5 | # 6 | # Other parameters picked up from config file using profile name 7 | # username 8 | # password 9 | # idcs_id (idcs-4656dbcafeb47777d3efabcdef12...) from idcs url 10 | # domain_id (cacct-8b4b0c9b4c40173264564750985ff6... select idcs_users in services from myservices page) 11 | # 12 | # Output 13 | # stdout, readable column format 14 | # 15 | # 27-nov-2019 1.0 mbridge Created 16 | # 22-april-2020 1.1 melkayal Added support for CSV file output 17 | 18 | import configparser 19 | import csv 20 | import os 21 | import sys 22 | from datetime import datetime 23 | import requests 24 | 25 | # ====================================================================================================================== 26 | debug: bool = False 27 | configfile = '~/.oci/config.ini' 28 | output_dir = "./log" 29 | # ====================================================================================================================== 30 | 31 | field_names = ['Tenancy', 'ServiceType', 'ServiceName', 'Creator', 'State', 'Region', 'CreationDate'] 32 | 33 | 34 | def list_psm_services(tenancy_name, username, password, idcs_guid): 35 | 36 | global csv_writer 37 | 38 | if debug: 39 | print(f'User:Pass = {username}/{"*" * len(password)}') 40 | print(f'IDCSID = {idcs_guid}') 41 | 42 | # Print Headings 43 | print( 44 | f"{'Tenancy':22} " 45 | f"{'Service Type':18} " 46 | f"{'Service Name':20.20} " 47 | f"{'Creator':28.28} " 48 | f"{'State':10} " 49 | f"{'Region':15} " 50 | f"{'CreationDate':32} ") 51 | 52 | # Full list - many are now disabled/obsolete in some OCI accounts 53 | # service_type_list = [ 54 | # "accs", "adbc", "adwc", "adwcp", "aiacs", "aipod", "analytics", "analyticssub", "andc", "andcp", 55 | # "apicatalog", "apics", "apicsauto", "apicsautopod", "autoanalytics", "autoanalyticsinst", "autoanalyticspod", 56 | # "autoblockchain", "bcsmgr", "bcsmgrpod", "bdcsce", "bigdataappliance", "botmxp", "botsaasauto", 57 | # "botscfg", "botscon", "botsint", "botsmgm", "botspip", "botsxp", "caching", "cec", "cecauto", "cecs", "cecsauto", 58 | # "container", "containerpod", "cxaana", "cxacfg", "cxacol", "cxapod", "dbcs", "demo", 59 | # "devserviceapp", "devserviceappauto", "devservicepod", "devservicepodauto", "dhcs", "dics", 60 | # "dipcauto", "dipcinst", "dipcpod", "erp", "ggcs", "integrationcauto", "integrationcloud", 61 | # "iotassetmon", "iotconnectedwrker", "iotenterpriseapps", "iotfleetmon", "iotjls", "iotprodmonitoring", "iotsvcasset", 62 | # "jcs", "mobileccc", "mobilecccom", "mobilecorepod", "mobilecorepodom", "mobileserviceauto", 63 | # "mobilestandard", "mobilestdccc", "mobilestdcore", "mysqlcs", "oabcsinst", "oabcspod", "oaics", 64 | # "oehcs", "oehpcs", "oicinst", "oicpod", "oicsubinst", "omce", "omcexternal", "omcp", 65 | # "ratscontrolplane", "search", "searchcloudapp", "soa", "ssi", "ssip", 66 | # # "stack", 67 | # "vbinst", "vbpod", "visualbuilder", "visualbuilderauto", "wtss"] 68 | 69 | service_type_list = ["adbc", "andc", "apicsauto", "autoanalytics", "autoanalyticsinst", "autoblockchain", "bcsmgr", 70 | "bdcsce", "botsaasauto", "cecsauto", "dbcs", "devserviceappauto", "dipcauto", "dipcinst", 71 | "integrationcauto", "jcs", "mobilestandard", "oabcsinst", "oehcs", "oehpcs", "oicinst", "omcexternal", 72 | "searchcloudapp", "soa", "ssi", "vbinst", "visualbuilderauto", "wtss"] 73 | 74 | for service_type in service_type_list: 75 | resp = requests.get( 76 | "https://psm.europe.oraclecloud.com/paas/api/v1.1/instancemgmt/" 77 | + idcs_guid + "/services/" + service_type + "/instances?limit=500", 78 | auth=(username, password), 79 | headers={'X-ID-TENANT-NAME': idcs_guid} 80 | ) 81 | 82 | if resp.status_code != 200: 83 | # This means something went wrong. 84 | print(f'Error in GET: {resp.status_code} ({resp.reason}) ' 85 | f'on tenancy {tenancy_name}, service type {service_type}', file=sys.stderr) 86 | # msg = json.loads(resp.text)['errorMessage'] 87 | # print(f' {msg}', file=sys.stderr) 88 | # return -1 89 | else: 90 | for services in resp.json()['services'].items(): 91 | svc = services[1] 92 | 93 | dttm = datetime.strptime(svc['creationDate'], "%Y-%m-%dT%H:%M:%S.%f%z") 94 | create_date = datetime.strftime(dttm, "%Y-%m-%d %H:%M:%S") 95 | 96 | # Region not always available (e.g. when service initializing) 97 | reg = svc.get('region', "N/A") 98 | 99 | print( 100 | f"{tenancy_name:22} " 101 | f"{svc['serviceType']:18} " 102 | f"{svc['serviceName']:20.20} " 103 | f"{svc['creator']:28.28} " 104 | f"{svc['state']:12} " 105 | f"{reg:15} " 106 | f"{create_date:32} ") 107 | 108 | output_dict = { 109 | 'Tenancy': tenancy_name, 110 | 'ServiceType': svc['serviceType'], 111 | 'ServiceName': svc['serviceName'], 112 | 'Creator': svc['creator'], 113 | 'State': svc['state'], 114 | 'Region': reg, 115 | 'CreationDate': create_date 116 | } 117 | 118 | format_output(output_dict) 119 | 120 | # TODO: Handle isBYOL flag 121 | return 122 | 123 | 124 | def tenancy_usage(tenancy_name): 125 | 126 | # Just in case we use the tilde (~) home directory character 127 | configfilepath = os.path.expanduser(configfile) 128 | 129 | if not os.path.isfile(configfilepath): 130 | print(f'Error: Config file not found ({configfilepath})', file=sys.stderr) 131 | sys.exit(0) 132 | 133 | config = configparser.ConfigParser() 134 | config.read(configfilepath) 135 | 136 | ini_data = config[tenancy_name] 137 | 138 | # Get all service details 139 | list_psm_services(tenancy_name, ini_data['username'], ini_data['password'], ini_data['idcs_guid']) 140 | 141 | 142 | def csv_open(filename): 143 | csv_path = f'{output_dir}/{filename}.csv' 144 | 145 | csv_file = open(csv_path, 'wt') 146 | 147 | if debug: 148 | print('CSV File : ' + csv_path) 149 | 150 | csv_writer = csv.DictWriter( 151 | csv_file, 152 | fieldnames=field_names, delimiter=',', 153 | dialect='excel', 154 | quotechar='"', quoting=csv.QUOTE_MINIMAL) 155 | 156 | csv_writer.writeheader() 157 | 158 | return csv_writer 159 | 160 | 161 | # Output a line for each cloud resource (output_dict should be a dictionary) 162 | def format_output(output_dict): 163 | global csv_writer 164 | 165 | try: 166 | # CSV to file 167 | csv_writer.writerow(output_dict) 168 | except Exception as error: 169 | print(f'Error {error.code} [{output_dict}', file=sys.stderr) 170 | 171 | 172 | if __name__ == "__main__": 173 | # Get profile from command line 174 | if len(sys.argv) != 2: 175 | print(f'Usage: {sys.argv[0]} ') 176 | sys.exit() 177 | else: 178 | tenancy_name = sys.argv[1] 179 | 180 | csv_writer = csv_open(f"psm-{tenancy_name}") 181 | 182 | tenancy_usage(tenancy_name) 183 | 184 | if debug: 185 | print('DONE') 186 | -------------------------------------------------------------------------------- /usage_cost_total.py: -------------------------------------------------------------------------------- 1 | # OCI Usage Cost 2 | # Get Oracle cloud usage costs for given data range 3 | # 4 | # Parameters: 5 | # profile_name 6 | # start_date 7 | # end_date 8 | # 9 | # Other parameters picked up from config file using profile name 10 | # username 11 | # password 12 | # idcs_id (idcs-4656dbcafeb47777d3efabcdef12...) from idcs url 13 | # domain_id (cacct-8b4b0c9b4c40173264564750985ff6... select idcs_users in services from myservices page) 14 | # 15 | # Output 16 | # stdout, readable column format 17 | # 18 | # 08-jan-2018 1.0 mbridge Created 19 | # 25-jan-2018 1.1 mbridge Handle overage charges in service costs 20 | # 31-oct-2019 1.2 mbridge Simplified output using f-strings (requires python 3.6) 21 | # 10-feb-2020 1.3 mbridge Allow choice of CSV or tablular output 22 | # 29-may-2020 1.4 mbridge Make end-date non-inclusive (i.e. start_date <= d < end_date) 23 | # 13-apr-2021 1.5 mbridge Improved command line parameters using argparse 24 | # 06-jul-2021 1.6 mbridge Added list price lookup 25 | 26 | import argparse 27 | import configparser 28 | import csv 29 | import json 30 | import os 31 | import re 32 | import sys 33 | from datetime import datetime 34 | from string import Formatter 35 | 36 | import requests 37 | 38 | # ====================================================================================================================== 39 | output_format = "CSV" # CSV or normal output, set to "CSV" or anything else 40 | configfile = '~/.oci/config.ini' 41 | # ====================================================================================================================== 42 | 43 | # Dictionary keys and headings 44 | field_names = [ 45 | 'Tenancy', 'ServiceName', 'ResourceName', 'SKU', 'Qty', 46 | 'UnitPrc', 'Total', 'Cur', 'OvrFlg', 'ComputeType', 47 | 'CalcUnitPrc', 'CalcLineCost', 'ListUnitPrc', 'ListLineCost'] 48 | 49 | print_format = "{Tenancy:24} {ServiceName:24} {ResourceName:58.58} {SKU:6} {Qty:>10.3f} " \ 50 | "{UnitPrc:>10.6f} {Total:>7.2f} {Cur:3} {OvrFlg:>6} {ComputeType:11.11} " \ 51 | "{CalcUnitPrc:>10.6f} {CalcLineCost:>8.2f} " \ 52 | "{ListUnitPrc:>10.6f} {ListLineCost:>7.2f}" 53 | 54 | header_format = re.sub('{[A-Z,a-z]*', '{', print_format) # Header format removes the named placeholders 55 | header_format = re.sub('\.[0-9]*f', 's', header_format) # Change number formats to string for heading output 56 | 57 | 58 | # Output a line for each cloud resource (output_dict should be a dictionary) 59 | def format_output(output_dict, format): 60 | global csv_writer 61 | 62 | if format == "CSV": 63 | # CSV to file 64 | csv_writer.writerow(output_dict) 65 | else: 66 | # Readable format to stdout 67 | print(print_format.format(**output_dict)) 68 | 69 | 70 | def csv_init(): 71 | csv_writer = csv.DictWriter( 72 | sys.stdout, 73 | lineterminator='\n', 74 | fieldnames=field_names, delimiter=',', 75 | dialect='excel', 76 | quotechar='"', quoting=csv.QUOTE_MINIMAL) 77 | 78 | if detail: 79 | csv_writer.writeheader() 80 | 81 | return csv_writer 82 | 83 | 84 | # Get simplified price list (SKU + prices) 85 | def get_price_list(currency_code): 86 | url = "https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=500" 87 | http_header = {'X-Oracle-Accept-CurrencyCode': currency_code} 88 | resp = requests.get(url, headers=http_header) 89 | items = resp.json()['items'] 90 | 91 | price_list = {} 92 | for item in items: 93 | 94 | partNum = item['partNumber'] 95 | payg_price = 0 96 | month_price = 0 97 | 98 | # Only look at first 2 pricing elements 99 | # Some items, such as B88327 (Outbound Data Transfer) have a free amount before charging kicks in 100 | # So for easy approximation, we will ignore charged amount 101 | # TODO: Fix this horrible hack! 102 | 103 | # Some items, such as 'B94418 - Oracle Cloud Program - Universal Credits - Research Cloud Starter', 104 | # do not have a price entry, so skip 105 | prices = item.get('prices', None) 106 | if prices is not None: 107 | for price in prices: 108 | if price['model'] == 'PAY_AS_YOU_GO': 109 | payg_price = float(price['value']) 110 | elif price['model'] == 'MONTHLY_COMMIT': 111 | month_price = float(price['value']) 112 | else: 113 | print(f"Unknown price type: {price['model']}") 114 | 115 | price_list[partNum] = {"payg_price": payg_price, "month_price": month_price} 116 | 117 | return price_list 118 | 119 | 120 | def get_account_charges(tenancy_name, username, password, domain, idcs_guid, start_time, end_time): 121 | global csv_writer 122 | 123 | price_list = get_price_list("GBP") 124 | 125 | if debug: 126 | print(f'User:Pass = {username}/{"*" * len(password)}') 127 | print(f'Domain, IDCSID = {domain} {idcs_guid}') 128 | print(f'Start/End Time = {start_time} to {end_time}') 129 | 130 | # Oracle API needs the milliseconds explicitly 131 | # UsageType can be TOTAL, HOURLY or DAILY. 132 | url_params = { 133 | 'startTime': start_time.isoformat() + '.000', 134 | 'endTime': end_time.isoformat() + '.000', 135 | 'usageType': 'TOTAL', 136 | 'dcAggEnabled': 'N', 137 | 'computeTypeEnabled': 'Y' 138 | } 139 | 140 | resp = requests.get( 141 | 'https://itra.oraclecloud.com/metering/api/v1/usagecost/' + domain, 142 | auth=(username, password), 143 | headers={'X-ID-TENANT-NAME': idcs_guid, 'accept-encoding': '*'}, 144 | params=url_params, 145 | timeout=600 146 | ) 147 | 148 | if resp.status_code != 200: 149 | # This means something went wrong. 150 | msg = json.loads(resp.text)['errorMessage'] 151 | print(f'Error in GET: {resp.status_code} ({resp.reason}) on tenancy {tenancy_name}', file=sys.stderr) 152 | print(f' {msg}', file=sys.stderr) 153 | return -1 154 | else: 155 | # Add the cost of all items returned 156 | bill_total_cost = 0 # Ignores 'Do Not Bill' costs 157 | calc_total_cost = 0 # Uses all quantities, but uses 'Usage' costs where available 158 | list_total_cost = 0 # Total cost at list price 159 | if detail: 160 | # Print Headings 161 | if output_format == "CSV": 162 | csv_writer = csv_init() 163 | else: 164 | vformat = Formatter().vformat 165 | print(vformat(header_format, field_names, '')) 166 | 167 | items = resp.json() 168 | 169 | for item in items['items']: 170 | # Each service could have multiple costs (e.g. in overage) 171 | # Because of an anomoly in billing, overage amounts use the wrong unitPrice 172 | # so take the unit price from the non-overage entry 173 | 174 | costs = item['costs'] 175 | calc_unit_price = 0 176 | std_unit_price = 0 177 | 178 | # TESTING 179 | # Find the pricing record for the non-overage amount 180 | # This only works if there are records for overage and non-overage in the same report range!! 181 | # This code is pretty ugly, but it's a quick (temporary!) test 182 | for cost in costs: 183 | if cost['overagesFlag'] == "N": 184 | std_unit_price = cost['unitPrice'] 185 | 186 | for cost in costs: 187 | 188 | if std_unit_price == 0: 189 | # Std price not found for non-overage, so just use the (probabl) overages one 190 | calc_unit_price = cost['unitPrice'] 191 | else: 192 | calc_unit_price = std_unit_price 193 | 194 | calc_line_item_cost = calc_unit_price * cost['computedQuantity'] 195 | calc_total_cost += calc_line_item_cost 196 | 197 | if cost['computeType'] == 'Usage': 198 | bill_total_cost += cost['computedAmount'] 199 | 200 | # Get list price of current item 201 | partNum = item['gsiProductId'] 202 | try: 203 | list_unit_price = price_list[partNum]['month_price'] 204 | except KeyError: 205 | list_unit_price = 0.0 206 | 207 | list_line_cost = cost['computedQuantity'] * list_unit_price 208 | list_total_cost += list_line_cost 209 | 210 | if detail: 211 | output_dict = { 212 | 'Tenancy': tenancy_name, 213 | 'ServiceName': item['serviceName'], 214 | 'ResourceName': item['resourceName'], 215 | 'SKU': partNum, 216 | 'Qty': cost['computedQuantity'], 217 | 'UnitPrc': cost['unitPrice'], 218 | 'Total': cost['computedAmount'], 219 | 'Cur': item['currency'], 220 | 'OvrFlg': cost['overagesFlag'], 221 | 'ComputeType': cost['computeType'], 222 | 'CalcUnitPrc': calc_unit_price, 223 | 'CalcLineCost': calc_line_item_cost, 224 | 'ListUnitPrc': list_unit_price, 225 | 'ListLineCost': list_line_cost 226 | } 227 | 228 | format_output(output_dict, output_format) 229 | 230 | return bill_total_cost, calc_total_cost, list_total_cost 231 | 232 | 233 | def tenancy_usage(tenancy_name, start_date, end_date, grand_total): 234 | 235 | # Just in case we use the tilde (~) home directory character 236 | configfilepath = os.path.expanduser(configfile) 237 | 238 | if not os.path.isfile(configfilepath): 239 | print(f'Error: Config file not found ({configfilepath})', file=sys.stderr) 240 | sys.exit(0) 241 | 242 | config = configparser.ConfigParser() 243 | config.read(configfilepath) 244 | 245 | ini_data = config[tenancy_name] 246 | 247 | # Show usage details 248 | # Set time component of end date to 23:59:59.999 to match the behaviour of the Oracle my-services dashboard 249 | bill_total_cost, calc_total_cost, list_total_cost = get_account_charges( 250 | tenancy_name, 251 | ini_data['username'], ini_data['password'], 252 | ini_data['domain'], ini_data['idcs_guid'], 253 | datetime.strptime(start_date, '%d-%m-%Y'), 254 | datetime.strptime(end_date, '%d-%m-%Y') # + timedelta(days=1, seconds=-0.001) 255 | ) 256 | 257 | if grand_total: 258 | # Simple output as I use it to feed a report 259 | print(f'{tenancy_name:24} {bill_total_cost:10.2f} (Billed) {calc_total_cost:10.2f} (Corrected) {list_total_cost:10.2f} (List)') 260 | 261 | 262 | if __name__ == "__main__": 263 | # Get profile from command line 264 | parser = argparse.ArgumentParser(description='OCI usage costs from a tenancy') 265 | 266 | # Positional 267 | parser.add_argument('tenancy', help="Name of OCI tenancy (config profile name)") 268 | parser.add_argument('start_date', help="Start date (dd-mm-yyyy')") 269 | parser.add_argument('end_date', help="End date, inclusive (dd-mm-yyyy')") 270 | parser.add_argument('--no-total', dest='total', action='store_false', default=True, help="Print summary costs") 271 | parser.add_argument('--debug', action='store_true', help="Print debug info") 272 | parser.add_argument('--detail', action='store_true', help="Show detailed breakdown of costs per service ") 273 | 274 | args = parser.parse_args() 275 | 276 | tenancy_name = args.tenancy 277 | start_date = args.start_date 278 | end_date = args.end_date 279 | grand_total = args.total 280 | debug = args.debug 281 | detail = args.detail 282 | 283 | tenancy_usage(tenancy_name, start_date, end_date, grand_total) 284 | --------------------------------------------------------------------------------