diff --git a/Scripts/README.md b/Scripts/README.md index a6ea279..d3717b3 100644 --- a/Scripts/README.md +++ b/Scripts/README.md @@ -13,3 +13,4 @@ These are example scripts that support the [Oracle Cloud Migrations](https://doc | [SetIP.py](SetIP/README.md) | Manage the private IP address of the target Assets in a migration plan | Python | 11 Nov 2024 | | [cbt.py](ChangeBlockTracking/README.md) | Check and configure Change Block Tracking for multiple VMs in vCenter | Python | 9 December 2024 | | [create_custom_image.py](create_custom_image/Readme.md) | Create zero byte custom image used for windows migration | Python | 3 October 2025 | +|[target\_asset\_report.py](target_asset_report/README.md)|Create target asset report across migration projects / plans within a compartment.|Python|8 April 2026| diff --git a/Scripts/target_asset_report/.gitignore b/Scripts/target_asset_report/.gitignore new file mode 100644 index 0000000..b47bff3 --- /dev/null +++ b/Scripts/target_asset_report/.gitignore @@ -0,0 +1,4 @@ +*.xlsx +*.xls +__pycache__/ +*.py[cod] diff --git a/Scripts/target_asset_report/LICENSE b/Scripts/target_asset_report/LICENSE new file mode 100644 index 0000000..4f38ce8 --- /dev/null +++ b/Scripts/target_asset_report/LICENSE @@ -0,0 +1,36 @@ +Copyright (c) 2020, 2024, Oracle and/or its affiliates. + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a “Larger Work” to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: + +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Scripts/target_asset_report/README.md b/Scripts/target_asset_report/README.md new file mode 100644 index 0000000..71a142c --- /dev/null +++ b/Scripts/target_asset_report/README.md @@ -0,0 +1,37 @@ +# OCI Cloud Migrations Target Asset Reporting Tool + +This script generates a migration reporting table for a given OCI compartment by walking through: + +- Migration Projects (migrations) +- Migration Plans for each project +- Target Assets for each plan + +For each target asset, it reports: + +- Project and plan names +- Target asset name +- Target asset lifecycle state +- Excluded-from-execution flag +- `recommended_spec` JSON (non-null values only) +- `user_spec` JSON (non-null values only) + +The report is printed as a Markdown table and also saved to an Excel file (`.xlsx`). + +## Goal + +Provide a single report that helps compare migration target assets and their non-null specification details across all projects/plans in a compartment. + +## Parameters + +- `-cp `: OCI config profile name (default: `DEFAULT`) +- `-ip`: Use Instance Principals authentication +- `-dt`: Use Delegation Token authentication (Auto selected when running in cloud shell) +- `-log [file]`: Also write output to a log file (default file when omitted: `log.txt`) +- `-c, --compartment-id `: Compartment OCID to query (defaults to tenancy OCID when omitted) +- `--excel-file `: Excel output file name (default: `migration_report.xlsx`) + +## Usage + +```bash +python main.py -cp DEFAULT -c --excel-file migration_report.xlsx +``` \ No newline at end of file diff --git a/Scripts/target_asset_report/ocimodules/IAM.py b/Scripts/target_asset_report/ocimodules/IAM.py new file mode 100644 index 0000000..f762056 --- /dev/null +++ b/Scripts/target_asset_report/ocimodules/IAM.py @@ -0,0 +1,208 @@ +import oci +import time + +WaitRefresh = 10 +MaxIDeleteTagIteration = 5 + + +class OCICompartments: + fullpath = "" + level = 0 + details = oci.identity.models.Compartment() + + +def GetCompartments(identity, rootID): + retry = True + while retry: + retry = False + try: + # print("Getting compartments for {}".format(rootID)) + compartments = oci.pagination.list_call_get_all_results(identity.list_compartments, compartment_id=rootID, retry_strategy=oci.retry.DEFAULT_RETRY_STRATEGY).data + return compartments + except oci.exceptions.ServiceError as e: + if e.status == 429: + print("API busy.. retry", end="\r") + retry = True + time.sleep(WaitRefresh) + else: + print("bad error!: " + e.message) + return [] + + +def GetCompartmentFullPath(compartments, ocid): + """ + Given a list of OCICompartments objects and an OCID, + returns the full path of the compartment that matches the given OCID. + If not found, returns None. + """ + for compartment in compartments: + if hasattr(compartment, "details") and getattr(compartment.details, "id", None) == ocid: + return getattr(compartment, "fullpath", None) + return None + + + +################################################# +# Login # +################################################# +def Login(config, signer, startcomp, sso_user=False, get_compartments=False): + identity = oci.identity.IdentityClient(config, signer=signer) + if "user" in config: + try: + user = identity.get_user(config["user"]).data + print("Logged in as: {} @ {}".format(user.description, config["region"])) + except oci.exceptions.ServiceError as e: + if e.status == 404 and sso_user: + print("Warning: user not found — assuming SSO") + user = "IP-DT" + else: + raise e + else: + print("Logged in as: {} @ {}".format("InstancePrinciple/DelegationToken", config["region"])) + user = "IP-DT" + + c = [] + if get_compartments: + print ("Getting compartments...") + # Adding Start compartment + if "user" in config or ".tenancy." not in startcomp: + compartment = identity.get_compartment(compartment_id=startcomp, retry_strategy=oci.retry.DEFAULT_RETRY_STRATEGY).data + else: + # Bug fix - for working on root compartment using instance principle. + compartment = oci.identity.models.Compartment() + compartment.id = startcomp + compartment.name = "root compartment" + compartment.lifecycle_state = "ACTIVE" + + newcomp = OCICompartments() + newcomp.details = compartment + if ".tenancy." in startcomp: + newcomp.fullpath = "/root" + newcomp.level = 0 + else: + newcomp.level = 0 + newcomp.fullpath = compartment.name + c.append(newcomp) + + # Add first level subcompartments + compartments = GetCompartments(identity, startcomp) + + # Add 2nd level subcompartments + fullpath = newcomp.fullpath + "/" + for compartment in compartments: + if compartment.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = compartment + newcomp.fullpath = "{}{}".format(fullpath, compartment.name) + newcomp.level = 1 + c.append(newcomp) + subcompartments = GetCompartments(identity, compartment.id) + subpath1 = compartment.name + for sub1 in subcompartments: + if sub1.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub1 + newcomp.fullpath = "{}{}/{}".format(fullpath, subpath1, sub1.name) + newcomp.level = 2 + c.append(newcomp) + + subcompartments2 = GetCompartments(identity, sub1.id) + subpath2 = sub1.name + for sub2 in subcompartments2: + if sub2.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub2 + newcomp.fullpath = "{}{}/{}/{}".format(fullpath, subpath1, subpath2, sub2.name) + newcomp.level = 3 + c.append(newcomp) + + subcompartments3 = GetCompartments(identity, sub2.id) + subpath3 = sub2.name + for sub3 in subcompartments3: + if sub3.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub3 + newcomp.fullpath = "{}{}/{}/{}/{}".format(fullpath, subpath1, subpath2, subpath3, sub3.name) + newcomp.level = 4 + c.append(newcomp) + + subcompartments4 = GetCompartments(identity, sub3.id) + subpath4 = sub3.name + for sub4 in subcompartments4: + if sub4.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub4 + newcomp.fullpath = "{}{}/{}/{}/{}/{}".format(fullpath, subpath1, subpath2, + subpath3, subpath4, sub4.name) + newcomp.level = 5 + c.append(newcomp) + + subcompartments5 = GetCompartments(identity, sub4.id) + subpath5 = sub4.name + for sub5 in subcompartments5: + if sub5.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub5 + newcomp.fullpath = "{}{}/{}/{}/{}/{}/{}".format(fullpath, subpath1, subpath2, subpath3, subpath4, subpath5, sub5.name) + newcomp.level = 6 + c.append(newcomp) + + subcompartments6 = GetCompartments(identity, sub5.id) + subpath6 = sub5.name + for sub6 in subcompartments6: + if sub6.lifecycle_state == "ACTIVE": + newcomp = OCICompartments() + newcomp.details = sub6 + newcomp.fullpath = "{}{}/{}/{}/{}/{}/{}/{}".format( + fullpath, + subpath1, + subpath2, + subpath3, + subpath4, + subpath5, subpath6, + sub6.name) + newcomp.level = 7 + c.append(newcomp) + + return c + + +################################################# +# SubscribedRegions +################################################# +def SubscribedRegions(config, signer): + regions = [] + identity = oci.identity.IdentityClient(config, signer=signer) + regionDetails = identity.list_region_subscriptions(tenancy_id=config["tenancy"]).data + + # Add subscribed regions to list + for detail in regionDetails: + regions.append(detail.region_name) + + return regions + + +################################################# +# GetHomeRegion +################################################# +def GetHomeRegion(config, signer): + home_region = "" + identity = oci.identity.IdentityClient(config, signer=signer) + regionDetails = identity.list_region_subscriptions(tenancy_id=config["tenancy"]).data + + # Set home region for connection + for reg in regionDetails: + if reg.is_home_region: + home_region = str(reg.region_name) + + return home_region + + +################################################# +# GetTenantName +################################################# +def GetTenantName(config, signer): + identity = oci.identity.IdentityClient(config, signer=signer) + tenancy = identity.get_tenancy(config['tenancy']).data + return tenancy.name + diff --git a/Scripts/target_asset_report/ocimodules/functions.py b/Scripts/target_asset_report/ocimodules/functions.py new file mode 100644 index 0000000..5ca58d5 --- /dev/null +++ b/Scripts/target_asset_report/ocimodules/functions.py @@ -0,0 +1,170 @@ +import argparse +import oci +import os +import sys +import time + +########################################################################## +# input_command_line +########################################################################## +def input_command_line(help=False): + + parser = argparse.ArgumentParser(formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=80, width=130)) + parser.add_argument('-cp', default="DEFAULT", dest='config_profile', help='Config Profile inside the config file') + parser.add_argument('-ip', action='store_true', default=False, dest='is_instance_principals', help='Use Instance Principals for Authentication') + parser.add_argument('-dt', action='store_true', default=False, dest='is_delegation_token', help='Use Delegation Token for Authentication') + parser.add_argument("-log", nargs='?', const='log.txt', default="", dest='log_file', help="Output also to logfile. If logfile not specified, will log to log.txt") + parser.add_argument( + "-c", + "--compartment-id", + dest="compartment_id", + default="", + help="Compartment OCID to query. Defaults to tenancy OCID from config if omitted." + ) + parser.add_argument( + "--excel-file", + dest="excel_file", + default="migration_report.xlsx", + help="Excel output filename for the migration report table." + ) + + cmd = parser.parse_args() + + # If running in Cloud Shell (OCI_CLI_CLOUD_SHELL=true), default to Delegation Token + if os.environ.get("OCI_CLI_CLOUD_SHELL", "").lower() == "true": + print("Running in Cloud Shell..") + cmd.is_delegation_token = True + cmd.is_instance_principals = False + cmd.config_profile = "DEFAULT" + + if help: + parser.print_help() + + return cmd + +########################################################################## +# Create signer for Authentication +# Input - config_profile and is_instance_principals and is_delegation_token +# Output - config and signer objects +########################################################################## +def create_signer(config_profile, is_instance_principals, is_delegation_token): + + # if instance principals authentications + if is_instance_principals: + try: + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + config = {'region': signer.region, 'tenancy': signer.tenancy_id} + return config, signer + + except Exception: + print("Error obtaining instance principals certificate, aborting") + sys.exit(-1) + + # ----------------------------- + # Delegation Token + # ----------------------------- + elif is_delegation_token: + + try: + # check if env variables OCI_CONFIG_FILE, OCI_CONFIG_PROFILE exist and use them + env_config_file = os.environ.get('OCI_CONFIG_FILE') + env_config_section = os.environ.get('OCI_CONFIG_PROFILE') + + # check if file exist + if env_config_file is None or env_config_section is None: + print("*** OCI_CONFIG_FILE and OCI_CONFIG_PROFILE env variables not found, abort. ***") + print("") + sys.exit(-1) + + config = oci.config.from_file(env_config_file, env_config_section) + delegation_token_location = config["delegation_token_file"] + + with open(delegation_token_location, 'r') as delegation_token_file: + delegation_token = delegation_token_file.read().strip() + # get signer from delegation token + signer = oci.auth.signers.InstancePrincipalsDelegationTokenSigner(delegation_token=delegation_token) + + return config, signer + + except KeyError: + print("* Key Error obtaining delegation_token_file") + sys.exit(-1) + + except Exception: + raise + + # ----------------------------- + # config file authentication + # ----------------------------- + else: + try: + config = oci.config.from_file( + oci.config.DEFAULT_LOCATION, + (config_profile if config_profile else oci.config.DEFAULT_PROFILE) + ) + signer = oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config.get("key_file"), + pass_phrase=oci.config.get_config_value_or_default(config, "pass_phrase"), + private_key_content=config.get("key_content") + ) + except Exception: + print("Error obtaining authentication, did you configure config file? aborting") + sys.exit(-1) + + return config, signer + + +########################################################################## +# Checking SDK Version +# Minimum version requirements for OCI SDK +########################################################################## +def check_oci_version(min_oci_version_required): + outdated = False + + for i, rl in zip(oci.__version__.split("."), min_oci_version_required.split(".")): + if int(i) > int(rl): + break + if int(i) < int(rl): + outdated = True + break + + if outdated: + print("Your version of the OCI SDK is out-of-date. Please first upgrade your OCI SDK Library bu running the command:") + print("OCI SDK Version : {}".format(oci.__version__)) + print("Min SDK required: {}".format(min_oci_version_required)) + print("pip install --upgrade oci") + quit() + + +############################################# +# MyWriter to redirect output +############################################# +def CurrentTimeString(): + return time.strftime("%D %H:%M:%S", time.localtime()) + +class MyWriter: + + #filename = "log.txt" + + def __init__(self, stdout, filename): + self.stdout = stdout + self.filename = filename + self.logfile = open(self.filename, "a", encoding="utf-8") + + def write(self, text): + self.stdout.write(text) + self.logfile.write(text) + + def close(self): + self.stdout.close() + self.logfile.close() + + def flush(self): + self.logfile.close() + self.logfile = open(self.filename, "a", encoding="utf-8") + + + diff --git a/Scripts/target_asset_report/requirements.txt b/Scripts/target_asset_report/requirements.txt new file mode 100644 index 0000000..82c1bb3 --- /dev/null +++ b/Scripts/target_asset_report/requirements.txt @@ -0,0 +1,2 @@ +oci>=2.129.0 +openpyxl diff --git a/Scripts/target_asset_report/target_asset_report.py b/Scripts/target_asset_report/target_asset_report.py new file mode 100644 index 0000000..4d4877b --- /dev/null +++ b/Scripts/target_asset_report/target_asset_report.py @@ -0,0 +1,212 @@ +import sys +import json +import oci +from openpyxl import Workbook + +from ocimodules.functions import input_command_line, create_signer, MyWriter +from ocimodules.IAM import Login + + +def _prune_nulls(value): + """Return object with null values removed recursively.""" + if value is None: + return None + + if isinstance(value, dict): + cleaned = {} + for key, nested_value in value.items(): + pruned = _prune_nulls(nested_value) + if pruned is not None: + cleaned[key] = pruned + return cleaned if cleaned else None + + if isinstance(value, list): + cleaned_list = [] + for nested_value in value: + pruned = _prune_nulls(nested_value) + if pruned is not None: + cleaned_list.append(pruned) + return cleaned_list if cleaned_list else None + + return value + + +def _escape_markdown_cell(value): + text = str(value) if value is not None else "" + return text.replace("|", "\\|").replace("\n", "
") + + +# Disable OCI CircuitBreaker feature +oci.circuit_breaker.NoCircuitBreakerStrategy() + +################################################# +# Application Configuration # +################################################# +min_version_required = "2.164.0" +application_version = "08.04.2026" + + +########################################################################## +# Main Program +########################################################################## + +print ("OCI - Oracle Cloud Migration reporting tool") +print ("This utility help you to report the target assets in all the migration projects/plans in a compartment") +print ("=======================================================================================================") +print ("") + +# Check command line parameters +cmd = input_command_line() + +# if logging to file, overwrite default print function to also write to file +if cmd.log_file != "": + writer = MyWriter(sys.stdout, cmd.log_file) + sys.stdout = writer + +################################################# +# oci config and "login" check +###################################################### +config, signer = create_signer(cmd.config_profile, cmd.is_instance_principals, cmd.is_delegation_token) +tenant_id = config['tenancy'] + +compartments= Login(config, signer, tenant_id, get_compartments=False) +print(f"Current configured region is: {config['region']}") + +# Use user-provided compartment OCID, fallback to tenancy OCID. +target_compartment_id = cmd.compartment_id if cmd.compartment_id else tenant_id +print(f"Target compartment OCID: {target_compartment_id}") +print("") + +migration_client = oci.cloud_migrations.MigrationClient(config=config, signer=signer) + +try: + migrations = oci.pagination.list_call_get_all_results( + migration_client.list_migrations, + compartment_id=target_compartment_id + ).data +except oci.exceptions.ServiceError as e: + print(f"Unable to list migration projects/migrations: {e.message}") + raise + +if not migrations: + print("No migration projects found in the selected compartment.") + raise SystemExit(0) + +print(f"Found {len(migrations)} migration project(s):") +print("") + +table_rows = [] + +for idx, migration in enumerate(migrations, 1): + migration_name = getattr(migration, "display_name", migration.id) + + plans = oci.pagination.list_call_get_all_results( + migration_client.list_migration_plans, + compartment_id=target_compartment_id, + migration_id=migration.id + ).data + + if not plans: + continue + + for pidx, plan in enumerate(plans, 1): + plan_name = getattr(plan, "display_name", plan.id) + + target_assets = oci.pagination.list_call_get_all_results( + migration_client.list_target_assets, + migration_plan_id=plan.id + ).data + + if not target_assets: + table_rows.append({ + "project_name": migration_name, + "plan_name": plan_name, + "target_asset_name": "", + "target_asset_lifecycle_state": "", + "excluded_from_execution": "", + "recommended_spec_json": "", + "user_spec_json": "" + }) + continue + + for aidx, asset in enumerate(target_assets, 1): + asset_name = getattr(asset, "display_name", getattr(asset, "id", "unknown")) + asset_id = getattr(asset, "id", "n/a") + + try: + asset_details = migration_client.get_target_asset(asset_id).data + except oci.exceptions.ServiceError as e: + print(f" unable to get target asset details: {e.message}") + continue + + user_spec = getattr(asset_details, "user_spec", None) + recommended_spec = getattr(asset_details, "recommended_spec", None) + + if user_spec is None: + non_null_user_spec = None + else: + user_spec_dict = oci.util.to_dict(user_spec) + non_null_user_spec = _prune_nulls(user_spec_dict) + + if recommended_spec is None: + non_null_recommended_spec = None + else: + recommended_spec_dict = oci.util.to_dict(recommended_spec) + non_null_recommended_spec = _prune_nulls(recommended_spec_dict) + + table_rows.append({ + "project_name": migration_name, + "plan_name": plan_name, + "target_asset_name": asset_name, + "target_asset_lifecycle_state": getattr(asset_details, "lifecycle_state", ""), + "excluded_from_execution": getattr(asset_details, "is_excluded_from_execution", ""), + "recommended_spec_json": json.dumps(non_null_recommended_spec, sort_keys=True) if non_null_recommended_spec else "", + "user_spec_json": json.dumps(non_null_user_spec, sort_keys=True) if non_null_user_spec else "" + }) + +if not table_rows: + print("No migration plans / target assets found for the selected compartment.") + raise SystemExit(0) + +print("Migration Report Table") +print("") +headers = [ + "Migration Project Name", + "Migration Plan Name", + "Target Asset Name", + "Target Asset Lifecycle State", + "Excluded From Execution", + "Recommended Spec (JSON, Non-Null)", + "User Spec (JSON, Non-Null)" +] +print("| " + " | ".join(headers) + " |") +print("|" + "|".join(["---"] * len(headers)) + "|") +excel_rows = [] +for row in table_rows: + row_values = [ + row["project_name"], + row["plan_name"], + row["target_asset_name"], + row["target_asset_lifecycle_state"], + row["excluded_from_execution"], + row["recommended_spec_json"], + row["user_spec_json"] + ] + excel_rows.append(row_values) + + print( + "| " + " | ".join(_escape_markdown_cell(v) for v in row_values) + " |" + ) + +# Save to Excel +workbook = Workbook() +sheet = workbook.active +sheet.title = "Migration Report" +sheet.append(headers) +for excel_row in excel_rows: + sheet.append(excel_row) + +workbook.save(cmd.excel_file) +print("") +print(f"Excel report saved: {cmd.excel_file}") +